├── .github
└── funding.yml
├── .gitignore
├── media
├── icon.ai
├── promo.png
├── screenshot.gif
├── screenshot-settings.png
└── screenshot-webstore.png
├── source
├── icon.png
├── features
│ ├── hide-promoted-tweets.js
│ ├── hide-retweet-buttons.js
│ ├── hide-retweets.js
│ ├── hide-like-tweets.js
│ ├── hide-follow-tweets.js
│ ├── hide-in-case-you-missed-notifications.js
│ ├── clean-navbar-dropdown.js
│ ├── hide-trends-and-who-to-follow.js
│ ├── remove-profile-header.js
│ ├── auto-load-new-tweets.js
│ ├── disable-custom-colors.js
│ ├── likes-button-navbar.js
│ ├── mentions-highlight.js
│ ├── inline-code.js
│ ├── keyboard-shortcuts.js
│ ├── inline-instagram-photos.js
│ ├── image-alternatives.js
│ ├── code-highlight.js
│ ├── preserve-text-messages.js
│ └── index.js
├── options.html
├── manifest.json
├── options.js
├── background.js
├── style
│ ├── code-highlight.css
│ └── content.css
├── content.js
└── libs
│ └── utils.js
├── .gitattributes
├── test
├── index.js
└── fixtures
│ └── window.js
├── .editorconfig
├── .travis.yml
├── webpack.config.js
├── license
├── package.json
└── readme.md
/.github/funding.yml:
--------------------------------------------------------------------------------
1 | github: sindresorhus
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | yarn.lock
3 | distribution
4 | .DS_Store
5 |
--------------------------------------------------------------------------------
/media/icon.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sindresorhus/refined-twitter/HEAD/media/icon.ai
--------------------------------------------------------------------------------
/media/promo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sindresorhus/refined-twitter/HEAD/media/promo.png
--------------------------------------------------------------------------------
/source/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sindresorhus/refined-twitter/HEAD/source/icon.png
--------------------------------------------------------------------------------
/media/screenshot.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sindresorhus/refined-twitter/HEAD/media/screenshot.gif
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 | *.ai binary
3 | readme.md merge=union
4 | src/content.css merge=union
5 |
--------------------------------------------------------------------------------
/media/screenshot-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sindresorhus/refined-twitter/HEAD/media/screenshot-settings.png
--------------------------------------------------------------------------------
/media/screenshot-webstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sindresorhus/refined-twitter/HEAD/media/screenshot-webstore.png
--------------------------------------------------------------------------------
/source/features/hide-promoted-tweets.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | $('.promoted-tweet').parent().hide();
3 | }
4 |
--------------------------------------------------------------------------------
/source/features/hide-retweet-buttons.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | $('.ProfileTweet-action--retweet').hide();
3 | }
4 |
--------------------------------------------------------------------------------
/source/features/hide-retweets.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | $('.tweet-context .Icon--retweeted').parents('.js-stream-item').hide();
3 | }
4 |
--------------------------------------------------------------------------------
/source/features/hide-like-tweets.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | $('.tweet-context .Icon--heartBadge').parents('.js-stream-item').hide();
3 | }
4 |
--------------------------------------------------------------------------------
/source/features/hide-follow-tweets.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | $('[data-component-context="suggest_pyle_tweet"]').parents('.js-stream-item').hide();
3 | }
4 |
--------------------------------------------------------------------------------
/source/features/hide-in-case-you-missed-notifications.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | $('[data-component-context="generic_activity_MagicRecFirstDegreeTweetRecap"]').parents('.js-stream-item').hide();
3 | }
4 |
--------------------------------------------------------------------------------
/test/index.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import Window from './fixtures/window';
3 |
4 | global.window = new Window();
5 | global.location = window.location;
6 | global.document = {};
7 |
8 | test.todo('main');
9 |
--------------------------------------------------------------------------------
/source/features/clean-navbar-dropdown.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | $('#user-dropdown').find('[data-nav="all_moments"], [data-nav="ads"], [data-nav="promote-mode"], [data-nav="help_center"]').parent().hide();
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/window.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const {URL} = require('url');
3 |
4 | function WindowMock(initialURI = 'https://twitter.com') {
5 | this.location = new URL(initialURI);
6 | }
7 |
8 | module.exports = WindowMock;
9 |
--------------------------------------------------------------------------------
/source/features/hide-trends-and-who-to-follow.js:
--------------------------------------------------------------------------------
1 | import elementReady from 'element-ready';
2 |
3 | export default async function () {
4 | const sidebar = await elementReady('[data-testid=sidebarColumn]');
5 | $(sidebar).hide();
6 | }
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [*.yml]
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/source/features/remove-profile-header.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | $('.ProfileCanopy-header .ProfileCanopy-avatar').appendTo('.ProfileCanopy-inner .AppContainer');
3 | $('.ProfileCanopy-header').hide();
4 | $('.ProfileCardMini').hide();
5 | }
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 'node'
4 | env:
5 | - EXTENSION_ID=nlfgmdembofgodcemomfeimamihoknip
6 | deploy:
7 | - provider: script
8 | skip_cleanup: true
9 | script: npm run release
10 | on:
11 | branch: master
12 |
--------------------------------------------------------------------------------
/source/features/auto-load-new-tweets.js:
--------------------------------------------------------------------------------
1 | import {observeEl, isModalOpen} from '../libs/utils';
2 |
3 | export default function () {
4 | const el = $('.stream-container .stream-item')[0];
5 |
6 | observeEl(el, () => {
7 | if (isModalOpen()) {
8 | return;
9 | }
10 |
11 | const threshold = 20;
12 | const offsetY = document.body.getBoundingClientRect().top;
13 |
14 | if (offsetY <= -threshold) {
15 | return;
16 | }
17 |
18 | $('.new-tweets-bar', el).click();
19 | });
20 | }
21 |
--------------------------------------------------------------------------------
/source/features/disable-custom-colors.js:
--------------------------------------------------------------------------------
1 | import {isProfilePage, isOwnProfilePage, getUsername} from '../libs/utils';
2 |
3 | export default () => {
4 | if (isProfilePage() && !isOwnProfilePage()) {
5 | // An override class is created and persists temporarily after you change your color theme.
6 | const overrideSelector = `#user-style-override-${getUsername()}`;
7 | const targetSelector = `#user-style-${getUsername()}`;
8 | const userStyles = document.querySelector(overrideSelector) || document.querySelector(targetSelector);
9 | document.body.append(userStyles);
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/source/features/likes-button-navbar.js:
--------------------------------------------------------------------------------
1 | import {h} from 'dom-chef';
2 | import {safeElementReady} from '../libs/utils';
3 |
4 | export default async function () {
5 | const navBar = await safeElementReady('#global-actions');
6 |
7 | // Exit if it already exists
8 | if (document.querySelector('.refined-twitter_like-button')) {
9 | return;
10 | }
11 |
12 | navBar.append(
13 |
14 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/source/features/mentions-highlight.js:
--------------------------------------------------------------------------------
1 | import {getUsername} from '../libs/utils';
2 |
3 | function saveUserColor() {
4 | const html = document.querySelector('html');
5 | const newTweetButton = document.querySelector('#global-new-tweet-button');
6 |
7 | if (html && newTweetButton) {
8 | const bgColor = window.getComputedStyle(newTweetButton).backgroundColor;
9 | const userChoiceColorValues = /\((.*)\)/i.exec(bgColor)[1];
10 |
11 | html.style.setProperty('--refined-twitter_bgcolor-values', userChoiceColorValues);
12 | }
13 | }
14 |
15 | export default function () {
16 | saveUserColor();
17 |
18 | const username = getUsername();
19 | const mentions = document.querySelectorAll(`[data-mentions*=${username}]:not(.refined-twitter_mention)`);
20 |
21 | for (const el of mentions) {
22 | el.classList.add('refined-twitter_mention');
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/source/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Refined Twitter options
4 |
22 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/source/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Refined Twitter",
3 | "version": "0.0.0",
4 | "description": "Simplifies the Twitter interface and adds useful features",
5 | "homepage_url": "https://github.com/sindresorhus/refined-twitter",
6 | "manifest_version": 2,
7 | "minimum_chrome_version": "62",
8 | "permissions": [
9 | "storage",
10 | "downloads",
11 | "webRequest",
12 | "webRequestBlocking",
13 | "https://pbs.twimg.com/"
14 | ],
15 | "icons": {
16 | "128": "icon.png"
17 | },
18 | "options_ui": {
19 | "chrome_style": true,
20 | "page": "options.html"
21 | },
22 | "background": {
23 | "scripts": [
24 | "browser-polyfill.min.js",
25 | "background.js"
26 | ]
27 | },
28 | "content_scripts": [
29 | {
30 | "run_at": "document_start",
31 | "matches": [
32 | "https://twitter.com/*"
33 | ],
34 | "css": [
35 | "style/content.css",
36 | "style/code-highlight.css"
37 | ],
38 | "js": [
39 | "jquery.slim.min.js",
40 | "browser-polyfill.min.js",
41 | "content.js"
42 | ]
43 | }
44 | ]
45 | }
46 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const path = require('path');
3 | const CopyWebpackPlugin = require('copy-webpack-plugin');
4 | const SizePlugin = require('size-plugin');
5 |
6 | module.exports = {
7 | stats: 'errors-only',
8 | entry: {
9 | content: './source/content',
10 | background: './source/background',
11 | options: './source/options'
12 | },
13 | output: {
14 | path: path.join(__dirname, 'distribution'),
15 | filename: '[name].js'
16 | },
17 | module: {
18 | rules: [
19 | {
20 | test: /\.js$/,
21 | exclude: /node_modules/,
22 | loader: 'babel-loader'
23 | }
24 | ]
25 | },
26 | plugins: [
27 | new SizePlugin(),
28 | new CopyWebpackPlugin([
29 | {
30 | from: '*',
31 | context: 'source',
32 | ignore: '*.js'
33 | },
34 | {
35 | from: 'style/*',
36 | context: 'source'
37 | },
38 | {
39 | from: 'node_modules/webextension-polyfill/dist/browser-polyfill.min.js'
40 | },
41 | {
42 | from: 'node_modules/jquery/dist/jquery.slim.min.js'
43 | }
44 | ])
45 | ]
46 | };
47 |
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Sindre Sorhus (sindresorhus.com)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/source/options.js:
--------------------------------------------------------------------------------
1 | import OptionsSync from 'webext-options-sync';
2 | import {groupedFeatures} from './features';
3 |
4 | const element = document.querySelector('#features-placeholder');
5 | const _groupedFeaturesEntries = Object.entries(groupedFeatures);
6 | for (const [category, features] of _groupedFeaturesEntries) {
7 | // Hide category if it has only hidden configurations
8 | if (!features.find(feature => !feature.hidden)) {
9 | continue;
10 | }
11 |
12 | const section = document.createElement('section');
13 |
14 | const h4 = document.createElement('h4');
15 | h4.textContent = category.toUpperCase();
16 | section.append(h4);
17 |
18 | for (const feature of features) {
19 | const p = document.createElement('p');
20 |
21 | if (feature.hidden) {
22 | p.className = 'hidden-feature';
23 | }
24 |
25 | const label = document.createElement('label');
26 |
27 | const input = document.createElement('input');
28 | input.setAttribute('type', 'checkbox');
29 | input.setAttribute('name', feature.id);
30 | label.append(input);
31 |
32 | const labelText = document.createTextNode(` ${feature.label}`);
33 | label.append(labelText);
34 |
35 | p.append(label);
36 | section.append(p);
37 | }
38 |
39 | element.append(section);
40 | }
41 |
42 | new OptionsSync().syncForm('#options-form');
43 |
--------------------------------------------------------------------------------
/source/background.js:
--------------------------------------------------------------------------------
1 | import OptionsSync from 'webext-options-sync';
2 |
3 | import {featuresDefaultValues} from './features';
4 |
5 | const optionsSync = new OptionsSync();
6 |
7 | // Define defaults
8 | optionsSync.define({
9 | defaults: Object.assign({}, featuresDefaultValues, {
10 | logging: false
11 | }),
12 | migrations: [
13 | OptionsSync.migrations.removeUnused
14 | ]
15 | });
16 |
17 | // Make sure that all features have an option value
18 | optionsSync.getAll().then(options => {
19 | const newOptions = Object.assign({}, featuresDefaultValues, options);
20 | optionsSync.setAll(newOptions);
21 | });
22 |
23 | // Fix the extension when right-click saving a tweet image
24 | browser.downloads.onDeterminingFilename.addListener((item, suggest) => {
25 | suggest({
26 | filename: item.filename.replace(/\.(jpg|png)_(large|orig)$/, '.$1')
27 | });
28 | });
29 |
30 | browser.webRequest.onBeforeRequest.addListener(({url}) => {
31 | if (url.endsWith(':large')) {
32 | return {
33 | redirectUrl: url.replace(/:large$/, ':orig')
34 | };
35 | }
36 | }, {
37 | urls: ['https://pbs.twimg.com/media/*']
38 | }, ['blocking']);
39 |
40 | browser.runtime.onMessage.addListener(request => {
41 | if (request.contentScriptQuery === 'getInstagramPhotoUrl') {
42 | const url = `https://instagram.com/p/${request.postID}`;
43 | return fetch(url).then(response => response.text());
44 | }
45 | });
46 |
--------------------------------------------------------------------------------
/source/features/inline-code.js:
--------------------------------------------------------------------------------
1 | import {h} from 'dom-chef';
2 |
3 | function styleInlineCode(md) {
4 | return ;
5 | }
6 |
7 | function isElement(el) {
8 | return el instanceof HTMLElement || el instanceof Text;
9 | }
10 |
11 | function splitTextReducer(frag, text, index) {
12 | if (index % 2 && text.length >= 1) {
13 | // Code is always in odd positions
14 | frag.append(styleInlineCode(text));
15 | } else if (text.length > 0) {
16 | frag.append(text);
17 | }
18 |
19 | return frag;
20 | }
21 |
22 | export default function () {
23 | const splittingRegex = /`(.*?)`/g;
24 | const styledClassName = 'refined-twitter_monospaced-styled';
25 |
26 | $('.tweet-text').each((i, el) => {
27 | if ($(el).hasClass(styledClassName)) {
28 | return;
29 | }
30 |
31 | // Get everything in tweet
32 | const contents = Object.values($(el).contents());
33 | const text = contents.map(node => node.nodeValue || node);
34 | text.splice(-2); // Remove extraneous elements
35 |
36 | const frag = text.map(val => {
37 | // Style the single backticks while ignoring the already styled multiline code blocks
38 | if (isElement(val)) {
39 | return val;
40 | }
41 |
42 | return val
43 | .split(splittingRegex)
44 | .reduce(splitTextReducer, new DocumentFragment());
45 | });
46 |
47 | const flattened = Array.prototype.concat.apply([], frag);
48 | $(el).html(flattened);
49 | $(el).addClass(styledClassName);
50 | });
51 | }
52 |
--------------------------------------------------------------------------------
/source/features/keyboard-shortcuts.js:
--------------------------------------------------------------------------------
1 | const toggleNightMode = () => {
2 | const nightmodeToggle = document.querySelector('.nightmode-toggle');
3 | if (nightmodeToggle) {
4 | nightmodeToggle.click();
5 | }
6 | };
7 |
8 | export default () => {
9 | const customShortcuts = [
10 | {
11 | name: 'Actions',
12 | description: 'Shortcuts for common actions.',
13 | shortcuts: [
14 | {
15 | keys: [
16 | 'Alt',
17 | 'm'
18 | ],
19 | description: 'Toggle Night Mode'
20 | }
21 | ]
22 | },
23 | {
24 | name: 'Navigation',
25 | description: 'Shortcuts for navigating between items in timelines.',
26 | shortcuts: []
27 | },
28 | {
29 | name: 'Timelines',
30 | description: 'Shortcuts for navigating to different timelines or pages.',
31 | shortcuts: []
32 | }
33 | ];
34 |
35 | document.addEventListener('keydown', event => {
36 | const keyName = event.key;
37 | switch (keyName) {
38 | case 'µ': {
39 | toggleNightMode();
40 | break;
41 | }
42 |
43 | case 'm': {
44 | if (event.altKey) {
45 | toggleNightMode();
46 | }
47 |
48 | break;
49 | }
50 |
51 | default:
52 | break;
53 | }
54 | });
55 |
56 | const initDataElement = document.querySelector('#init-data');
57 | if (initDataElement) {
58 | const initData = JSON.parse(initDataElement.value);
59 | const updatedShortcuts = [...initData.keyboardShortcuts];
60 | for (const [i, item] of updatedShortcuts.entries()) {
61 | item.shortcuts = item.shortcuts.concat(customShortcuts[i].shortcuts);
62 | }
63 |
64 | initData.keyboardShortcuts = updatedShortcuts;
65 | initDataElement.value = JSON.stringify(initData);
66 | }
67 | };
68 |
--------------------------------------------------------------------------------
/source/features/inline-instagram-photos.js:
--------------------------------------------------------------------------------
1 | import {h} from 'dom-chef';
2 |
3 | const instagramUrls = new Map();
4 |
5 | function createPhotoElement(imageUrl, postUrl) {
6 | return (
7 |
12 | );
13 | }
14 |
15 | function createPhotoContainer() {
16 | return ;
17 | }
18 |
19 | async function getInstagramPhotoUrl(instagramPostUrl) {
20 | const imageRegex = /"display_url": ?"([^"]+)"/;
21 | const instagramSuffixRegex = /instagram\.com\/p\/([^/]+)/;
22 | const [, postID] = instagramSuffixRegex.exec(instagramPostUrl) || [];
23 | const instagramHTMLContent = await browser.runtime.sendMessage({
24 | contentScriptQuery: 'getInstagramPhotoUrl',
25 | postID
26 | });
27 | const [, instagramImageUrl] = imageRegex.exec(instagramHTMLContent) || [];
28 | return instagramImageUrl;
29 | }
30 |
31 | export default function () {
32 | $('.twitter-timeline-link[data-expanded-url*="//www.instagram.com/p/"]').each(async (idx, instagramAnchor) => {
33 | const tweetElement = $(instagramAnchor).parents('.js-tweet-text-container');
34 | const instagramPostUrl = instagramAnchor.dataset.expandedUrl;
35 | const shouldInlinePhoto = tweetElement.siblings('.AdaptiveMediaOuterContainer').length < 1;
36 |
37 | if (!shouldInlinePhoto) {
38 | return;
39 | }
40 |
41 | tweetElement.after(createPhotoContainer());
42 |
43 | let imageUrl = '';
44 |
45 | if (instagramUrls.has(instagramPostUrl)) {
46 | imageUrl = instagramUrls.get(instagramPostUrl);
47 | } else {
48 | imageUrl = await getInstagramPhotoUrl(instagramPostUrl);
49 | instagramUrls.set(instagramPostUrl, imageUrl);
50 | }
51 |
52 | const photoElement = createPhotoElement(imageUrl, instagramPostUrl);
53 | tweetElement.siblings('.AdaptiveMediaOuterContainer').html(photoElement);
54 | });
55 | }
56 |
--------------------------------------------------------------------------------
/source/features/image-alternatives.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | const imgContainers = document.querySelectorAll('.AdaptiveMedia-photoContainer, .Gallery-media');
3 | const imgAvatar = document.querySelector('.ProfileAvatar-image');
4 |
5 | for (const imgContainer of imgContainers) {
6 | // Exit if it already exists
7 | // Test on content because a same container can be reused (Gallery)
8 | if (imgContainer.querySelector('.refined-twitter_image-alt')) {
9 | continue;
10 | }
11 |
12 | const imgs = imgContainer.querySelectorAll('img');
13 | for (const img of imgs) {
14 | const imgAlt = img.getAttribute('alt');
15 |
16 | if (!imgAlt) {
17 | continue;
18 | }
19 |
20 | imgContainer.classList.add('refined-twitter_image-alt_container');
21 |
22 | // Current image equals the avatar on the current page
23 | if (imgAvatar && img.src === imgAvatar.src) {
24 | imgContainer.classList.add('refined-twitter_image-alt_profile-container');
25 | } else {
26 | // Remove previouly added classname if container is not for the profile avatar
27 | imgContainer.classList.remove('refined-twitter_image-alt_profile-container');
28 | }
29 |
30 | if (imgContainer.classList.contains('AdaptiveMedia-photoContainer')) {
31 | const ancestor1 = imgContainer.parentNode;
32 | ancestor1.classList.add('refined-twitter_image-alt_photocontainer');
33 | if (ancestor1.parentNode.classList.contains('AdaptiveMedia-container')) {
34 | const ancestor2 = ancestor1.parentNode;
35 | if (ancestor2.parentNode.classList.contains('is-square')) {
36 | ancestor2.parentNode.classList.add('refined-twitter_image-alt_ancestor-not-square');
37 | }
38 | }
39 | }
40 |
41 | const altDiv = document.createElement('div');
42 | altDiv.textContent = imgAlt;
43 |
44 | if (imgContainer.classList.contains('Gallery-media')) {
45 | altDiv.className = 'refined-twitter_image-alt refined-twitter_image-alt_top';
46 | img.parentNode.prepend(altDiv);
47 | } else {
48 | altDiv.className = 'refined-twitter_image-alt refined-twitter_image-alt_bottom';
49 | img.parentNode.append(altDiv);
50 | }
51 |
52 | img.classList.add('refined-twitter_image-alt_img');
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "lint": "xo && stylelint source/**/*.css",
4 | "test": "npm run lint && ava && npm run build",
5 | "build": "webpack --mode=production",
6 | "watch": "webpack --mode=development --watch",
7 | "release:cws": "cd distribution && webstore upload --auto-publish",
8 | "release": "run-s build update-version release:cws",
9 | "update-version": "VERSION=$(utc-version); echo $VERSION; dot-json distribution/manifest.json version $VERSION"
10 | },
11 | "dependencies": {
12 | "ajv": "^6.10.0",
13 | "dom-chef": "^3.6.0",
14 | "dom-loaded": "^1.2.0",
15 | "dompurify": "^1.0.10",
16 | "element-ready": "^3.0.0",
17 | "jquery": "^3.4.1",
18 | "lodash.debounce": "^4.0.8",
19 | "lodash.groupby": "^4.6.0",
20 | "lodash.pick": "^4.4.0",
21 | "lodash.sortby": "^4.7.0",
22 | "prismjs": "^1.16.0",
23 | "select-dom": "^5.1.0",
24 | "webext-options-sync": "^0.16.0",
25 | "webextension-polyfill": "^0.4.0"
26 | },
27 | "devDependencies": {
28 | "@babel/core": "^7.4.5",
29 | "@babel/plugin-transform-react-jsx": "^7.3.0",
30 | "@babel/register": "^7.4.4",
31 | "ava": "^1.4.1",
32 | "babel-eslint": "^10.0.1",
33 | "babel-loader": "^8.0.6",
34 | "chrome-webstore-upload-cli": "^1.1.1",
35 | "copy-webpack-plugin": "^5.0.3",
36 | "dot-json": "^1.1.0",
37 | "npm-run-all": "^4.1.1",
38 | "size-plugin": "^1.2.0",
39 | "stylelint": "^10.0.1",
40 | "stylelint-config-xo": "^0.15.0",
41 | "utc-version": "^1.0.0",
42 | "webpack": "^4.32.2",
43 | "webpack-cli": "^3.3.2",
44 | "xo": "^0.24.0"
45 | },
46 | "xo": {
47 | "parser": "babel-eslint",
48 | "envs": [
49 | "browser",
50 | "jquery"
51 | ],
52 | "rules": {
53 | "import/no-unassigned-import": 0,
54 | "no-unused-vars": [
55 | 2,
56 | {
57 | "varsIgnorePattern": "^h$"
58 | }
59 | ]
60 | },
61 | "globals": [
62 | "browser"
63 | ]
64 | },
65 | "ava": {
66 | "files": [
67 | "test/*.js"
68 | ],
69 | "require": [
70 | "@babel/register"
71 | ]
72 | },
73 | "babel": {
74 | "plugins": [
75 | [
76 | "@babel/plugin-transform-react-jsx",
77 | {
78 | "pragma": "h",
79 | "useBuiltIns": true
80 | }
81 | ]
82 | ]
83 | },
84 | "stylelint": {
85 | "extends": "stylelint-config-xo",
86 | "rules": {
87 | "declaration-no-important": null,
88 | "selector-class-pattern": null
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/source/features/code-highlight.js:
--------------------------------------------------------------------------------
1 | import {h} from 'dom-chef';
2 | import prism from 'prismjs';
3 | import {domify} from '../libs/utils';
4 | import 'prismjs/components/prism-jsx';
5 | import 'prismjs/components/prism-bash';
6 | import 'prismjs/components/prism-git';
7 | import 'prismjs/components/prism-typescript';
8 | import 'prismjs/components/prism-scss';
9 | import 'prismjs/components/prism-diff';
10 | import 'prismjs/components/prism-ruby';
11 | import 'prismjs/components/prism-rust';
12 | import 'prismjs/components/prism-swift';
13 | import 'prismjs/components/prism-java';
14 | import 'prismjs/components/prism-python';
15 | import 'prismjs/components/prism-r';
16 |
17 | const aliases = new Map([
18 | ['js', 'javascript'],
19 | ['shell', 'bash'],
20 | ['sh', 'bash'],
21 | ['zsh', 'bash'],
22 | ['py', 'python']
23 | ]);
24 |
25 | function pickLanguage(lang) {
26 | return aliases.get(lang) || lang;
27 | }
28 |
29 | function highlightCode(md) {
30 | const codeBlockRegex = /```(\w*)([\s\S]+)```/g;
31 | const [, lang, code] = codeBlockRegex.exec(md) || [];
32 | if (!code) {
33 | return md;
34 | }
35 |
36 | const selectedLang = pickLanguage(lang.toLowerCase());
37 | if (!selectedLang) {
38 | return (
39 |
44 | );
45 | }
46 |
47 | const highlightedCode = prism.highlight(code.trim(), prism.languages[selectedLang]);
48 |
49 | return (
50 |
57 | );
58 | }
59 |
60 | function splitTextReducer(frag, text, index) {
61 | if (index % 2) { // Code is always in odd positions
62 | frag.append(highlightCode(text));
63 | } else if (text.length > 0) {
64 | frag.append(text);
65 | }
66 |
67 | return frag;
68 | }
69 |
70 | export default function () {
71 | // Regex needs to be non-capturing ?: and to have the extra () to work with .split
72 | const splittingRegex = /((?:```\w*[\s\S]+```\n?))/g;
73 | $('.tweet-text').each((i, el) => {
74 | const tweetWithCode = el.textContent.split(splittingRegex);
75 | if (tweetWithCode.length === 1) {
76 | return;
77 | }
78 |
79 | const frag = tweetWithCode.reduce(splitTextReducer, new DocumentFragment());
80 | $(el).html(frag);
81 | });
82 | }
83 |
--------------------------------------------------------------------------------
/source/style/code-highlight.css:
--------------------------------------------------------------------------------
1 | /* PrismJS 1.9.0
2 | http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript+bash+ruby+diff+go+graphql+java+json+php+powershell+pug+python+jsx+rust+scss+scala+swift+typescript */
3 | /**
4 | * okaidia theme for JavaScript, CSS and HTML
5 | * Loosely based on Monokai textmate theme by http://www.monokai.nl/
6 | * @author ocodia
7 | */
8 |
9 | code[class*='language-'],
10 | pre[class*='language-'] {
11 | color: #c5c8c6;
12 | text-shadow: 0 1px rgba(0, 0, 0, 0.3);
13 | font-family: Inconsolata, Monaco, Consolas, 'Courier New', Courier, monospace;
14 | direction: ltr;
15 | text-align: left;
16 | white-space: pre;
17 | word-spacing: normal;
18 | word-break: normal;
19 | line-height: 1.5;
20 | position: relative;
21 | tab-size: 4;
22 | hyphens: none;
23 | }
24 |
25 | /* Code blocks */
26 | pre[class*='language-'] {
27 | padding: 1em;
28 | overflow: auto;
29 | border-radius: 0.3em;
30 | line-height: 0;
31 | }
32 |
33 | :not(pre) > code[class*='language-'],
34 | pre[class*='language-'] {
35 | background: #1d1f21;
36 | }
37 |
38 | /* Inline code */
39 | :not(pre) > code[class*='language-'] {
40 | padding: 0.1em;
41 | border-radius: 0.3em;
42 | }
43 |
44 | .token.comment,
45 | .token.prolog,
46 | .token.doctype,
47 | .token.cdata {
48 | color: #7c7c7c;
49 | }
50 |
51 | .token.punctuation {
52 | color: #c5c8c6;
53 | }
54 |
55 | .namespace {
56 | opacity: 0.7;
57 | }
58 |
59 | .token.property,
60 | .token.keyword,
61 | .token.tag {
62 | color: #96cbfe;
63 | }
64 |
65 | .token.class-name {
66 | color: #ffffb6;
67 | text-decoration: underline;
68 | }
69 |
70 | .token.boolean,
71 | .token.constant {
72 | color: #9c9;
73 | }
74 |
75 | .token.symbol,
76 | .token.deleted {
77 | color: #f92672;
78 | }
79 |
80 | .token.number {
81 | color: #ff73fd;
82 | }
83 |
84 | .token.selector,
85 | .token.attr-name,
86 | .token.string,
87 | .token.char,
88 | .token.builtin,
89 | .token.inserted {
90 | color: #a8ff60;
91 | }
92 |
93 | .token.variable {
94 | color: #c6c5fe;
95 | }
96 |
97 | .token.operator {
98 | color: #ededed;
99 | }
100 |
101 | .token.entity {
102 | color: #ffffb6;
103 | cursor: help;
104 | /* text-decoration: underline; */
105 | }
106 |
107 | .token.url {
108 | color: #96cbfe;
109 | }
110 |
111 | .language-css .token.string,
112 | .style .token.string {
113 | color: #87c38a;
114 | }
115 |
116 | .token.atrule,
117 | .token.attr-value {
118 | color: #f9ee98;
119 | }
120 |
121 | .token.function {
122 | color: #dad085;
123 | }
124 |
125 | .token.regex {
126 | color: #e9c062;
127 | }
128 |
129 | .token.important {
130 | color: #fd971f;
131 | }
132 |
133 | .token.important,
134 | .token.bold {
135 | font-weight: bold;
136 | }
137 |
138 | .token.italic {
139 | font-style: italic;
140 | }
141 |
142 | .refined-twitter_highlight {
143 | position: relative;
144 | border-radius: 0.3em;
145 | overflow: hidden;
146 | font-size: 14px;
147 | }
148 |
--------------------------------------------------------------------------------
/source/content.js:
--------------------------------------------------------------------------------
1 | import domLoaded from 'dom-loaded';
2 |
3 | import {
4 | enableFeature,
5 | observeEl,
6 | safeElementReady
7 | } from './libs/utils';
8 |
9 | import {autoInitFeatures, features} from './features';
10 |
11 | async function init() {
12 | await safeElementReady('body');
13 |
14 | if (document.body.classList.contains('logged-out')) {
15 | return;
16 | }
17 |
18 | document.documentElement.classList.add('refined-twitter');
19 |
20 | for (const feature of autoInitFeatures) {
21 | enableFeature(Object.assign({}, feature, {fn: feature.fn || (() => {})}));
22 | }
23 |
24 | await domLoaded;
25 | onDomReady();
26 | }
27 |
28 | function onRouteChange(cb) {
29 | observeEl('#doc', cb, {attributes: true});
30 | }
31 |
32 | function onNewTweets(cb) {
33 | observeEl('#stream-items-id', cb);
34 | }
35 |
36 | function onSingleTweetOpen(cb) {
37 | observeEl('body', mutations => {
38 | for (const mutation of mutations) {
39 | const {classList} = mutation.target;
40 | if (classList.contains('overlay-enabled')) {
41 | observeEl('#permalink-overlay', cb, {attributes: true, subtree: true});
42 | break;
43 | } else if (classList.contains('modal-enabled')) {
44 | observeEl('#global-tweet-dialog', cb, {attributes: true, subtree: true});
45 | break;
46 | }
47 | }
48 | }, {attributes: true});
49 | }
50 |
51 | function onGalleryItemOpen(cb) {
52 | observeEl('body', mutations => {
53 | for (const mutation of mutations) {
54 | if (mutation.target.classList.contains('gallery-enabled')) {
55 | observeEl('.Gallery-media', cb, {attributes: true, subtree: true});
56 | break;
57 | }
58 | }
59 | }, {attributes: true});
60 | }
61 |
62 | function onDomReady() {
63 | enableFeature(features.cleanNavbarDropdown);
64 | enableFeature(features.keyboardShortcuts);
65 | enableFeature(features.preserveTextMessages);
66 |
67 | onRouteChange(() => {
68 | enableFeature(features.autoLoadNewTweets);
69 | enableFeature(features.disableCustomColors);
70 | enableFeature(features.hideProfileHeader);
71 | enableFeature(features.hideTrendsAndWhoToFollowCards);
72 |
73 | onNewTweets(() => {
74 | enableFeature(features.codeHighlight);
75 | enableFeature(features.mentionHighlight);
76 | enableFeature(features.hideFollowTweets);
77 | enableFeature(features.hideLikeTweets);
78 | enableFeature(features.hideRetweets);
79 | enableFeature(features.hideRetweetButtons);
80 | enableFeature(features.hideNotificationsInCaseYouMissed);
81 | enableFeature(features.inlineInstagramPhotos);
82 | enableFeature(features.hidePromotedTweets);
83 | enableFeature(features.renderInlineCode);
84 | enableFeature(features.imageAlternatives);
85 | });
86 | });
87 |
88 | onSingleTweetOpen(() => {
89 | enableFeature(features.codeHighlight);
90 | enableFeature(features.mentionHighlight);
91 | enableFeature(features.inlineInstagramPhotos);
92 | enableFeature(features.renderInlineCode);
93 | enableFeature(features.imageAlternatives);
94 | enableFeature(features.hideRetweetButtons);
95 | });
96 |
97 | onGalleryItemOpen(() => {
98 | enableFeature(features.imageAlternatives);
99 | });
100 | }
101 |
102 | init();
103 |
--------------------------------------------------------------------------------
/source/libs/utils.js:
--------------------------------------------------------------------------------
1 | import {h} from 'dom-chef';
2 | import select from 'select-dom';
3 | import elementReady from 'element-ready';
4 | import domLoaded from 'dom-loaded';
5 | import OptionsSync from 'webext-options-sync';
6 |
7 | let options;
8 | const optionsPromise = new OptionsSync().getAll();
9 |
10 | /**
11 | * Enable toggling each feature via options.
12 | * Prevent fn's errors from blocking the remaining tasks.
13 | * https://github.com/sindresorhus/refined-github/issues/678
14 | */
15 | export const enableFeature = async ({fn, id: _featureId = fn.name}) => {
16 | if (!options) {
17 | options = await optionsPromise;
18 | }
19 |
20 | const {logging = false} = options;
21 | const log = logging ? console.log : () => {};
22 |
23 | const featureId = _featureId.replace(/_/g, '-');
24 | if (/^$|^anonymous$/.test(featureId)) {
25 | console.warn('This feature is nameless', fn);
26 | } else if (options[featureId] === false) {
27 | $('html').removeClass(featureId);
28 | log('↩️', 'Skipping', featureId);
29 | return;
30 | }
31 |
32 | try {
33 | $('html').addClass(featureId);
34 | await fn();
35 | log('✅', featureId);
36 | } catch (error) {
37 | console.log('❌', featureId);
38 | console.error(error);
39 | }
40 | };
41 |
42 | /**
43 | * Automatically stops checking for an element to appear once the DOM is ready.
44 | */
45 | export const safeElementReady = selector => {
46 | const waiting = elementReady(selector);
47 |
48 | // Don't check ad-infinitum
49 | domLoaded.then(() => requestAnimationFrame(() => waiting.cancel()));
50 |
51 | // If cancelled, return null like a regular select() would
52 | return waiting.catch(() => null);
53 | };
54 |
55 | export const observeEl = (el, listener, options = {childList: true}) => {
56 | if (typeof el === 'string') {
57 | el = select(el);
58 | }
59 |
60 | if (!el) {
61 | return;
62 | }
63 |
64 | // Run first
65 | listener([]);
66 |
67 | // Run on updates
68 | const observer = new MutationObserver(listener);
69 | observer.observe(el, options);
70 | return observer;
71 | };
72 |
73 | export const domify = html => {
74 | const div = document.createElement('div');
75 | div.innerHTML = html;
76 | return div;
77 | };
78 |
79 | export const getUsername = () => document.querySelector('.DashUserDropdown-userInfo .username > b').textContent;
80 |
81 | export const isModalOpen = () => {
82 | const hasPermalinkOverlay = $('#permalink-overlay').is(':visible');
83 | const isDMModalOpen = $('.modal').is(':visible');
84 | return hasPermalinkOverlay || isDMModalOpen;
85 | };
86 |
87 | export const isProfilePage = () => document.body.classList.contains('ProfilePage');
88 |
89 | export const isOwnProfilePage = () => isProfilePage() && document.body.classList.contains(`user-style-${getUsername()}`);
90 |
91 | export const getFromLocalStorage = value => {
92 | try {
93 | return browser.storage.local.get(value);
94 | } catch (error) {
95 | console.error(`Error while fetching ${value ? JSON.stringify(value, null, '\t') : 'everything'}: ${error}`);
96 | }
97 | };
98 |
99 | export const setToLocalStorage = async value => {
100 | try {
101 | await browser.storage.local.set(value);
102 | } catch (error) {
103 | console.error(`Error in storing ${JSON.stringify(value, null, '\t')} to local storage: ${error}`);
104 | }
105 | };
106 |
--------------------------------------------------------------------------------
/source/features/preserve-text-messages.js:
--------------------------------------------------------------------------------
1 | import pick from 'lodash.pick';
2 | import debounce from 'lodash.debounce';
3 | import DOMPurify from 'dompurify';
4 | import {
5 | getFromLocalStorage,
6 | setToLocalStorage,
7 | observeEl
8 | } from '../libs/utils';
9 |
10 | let isDMModalOpen = false;
11 |
12 | export default function () {
13 | onDMModalOpenAndClose(
14 | handleConversationOpen,
15 | handleDMModalClose
16 | );
17 |
18 | // When the user deletes a conversation remove it from local storage
19 | onMessageDelete(() => removeMessages(idsOfNotDeletedMsgs));
20 | }
21 |
22 | function onMessageDelete(cb) {
23 | const messageDelMutatioOptions = {
24 | childList: true,
25 | subtree: true,
26 | attributes: true
27 | };
28 |
29 | observeEl('body', mutations => {
30 | for (const mutation of mutations) {
31 | if (mutation.target.id === 'confirm_dialog') {
32 | const deleteButton = $('#confirm_dialog_submit_button');
33 |
34 | const onDeleteButtonClick = () => {
35 | cb();
36 | deleteButton.off('click');
37 | };
38 |
39 | deleteButton.on('click', onDeleteButtonClick);
40 | break;
41 | }
42 | }
43 | }, messageDelMutatioOptions);
44 | }
45 |
46 | function onDMModalOpenAndClose(handleConversationOpen, handleDMModalClose) {
47 | observeEl('#dm_dialog', mutations => {
48 | for (const mutation of mutations) {
49 | if (mutation.target.style.display === 'none') {
50 | isDMModalOpen = false;
51 |
52 | handleDMModalClose();
53 | break;
54 | } else {
55 | isDMModalOpen = true;
56 |
57 | observeEl('.DMConversation', mutations => {
58 | for (const mutation of mutations) {
59 | if (mutation.target.classList.contains('DMActivity--open')) {
60 | handleConversationOpen();
61 | break;
62 | }
63 | }
64 | }, {attributes: true, attributeFilter: ['class']});
65 |
66 | break;
67 | }
68 | }
69 | }, {attributes: true});
70 | }
71 |
72 | function handleDMModalClose() {
73 | removeMessages(idsOfNonEmptyMsgs);
74 | }
75 |
76 | function onMessageChange(cb) {
77 | const msgChangeMutationOptions = {
78 | childList: true,
79 | subtree: true,
80 | characterData: true
81 | };
82 |
83 | observeEl('#tweet-box-dm-conversation', debounce(() => {
84 | cb();
85 | }, 150), msgChangeMutationOptions);
86 | }
87 |
88 | function handleConversationOpen() {
89 | restoreSavedMessage();
90 |
91 | onMessageChange(() => handleMessageChange());
92 | }
93 |
94 | async function removeMessages(refinedMsgIdsMaker) {
95 | const {messages: savedMessages} = await getFromLocalStorage('messages');
96 |
97 | if (!savedMessages) {
98 | return;
99 | }
100 |
101 | const idsOfMsgToReSave = refinedMsgIdsMaker(savedMessages);
102 |
103 | const updatedMessages = {
104 | messages: pick(savedMessages, idsOfMsgToReSave)
105 | };
106 |
107 | setToLocalStorage(updatedMessages);
108 | }
109 |
110 | function idsOfNonEmptyMsgs(savedMessages) {
111 | return Object.keys(savedMessages)
112 | .filter(id => !isEmptyMsgInput(savedMessages[id]));
113 | }
114 |
115 | function idsOfNotDeletedMsgs(savedMessages) {
116 | return Object.keys(savedMessages)
117 | .filter(id => id !== getConversationId());
118 | }
119 |
120 | // See: https://gist.github.com/al3x-edge/1010364
121 | function setCursorToEnd(contentEditableElement) {
122 | window.requestAnimationFrame(() => {
123 | const range = document.createRange();
124 | const selection = window.getSelection();
125 | range.selectNodeContents(contentEditableElement);
126 | range.collapse(false);
127 | selection.removeAllRanges();
128 | selection.addRange(range);
129 | });
130 | }
131 |
132 | function getConversationId() {
133 | return document.querySelector('.DMConversation').dataset.threadId;
134 | }
135 |
136 | function getMessageContainer() {
137 | return $('#tweet-box-dm-conversation');
138 | }
139 |
140 | function isEmptyMsgInput(message) {
141 | const messageEl = document.createElement('div');
142 | messageEl.innerHTML = DOMPurify.sanitize(message);
143 | return messageEl.textContent === '';
144 | }
145 |
146 | async function restoreSavedMessage() {
147 | const conversationId = getConversationId();
148 | const messageContainer = getMessageContainer();
149 | if (conversationId) {
150 | const {messages: savedMessages} = await getFromLocalStorage('messages');
151 |
152 | if (!savedMessages) {
153 | return;
154 | }
155 |
156 | const {[conversationId]: savedMessage} = savedMessages;
157 |
158 | if (savedMessage && !isEmptyMsgInput(savedMessage)) {
159 | console.log('prefilling contentEditableElement with savedMessage');
160 | fillContainerWithMessage(messageContainer, savedMessage);
161 | }
162 | }
163 | }
164 |
165 | async function handleMessageChange() {
166 | const conversationId = getConversationId();
167 | const currentMessage = getMessageContainer().html();
168 | const {messages: savedMessages} = await getFromLocalStorage('messages');
169 |
170 | const updatedMessages = {
171 | messages: Object.assign((savedMessages || {}), {[conversationId]: currentMessage})
172 | };
173 |
174 | // We need to check if DM Modal is open because
175 | // twitter unsets the message when DMModal closes.
176 | // Hence we need a way to tell handleMessageChange
177 | // to not store empty message (which happens when DMModal closes)
178 | if (isDMModalOpen) {
179 | setToLocalStorage(updatedMessages);
180 | }
181 | }
182 |
183 | function fillContainerWithMessage(messageContainer, savedMessage) {
184 | messageContainer.empty();
185 | messageContainer.append(savedMessage);
186 | setCursorToEnd(messageContainer[0]);
187 | }
188 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | #
Refined Twitter
2 |
3 | > Browser extension that simplifies the Twitter interface and adds useful features
4 |
5 | **This project is not maintained anymore. Twitter rewrote their website and completely broke this extension, and it's much harder to reliably modify the new website. I also don't have time to fix these things and no one has stepped up to do so either. It had a great run. Thanks for all the love.**
6 |
7 | We use Twitter a lot and notice many dumb annoyances we'd like to fix. So here be dragons.
8 |
9 | ## Install
10 |
11 | - [**Chrome** extension](https://chrome.google.com/webstore/detail/refined-twitter/nlfgmdembofgodcemomfeimamihoknip)
12 | - **Opera** extension: Use [this](https://addons.opera.com/en/extensions/details/download-chrome-extension-9/) to enable installing Chrome extensions and then install [Refined Twitter](https://chrome.google.com/webstore/detail/refined-twitter/nlfgmdembofgodcemomfeimamihoknip)
13 |
14 | *Note: You **must** be logged in for this extension to work.*
15 |
16 | ## Highlights
17 |
18 | *You can configure many of these features in the extension settings.*
19 |
20 | - Simplified and improved UI.
21 | - Hides promoted tweets.
22 | - Auto-loads new tweets in the stream if you're scrolled to the top. No more clicking `See 3 new Tweets`!
23 | - Fixes the file extension when saving images in tweets. No more `foo.jpg_large`!
24 | - Uses the system font.
25 | - [Embeds the photo from Instagram links directly in the tweet.](https://user-images.githubusercontent.com/170270/34315380-12d52994-e77f-11e7-8e23-27b76aee4df2.png)
26 | - Improves performance. [1](https://github.com/sindresorhus/refined-twitter/pull/14) [2](https://github.com/sindresorhus/refined-twitter/commit/23897e251d2bc8d59526129ce54c7a5bf1ef884c)
27 | - Hides "Liked" tweets in the stream.
28 | - [Hides "And others follow" tweets in the stream.](https://user-images.githubusercontent.com/5341072/39945031-cc81125a-5560-11e8-8334-ea310a9dfdad.png)
29 | - [Syntax highlighting in code blocks.](https://github.com/sindresorhus/refined-twitter/issues/37)
30 | - [Adds Markdown-like styling of `text wrapped in backticks`.](https://user-images.githubusercontent.com/12901172/38168571-d9bd82ea-351d-11e8-9858-0d7c8993cdd3.png)
31 | - Uses the original image in tweet image galleries instead of a downsized version.
32 | - [Removes the annoying suggestions in the search popover.](https://user-images.githubusercontent.com/170270/33800304-70198358-dd3d-11e7-9870-477a44f74f4d.png)
33 | - Hides "Notifications" activity for new followers and being added to a list.
34 | - Preserves unsent text in the Messages modal when it closes.
35 | - Highlight your mentions in the stream.
36 | - [Adds a `Likes` button to the main navbar.](https://user-images.githubusercontent.com/14620121/35988497-ace9f93e-0ce5-11e8-8675-17e6ee38cd99.png)
37 | - Keyboard shortcut to toggle Night Mode (Altm).
38 | - Uses your personal color theme on all profiles.
39 | - Hides the header image on profile pages.
40 | - [Shows alternative image text below images when available.](https://user-images.githubusercontent.com/170270/40556400-b46c292c-6076-11e8-8241-f5c4e1a7a161.png)
41 |
42 | Tip: Twitter has a native [dark mode](https://github.com/sindresorhus/refined-twitter/issues/10) and you can toggle it using Altm. And press Shift ? to see all keyboard shortcuts.
43 |
44 |
45 |
46 | ## Customization
47 |
48 | See the extension settings for what can be configured.
49 |
50 |
51 |
52 | We're happy to receive suggestions and contributions, but be aware this is an opinionated project. There's a high bar for adding options.
53 |
54 | This doesn't necessarily limit you from manually disabling functionality that is not useful for you. Options include:
55 |
56 | 1. *(CSS Only)* Use a Chrome extension that allows injecting custom styles into sites, based on a URL pattern. [Stylus](https://add0n.com/stylus.html) is one such tool. [Example](https://github.com/sindresorhus/refined-github/issues/136#issuecomment-204072018)
57 |
58 | 2. Clone the repository, make the adjustments you need, and [load the unpacked extension in Chrome](https://developer.chrome.com/extensions/getstarted#unpacked), rather than installing from the Chrome Store.
59 |
60 | ## Contribute
61 |
62 | Suggestions and pull requests are highly encouraged!
63 |
64 | In order to make modifications to the extension you'd need to run it locally.
65 |
66 | Please follow the below steps:
67 |
68 | ```sh
69 | git clone https://github.com/sindresorhus/refined-twitter
70 | cd refined-twitter
71 | npm install # Install dev dependencies
72 | npm run build # Build the extension code so it's ready for the browser
73 | npm run watch # Listen for file changes and automatically rebuild
74 | ```
75 |
76 | Once built, load it in the browser of your choice:
77 |
78 |
79 |
80 | | Chrome |
81 | Firefox |
82 |
83 |
84 |
85 |
86 | - Open
chrome://extensions
87 | - Check the Developer mode checkbox
88 |
- Click on the Load unpacked extension button
89 |
- Select the folder
refined-twitter/distribution
90 |
91 | |
92 |
93 |
94 | - Open
about:debugging#addons
95 | - Click on the Load Temporary Add-on button
96 |
- Select the file
refined-twitter/extension/manifest.json
97 |
98 | |
99 |
100 |
101 |
102 | ## FAQ
103 |
104 | #### Don't you have another extension with the same name?
105 |
106 | This is reusing the name from the [original Refined Twitter](https://github.com/sindresorhus/refined-twitter-old) extension, which tried to use the mobile Twitter version on the desktop. It was a good idea in theory, but not in practice. This extension instead improves upon the desktop version of Twitter.
107 |
108 | #### Will this extension work if I'm not logged in?
109 |
110 | [No](https://github.com/sindresorhus/refined-twitter/issues/126).
111 |
112 | ## Links
113 |
114 | - [Blog post](https://blog.sindresorhus.com/refined-twitter-74038424fe2a)
115 | - [Product Hunt submission](https://www.producthunt.com/posts/refined-twitter)
116 |
117 | ## Related
118 |
119 | - [Refined GitHub](https://github.com/sindresorhus/refined-github) - GitHub version of this extension
120 |
--------------------------------------------------------------------------------
/source/features/index.js:
--------------------------------------------------------------------------------
1 | import groupBy from 'lodash.groupby';
2 | import sortBy from 'lodash.sortby';
3 |
4 | export const features = {
5 | /* GENERAL */
6 | keyboardShortcuts: {
7 | id: 'feature-keyboard-shortcuts',
8 | category: 'general',
9 | label: 'Enable keyboard shortcut to toggle Night Mode (Alt + M)',
10 | fn: require('./keyboard-shortcuts').default,
11 | hidden: true
12 | },
13 | preserveTextMessages: {
14 | id: 'feature-preserve-text-messages',
15 | category: 'general',
16 | label: 'Preserve unsent text in the Messages modal when it closes',
17 | fn: require('./preserve-text-messages').default
18 | },
19 | useSystemFont: {
20 | id: 'feature-use-system-font',
21 | category: 'general',
22 | label: 'Use the system font',
23 | runOnInit: true
24 | },
25 |
26 | /* HEADER */
27 | addLikesButtonNavBar: {
28 | id: 'feature-likes-button-navbar',
29 | category: 'header',
30 | label: 'Add "Likes" tab',
31 | fn: require('./likes-button-navbar').default,
32 | runOnInit: true
33 | },
34 | cleanNavbarDropdown: {
35 | id: 'feature-clean-navbar-dropdown',
36 | category: 'header',
37 | label: 'Cleanup navbar dropdown',
38 | fn: require('./clean-navbar-dropdown').default,
39 | hidden: true
40 | },
41 | cleanupSearchSuggestions: {
42 | id: 'feature-cleanup-search-suggestions',
43 | category: 'header',
44 | label: 'Remove hashtags suggestions in the search popover',
45 | runOnInit: true,
46 | hidden: true
47 | },
48 | hideMomentsTab: {
49 | id: 'feature-hide-moments-tab',
50 | category: 'header',
51 | label: 'Hide "Moments" tab',
52 | runOnInit: true
53 | },
54 | hideTwitterLogo: {
55 | id: 'feature-hide-twitter-logo',
56 | category: 'header',
57 | label: 'Hide Twitter bird logo',
58 | runOnInit: true,
59 | hidden: true
60 | },
61 |
62 | /* HOME */
63 | hideHomeFooterCard: {
64 | id: 'feature-home-hide-footer-card',
65 | category: 'home',
66 | label: 'Hide "Footer" Card',
67 | runOnInit: true,
68 | hidden: true
69 | },
70 | hideHomeProfileCard: {
71 | id: 'feature-home-hide-profile-card',
72 | category: 'home',
73 | label: 'Hide "Profile" Card',
74 | runOnInit: true
75 | },
76 | hideHomeTrendsCard: {
77 | id: 'feature-home-hide-trends-card',
78 | category: 'home',
79 | label: 'Hide "Trends" Card',
80 | runOnInit: true
81 | },
82 | hideHomeWhoToFollowCard: {
83 | id: 'feature-home-hide-who-to-follow-card',
84 | category: 'home',
85 | label: 'Hide "Who to follow" Card',
86 | runOnInit: true
87 | },
88 | hideTrendsAndWhoToFollowCards: {
89 | id: 'feature-home-hide-trends-and-who-to-follow-cards',
90 | category: 'home',
91 | label: 'Hide "Trends for you" and "Who to follow" cards in the new Twitter UI',
92 | runOnInit: true,
93 | fn: require('./hide-trends-and-who-to-follow').default
94 | },
95 |
96 | /* NOTIFICATIONS */
97 | hideNotificationsFollowActivity: {
98 | id: 'feature-notifications-hide-follow-activity',
99 | category: 'notifications',
100 | label: 'Hide "Followed you" activity',
101 | runOnInit: true
102 | },
103 | hideNotificationsListActivity: {
104 | id: 'feature-notifications-hide-list-activity',
105 | category: 'notifications',
106 | label: 'Hide "Added you to a list" activity',
107 | runOnInit: true
108 | },
109 | hideNotificationsLikedReplyActivity: {
110 | id: 'feature-notifications-hide-liked-reply-activity',
111 | category: 'notifications',
112 | label: 'Hide "Liked a reply to you" activity',
113 | runOnInit: true
114 | },
115 | hideNotificationsInCaseYouMissed: {
116 | id: 'feature-notifications-in-case-you-missed-activity',
117 | category: 'notifications',
118 | label: 'Hide "In case you missed" activity',
119 | fn: require('./hide-in-case-you-missed-notifications').default
120 | },
121 |
122 | /* PROFILE */
123 | hideProfileHeader: {
124 | id: 'feature-remove-profile-header',
125 | category: 'profile',
126 | label: 'Hide the header image on profile pages',
127 | fn: require('./remove-profile-header').default
128 | },
129 | disableCustomColors: {
130 | id: 'feature-disable-custom-colors',
131 | category: 'profile',
132 | label: 'Use your personal color theme on all profiles',
133 | fn: require('./disable-custom-colors').default
134 | },
135 |
136 | /* TIMELINE */
137 | autoLoadNewTweets: {
138 | id: 'feature-auto-load-new-tweets',
139 | category: 'timeline',
140 | label: 'Auto-loads new tweets in the stream if you\'re scrolled to the top',
141 | fn: require('./auto-load-new-tweets').default
142 | },
143 | codeHighlight: {
144 | id: 'feature-code-highlight',
145 | category: 'timeline',
146 | label: 'Syntax highlighting in code blocks',
147 | fn: require('./code-highlight').default
148 | },
149 | hideFollowTweets: {
150 | id: 'feature-hide-follow-tweets',
151 | category: 'timeline',
152 | label: 'Hide "And others follow" tweets in the stream',
153 | fn: require('./hide-follow-tweets').default
154 | },
155 | hideLikeTweets: {
156 | id: 'feature-hide-like-tweets',
157 | category: 'timeline',
158 | label: 'Hide "Liked" tweets in the stream',
159 | fn: require('./hide-like-tweets').default
160 | },
161 | hideRetweets: {
162 | id: 'feature-hide-retweets',
163 | category: 'timeline',
164 | label: 'Hide retweets in the stream',
165 | enabledByDefault: false,
166 | fn: require('./hide-retweets').default
167 | },
168 | hidePromotedTweets: {
169 | id: 'feature-hide-promoted-tweets',
170 | category: 'timeline',
171 | label: 'Hide promoted tweets',
172 | fn: require('./hide-promoted-tweets').default
173 | },
174 | hideRetweetButtons: {
175 | id: 'feature-hide-retweet-buttons',
176 | category: 'timeline',
177 | label: 'Hide retweet buttons',
178 | enabledByDefault: false,
179 | fn: require('./hide-retweet-buttons').default
180 | },
181 | imageAlternatives: {
182 | id: 'feature-image-alternatives',
183 | category: 'timeline',
184 | label: 'Shows alternative image text below images when available',
185 | fn: require('./image-alternatives').default
186 | },
187 | inlineInstagramPhotos: {
188 | id: 'feature-inline-instagram-photos',
189 | category: 'timeline',
190 | label: 'Embed the photo from Instagram links directly in the tweet',
191 | fn: require('./inline-instagram-photos').default,
192 | hidden: true
193 | },
194 | mentionHighlight: {
195 | id: 'feature-mentions-highlight',
196 | category: 'timeline',
197 | label: 'Highlight your mentions in the stream',
198 | fn: require('./mentions-highlight').default
199 | },
200 | renderInlineCode: {
201 | id: 'feature-inline-code',
202 | category: 'timeline',
203 | label: 'Adds Markdown-like styling of text wrapped in backticks',
204 | fn: require('./inline-code').default
205 | }
206 | };
207 |
208 | export const featuresArr = sortBy(Object.values(features), ['category', 'label']);
209 |
210 | export const groupedFeatures = groupBy(featuresArr, 'category');
211 |
212 | export const autoInitFeatures = featuresArr.filter(feature => feature.runOnInit);
213 |
214 | const _featuresDefaultValues = {};
215 | for (const feature of featuresArr) {
216 | _featuresDefaultValues[feature.id] =
217 | typeof feature.enabledByDefault === 'boolean' ? feature.enabledByDefault : true;
218 | }
219 |
220 | export const featuresDefaultValues = _featuresDefaultValues;
221 |
--------------------------------------------------------------------------------
/source/style/content.css:
--------------------------------------------------------------------------------
1 | /**
2 |
3 | Global
4 |
5 | */
6 |
7 | /* Use system fonts */
8 | .feature-use-system-font body {
9 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol' !important;
10 | }
11 |
12 | /* Remove navbar logo */
13 | .feature-hide-twitter-logo .bird-topbar-etched {
14 | display: none !important;
15 | }
16 |
17 | /* Remove "Moments" tab */
18 | .feature-hide-moments-tab #global-actions .moments {
19 | display: none !important;
20 | }
21 |
22 | /* Promote layers to fix repaints */
23 | #permalink-overlay,
24 | .RichEditor-tempTextArea {
25 | will-change: transform;
26 | }
27 |
28 | /* Contain layout for better performance */
29 | .Gallery,
30 | .topbar {
31 | contain: layout style; /* We don't contain `content` as it breaks fullscreening videos */
32 | }
33 |
34 | .stream-item {
35 | contain: style; /* We don't contain `layout` as it hides tweet dropdown below next tweet */
36 | }
37 |
38 | /* Remove annoying suggestions in the Search popover */
39 | .feature-cleanup-search-suggestions #global-nav-search .typeahead-topics {
40 | display: none !important;
41 | }
42 |
43 | .feature-cleanup-search-suggestions #global-nav-search .typeahead-topics + ul {
44 | border: none !important;
45 | }
46 |
47 | /* Highlight your mentions in the stream */
48 | .feature-mentions-highlight body:not(.NotificationsPage) .tweet.refined-twitter_mention:not(.permalink-tweet) {
49 | background-color: rgba(var(--refined-twitter_bgcolor-values), 0.1);
50 | }
51 |
52 | .feature-mentions-highlight body:not(.NotificationsPage) .tweet.refined-twitter_mention:not(.permalink-tweet):hover {
53 | background-color: rgba(var(--refined-twitter_bgcolor-values), 0.2);
54 | }
55 |
56 | /**
57 |
58 | Dashboard
59 |
60 | */
61 |
62 | /* Remove the profile box */
63 | .feature-home-hide-profile-card .module.DashboardProfileCard {
64 | display: none !important;
65 | }
66 |
67 | /* Remove the "Trends for you" box */
68 | .feature-home-hide-trends-card .module.trends {
69 | display: none !important;
70 | }
71 |
72 | /* Remove the "Who to follow" box */
73 | .feature-home-hide-who-to-follow-card .module.wtf-module {
74 | display: none !important;
75 | }
76 |
77 | /* Remove the footer box */
78 | .feature-home-hide-footer-card .module.Footer {
79 | display: none !important;
80 | }
81 |
82 | /* Remove sidebars */
83 | /* The `:not(.wrapper-list)` part filters out Twitter lists because we don't want to hide the sidebars there */
84 | .feature-home-hide-profile-card.feature-home-hide-trends-card.feature-home-hide-footer-card.feature-home-hide-who-to-follow-card #page-container:not(.wrapper-list) .dashboard-left,
85 | .feature-home-hide-profile-card.feature-home-hide-trends-card.feature-home-hide-footer-card.feature-home-hide-who-to-follow-card #page-container:not(.wrapper-list) .dashboard-right {
86 | display: none !important;
87 | }
88 |
89 | /* Center the tweet stream */
90 | .feature-home-hide-profile-card.feature-home-hide-trends-card.feature-home-hide-footer-card.feature-home-hide-who-to-follow-card #page-container:not(.wrapper-settings) {
91 | width: unset !important;
92 | }
93 |
94 | .feature-home-hide-profile-card.feature-home-hide-trends-card.feature-home-hide-footer-card.feature-home-hide-who-to-follow-card #page-container:not(.wrapper-settings) .content-main {
95 | margin: 0 auto !important;
96 | float: unset !important; /* stylelint-disable property-blacklist */
97 | }
98 |
99 | /* Custom inline image element */
100 | .AdaptiveMedia .refined-twitter_instagram-inline {
101 | max-width: 100%;
102 | }
103 |
104 | /* Hide list-add activity */
105 | .feature-notifications-hide-list-activity #stream-items-id .js-activity-list_member_added {
106 | display: none;
107 | }
108 |
109 | /* Hide follow activity */
110 | .feature-notifications-hide-follow-activity #stream-items-id .js-activity-follow {
111 | display: none;
112 | }
113 |
114 | /* Center the nav widgets */
115 | .feature-hide-twitter-logo.feature-home-hide-profile-card.feature-home-hide-trends-card.feature-home-hide-footer-card.feature-home-hide-who-to-follow-card .global-nav .container {
116 | max-width: 880px !important;
117 | }
118 |
119 | .feature-hide-twitter-logo.feature-home-hide-profile-card.feature-home-hide-trends-card.feature-home-hide-footer-card.feature-home-hide-who-to-follow-card.feature-likes-button-navbar:not(.feature-hide-moments-tab) #global-nav-search {
120 | width: 180px !important;
121 | }
122 |
123 | @media screen and (max-width: 936px) {
124 | .global-nav .container {
125 | max-width: 590px !important;
126 | }
127 | }
128 |
129 | /* Adjust pushstate spinner for the above nav max-width change */
130 | .pushstate-spinner {
131 | position: absolute;
132 | left: 0 !important;
133 | margin-left: -40px !important;
134 | }
135 |
136 | @media screen and (max-width: 936px) {
137 | .pushstate-spinner {
138 | left: 43% !important;
139 | margin-left: -10px !important;
140 | }
141 | }
142 |
143 | /* Style text in backticks */
144 | code.refined-twitter_markdown {
145 | font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace;
146 | background-color: rgba(27, 31, 35, 0.05);
147 | padding: 0.1em 0.4em;
148 | margin: 0;
149 | font-size: 90%;
150 | }
151 |
152 | .feature-remove-profile-header .ProfileSidebar--withLeftAlignment {
153 | transform: translateY(130px);
154 | position: relative;
155 | z-index: 3;
156 | }
157 |
158 | .feature-remove-profile-header .ProfileCanopy-navBar > .AppContainer {
159 | position: relative;
160 | }
161 |
162 | .feature-remove-profile-header .ProfileCanopy,
163 | .feature-remove-profile-header .ProfileCanopy-header,
164 | .feature-remove-profile-header .ProfileCanopy--large {
165 | height: 0 !important;
166 | }
167 |
168 | .feature-remove-profile-header .ProfileCanopy-avatar {
169 | /* Remove white border and add shadow to profile picture */
170 | padding: 5px;
171 | border-radius: 100%;
172 |
173 | /* Remove big header from profile page */
174 | bottom: -160px !important;
175 | }
176 |
177 | .feature-remove-profile-header .ProfileAvatar,
178 | .feature-remove-profile-header .ProfileAvatarEditing {
179 | border: none !important;
180 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.36);
181 | }
182 |
183 | /* Prevent follow button being hidden */
184 | .feature-remove-profile-header .ProfileCanopy-inner {
185 | position: relative !important;
186 | }
187 |
188 | /* Move dropdown menu to the right */
189 | .DashUserDropdown {
190 | transform: translateX(145px);
191 | }
192 |
193 | .DashUserDropdown .dropdown-caret {
194 | transform: translateX(-145px);
195 | }
196 |
197 | /* Plugin-generated div that display the alternative text of an image */
198 | .refined-twitter_image-alt {
199 | background-color: #fff;
200 | color: #14171a;
201 | padding: 0.8rem;
202 | font-size: 0.9rem;
203 | line-height: 1.5;
204 | }
205 |
206 | .refined-twitter_image-alt_top {
207 | border-bottom: 1px solid rgba(0, 0, 0, 0.1);
208 | }
209 |
210 | .refined-twitter_image-alt_bottom {
211 | border-top: 1px solid rgba(0, 0, 0, 0.1);
212 | }
213 |
214 | .refined-twitter_image-alt_img {
215 | top: 0 !important;
216 | margin-top: 0 !important;
217 | position: relative !important;
218 | }
219 |
220 | .refined-twitter_image-alt_container {
221 | position: relative !important;
222 | }
223 |
224 | .refined-twitter_image-alt_photocontainer {
225 | padding-top: 0 !important;
226 | }
227 |
228 | .refined-twitter_image-alt_ancestor-not-square {
229 | max-height: initial !important;
230 | }
231 |
232 | /* Alternative text for user avatars */
233 | .refined-twitter_image-alt_profile-container {
234 | border-radius: none !important;
235 | background: transparent !important;
236 | overflow: visible !important;
237 | }
238 |
239 | .refined-twitter_image-alt_profile-container .refined-twitter_image-alt_img {
240 | border-radius: 50%;
241 | }
242 |
243 | .refined-twitter_image-alt_profile-container .refined-twitter_image-alt {
244 | position: absolute;
245 | top: 100%;
246 | width: 100%;
247 | box-sizing: border-box;
248 | background: transparent;
249 | color: #fff;
250 | font-size: 1.5em;
251 | font-weight: 300;
252 | text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5);
253 | }
254 |
255 | /* Hide `Liked a reply to you` notifications */
256 | .feature-notifications-hide-liked-reply-activity .stream-item-favorited_mention {
257 | display: none;
258 | }
259 |
260 | /* Avoid tooltip blinking #167 */
261 | .tooltip {
262 | pointer-events: none;
263 | }
264 |
--------------------------------------------------------------------------------