├── .editorconfig ├── .gitattributes ├── .github └── funding.yml ├── .gitignore ├── .travis.yml ├── license ├── media ├── icon.ai ├── promo.png ├── screenshot-settings.png ├── screenshot-webstore.png └── screenshot.gif ├── package-lock.json ├── package.json ├── readme.md ├── source ├── background.js ├── content.js ├── features │ ├── auto-load-new-tweets.js │ ├── clean-navbar-dropdown.js │ ├── code-highlight.js │ ├── disable-custom-colors.js │ ├── hide-follow-tweets.js │ ├── hide-in-case-you-missed-notifications.js │ ├── hide-like-tweets.js │ ├── hide-promoted-tweets.js │ ├── hide-retweet-buttons.js │ ├── hide-retweets.js │ ├── hide-trends-and-who-to-follow.js │ ├── image-alternatives.js │ ├── index.js │ ├── inline-code.js │ ├── inline-instagram-photos.js │ ├── keyboard-shortcuts.js │ ├── likes-button-navbar.js │ ├── mentions-highlight.js │ ├── preserve-text-messages.js │ └── remove-profile-header.js ├── icon.png ├── libs │ └── utils.js ├── manifest.json ├── options.html ├── options.js └── style │ ├── code-highlight.css │ └── content.css ├── test ├── fixtures │ └── window.js └── index.js └── webpack.config.js /.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 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.ai binary 3 | readme.md merge=union 4 | src/content.css merge=union 5 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: sindresorhus 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | distribution 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /media/icon.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/refined-twitter/bceb4440811fa97caedd0c1e1d94a5a1bf868e2e/media/icon.ai -------------------------------------------------------------------------------- /media/promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/refined-twitter/bceb4440811fa97caedd0c1e1d94a5a1bf868e2e/media/promo.png -------------------------------------------------------------------------------- /media/screenshot-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/refined-twitter/bceb4440811fa97caedd0c1e1d94a5a1bf868e2e/media/screenshot-settings.png -------------------------------------------------------------------------------- /media/screenshot-webstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/refined-twitter/bceb4440811fa97caedd0c1e1d94a5a1bf868e2e/media/screenshot-webstore.png -------------------------------------------------------------------------------- /media/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/refined-twitter/bceb4440811fa97caedd0c1e1d94a5a1bf868e2e/media/screenshot.gif -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 81 | 82 | 83 | 84 | 92 | 99 | 100 |
ChromeFirefox
85 |
    86 |
  1. Open chrome://extensions 87 |
  2. Check the Developer mode checkbox 88 |
  3. Click on the Load unpacked extension button 89 |
  4. Select the folder refined-twitter/distribution 90 |
91 |
93 |
    94 |
  1. Open about:debugging#addons 95 |
  2. Click on the Load Temporary Add-on button 96 |
  3. Select the file refined-twitter/extension/manifest.json 97 |
98 |
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/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/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/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/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 | -------------------------------------------------------------------------------- /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 |
40 | 				
41 | 					{code.trim()}
42 | 				
43 | 			
44 | ); 45 | } 46 | 47 | const highlightedCode = prism.highlight(code.trim(), prism.languages[selectedLang]); 48 | 49 | return ( 50 |
51 |
52 | 				
53 | 					{domify(highlightedCode)}
54 | 				
55 | 			
56 |
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/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/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 | -------------------------------------------------------------------------------- /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-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-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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/features/inline-code.js: -------------------------------------------------------------------------------- 1 | import {h} from 'dom-chef'; 2 | 3 | function styleInlineCode(md) { 4 | return {md}; 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/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 |
8 | 9 | 10 | 11 |
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/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/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 | 15 | 16 | Likes 17 | 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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /source/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/refined-twitter/bceb4440811fa97caedd0c1e1d94a5a1bf868e2e/source/icon.png -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /source/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Refined Twitter options 4 | 22 |
    23 |
    24 |

    Features

    25 |
    26 |
    27 |
    28 |
    29 |

    Debug

    30 |

    31 | 35 |

    36 |
    37 |
    38 | 39 | 40 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------