├── .gitignore ├── .vscode └── settings.json ├── README.md ├── babel.config.js ├── manifest.base.js ├── package-lock.json ├── package.json ├── src ├── @types │ ├── global.d.ts │ └── svg.d.ts ├── background │ ├── index.ts │ ├── manageTablock.ts │ ├── onInstall.ts │ └── retitle.ts ├── options │ ├── AdvancedSettings.tsx │ ├── ContextMenuSwitch.tsx │ ├── Footer.tsx │ ├── Header.tsx │ ├── Home.tsx │ ├── KeyboardShortcutSettings.tsx │ ├── OptionsApp.tsx │ ├── RegexPopup.tsx │ ├── SavedTitles.tsx │ ├── UserSettings.tsx │ └── index.tsx ├── popup │ ├── BookmarkTitle.tsx │ ├── ContentScriptChecker.tsx │ ├── CurrentTitle.tsx │ ├── Form.tsx │ ├── Gear.tsx │ ├── PopupApp.tsx │ ├── Revert.tsx │ └── index.tsx └── shared │ ├── AccessibleButton.tsx │ ├── ReTitleThemeWrapper.tsx │ ├── RegexInput.tsx │ ├── RegexInputGroup.tsx │ ├── injectedScripts.ts │ ├── storageHandler.ts │ ├── storageUtils.ts │ ├── types.ts │ └── utils.ts ├── static ├── icons │ ├── icon128.png │ ├── icon16.png │ ├── icon256.png │ ├── icon32.png │ ├── icon512.png │ └── icon64.png ├── options.html ├── popup.html └── svgs │ ├── chrome.svg │ ├── firefox.svg │ ├── gear.svg │ ├── github.svg │ └── pencil.svg ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist_chrome 4 | dist_firefox 5 | zip -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.formatOnPaste": true, 5 | "typescript.tsdk": "node_modules/typescript/lib" 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReTitle: Chrome and Firefox Extension for Renaming Tabs Easily 2 | 3 | ## ⚠️ This is the V2 branch that's not released yet as it is still being developed. 4 | 5 | Please check the master branch for the currently released version. 6 | 7 | ### Get ReTitle on Chrome 8 | 9 | https://chrome.google.com/webstore/detail/tab-retitle/hilgambgdpjgljhjdaccadahckpdiapo 10 | 11 | ### Get ReTitle on Firefox 12 | 13 | https://addons.mozilla.org/en-US/firefox/addon/tab-retitle/ 14 | 15 | #### Develop 16 | 17 | 1. Fork and clone this repo. 18 | 1. `npm i` 19 | 1. `npm run dev` 20 | 1. Load the folder created by webpack `dist_chrome` or `dist_firefox` on your browser (unpacked). 21 | 22 | #### Build 23 | 24 | 1. `npm ci` 25 | 1. `npm run build` 26 | 27 | #### Deploy 28 | 29 | 1. Publish the files created in `zip`, `ReTitle-.chrome.zip` and `ReTitle-.firefox.zip` to the respective extension stores. 30 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | 4 | return { 5 | presets: [ 6 | [ 7 | '@babel/env', 8 | { 9 | targets: 'last 5 chrome version, last 5 firefox version', 10 | }, 11 | ], 12 | [ 13 | '@babel/preset-react', 14 | { 15 | pragma: 'h', 16 | pragmaFrag: 'Fragment', 17 | }, 18 | ], 19 | [ 20 | '@babel/preset-typescript', 21 | { 22 | allExtensions: true, 23 | isTSX: true, 24 | jsxPragma: 'h', 25 | }, 26 | ], 27 | ], 28 | plugins: ['@babel/plugin-proposal-class-properties'], 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /manifest.base.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | manifest_version: 2, 3 | 4 | name: 'Tab ReTitle', 5 | short_name: 'ReTitle', 6 | description: 'Change tab titles easily!', 7 | author: 'Lazyuki', 8 | homepage_url: 'https://github.com/Lazyuki/ReTitle', 9 | browser_action: { 10 | default_icon: './icons/icon256.png', 11 | default_popup: 'popup.html', 12 | }, 13 | icons: { 14 | 16: './icons/icon16.png', 15 | 32: './icons/icon32.png', 16 | 64: './icons/icon64.png', 17 | 128: './icons/icon128.png', 18 | 256: './icons/icon256.png', 19 | }, 20 | background: { 21 | scripts: ['background.js'], 22 | }, 23 | options_ui: { 24 | page: 'options.html', 25 | chrome_style: false, 26 | open_in_tab: true, 27 | }, 28 | commands: { 29 | _execute_browser_action: { 30 | suggested_key: { 31 | default: 'Alt+Shift+X', 32 | mac: 'Alt+Shift+X', 33 | linux: 'Alt+Shift+X', 34 | }, 35 | description: 'Set new title', 36 | }, 37 | }, 38 | // contextMenus cannot be optional on Firefox 39 | // is needed to make sure background tabs could be retitled out of focus 40 | permissions: ['activeTab', 'tabs', 'storage', '', 'contextMenus'], 41 | optional_permissions: ['bookmarks'], 42 | }; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retitle", 3 | "version": "2.0.0", 4 | "description": "Change tab titles easily!", 5 | "author": "Lazyuki", 6 | "license": "MIT", 7 | "private": true, 8 | "bugs": { 9 | "url": "https://github.com/Lazyuki/ReTitle/issues" 10 | }, 11 | "homepage": "https://github.com/Lazyuki/ReTitle#readme", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Lazyuki/ReTitle.git" 15 | }, 16 | "scripts": { 17 | "lint": "eslint src", 18 | "preinstall": "npx npm-force-resolutions", 19 | "dev": "npm-run-all --parallel dev:chrome dev:firefox", 20 | "dev:chrome": "webpack --watch --env.BROWSER=chrome ", 21 | "dev:firefox": "webpack --watch --env.BROWSER=firefox ", 22 | "clean": "rimraf dist*", 23 | "prebuild": "npm run clean", 24 | "build": "npm-run-all --parallel build:chrome build:firefox", 25 | "build:chrome": "webpack --env.NODE_ENV=production --env.BROWSER=chrome", 26 | "build:firefox": "webpack --env.NODE_ENV=production --env.BROWSER=firefox" 27 | }, 28 | "dependencies": { 29 | "@material-ui/core": "^4.11.0", 30 | "@material-ui/icons": "^4.9.1", 31 | "clsx": "^1.1.1", 32 | "preact": "10.4.7", 33 | "react-redux": "^7.2.1", 34 | "redux": "^4.0.5", 35 | "redux-thunk": "^2.3.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.11.6", 39 | "@babel/preset-env": "^7.11.5", 40 | "@babel/preset-react": "^7.10.4", 41 | "@babel/preset-typescript": "^7.10.4", 42 | "@types/chrome": "^0.0.133", 43 | "@types/firefox-webext-browser": "^82.0.0", 44 | "@types/react-redux": "^7.1.9", 45 | "babel-loader": "^8.1.0", 46 | "copy-webpack-plugin": "^5.1.2", 47 | "eslint": "^7.11.0", 48 | "husky": "^4.3.0", 49 | "npm-run-all": "^4.1.5", 50 | "preact-svg-loader": "^0.2.1", 51 | "prettier": "^2.1.2", 52 | "pretty-quick": "^2.0.2", 53 | "rimraf": "^3.0.2", 54 | "ts-loader": "^7.0.5", 55 | "typescript": "^3.9.7", 56 | "webpack": "^4.44.2", 57 | "webpack-cli": "^3.3.12", 58 | "webpack-extension-manifest-plugin": "^0.5.0", 59 | "zip-webpack-plugin": "^3.0.0" 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "pretty-quick --staged" 64 | } 65 | }, 66 | "prettier": { 67 | "singleQuote": true 68 | }, 69 | "resolutions": { 70 | "acorn": "8.0.1" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | // Replaced during Webpack build process 2 | declare var BROWSER: 'chrome' | 'firefox'; 3 | declare var EXTENSION_VERSION: string; 4 | -------------------------------------------------------------------------------- /src/@types/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const value: any; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | // Core functionalities 2 | import './onInstall'; 3 | import './manageTablock'; 4 | import './retitle'; 5 | 6 | import { injectTitle } from '../shared/injectedScripts'; 7 | import { getContextMenuOption } from '../shared/storageHandler'; 8 | import { createContextMenu } from '../shared/utils'; 9 | 10 | // Simple context menu 11 | chrome.contextMenus.onClicked.addListener(function (info, tab) { 12 | chrome.tabs.executeScript({ 13 | code: `tempTitle = prompt("Enter a temporary title", window.getSelection().toString()); \ 14 | ${injectTitle.toString()}; tempTitle !== null && injectTitle(tempTitle, 'onetime');`, 15 | }); 16 | }); 17 | 18 | // Add context menu if option is set 19 | getContextMenuOption().then((enableContextMenu) => { 20 | if (enableContextMenu) { 21 | createContextMenu(); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /src/background/manageTablock.ts: -------------------------------------------------------------------------------- 1 | import { PREFIX_TABLOCK } from '../shared/utils'; 2 | import { 3 | setLocalItems, 4 | getAllLocalItems, 5 | setLocalItem, 6 | removeLocalItems, 7 | } from '../shared/storageUtils'; 8 | 9 | const isChrome = BROWSER === 'chrome'; 10 | 11 | interface TablockCache { 12 | tabId: number; 13 | windowId: number; 14 | index: number; 15 | title: string; 16 | url?: string; 17 | } 18 | interface TablockCaches { 19 | [tabId: number]: TablockCache; 20 | } 21 | 22 | // Map tab position to IDs so that Tablock persists through sessions. 23 | const tablockCaches: TablockCaches = {}; 24 | const previousTablockCaches: TablockCache[] = []; 25 | let previousCrashed = false; 26 | 27 | const debounce = (func: (...args: T) => any, wait = 250) => { 28 | let timer: any; 29 | return (...args: T) => { 30 | clearTimeout(timer); 31 | timer = setTimeout(() => func(...args), wait); 32 | }; 33 | }; 34 | 35 | function _recalculateTabPositionToId(windowId: number) { 36 | const tabIds = Object.keys(tablockCaches).map(parseInt); 37 | chrome.tabs.query({ windowId }, function (tabs) { 38 | tabs.forEach((tab) => { 39 | const { index, id: tabId, url } = tab; 40 | if (!tabId) return; 41 | if (tabIds.includes(tabId)) { 42 | const prev = tablockCaches[tabId]; 43 | tablockCaches[tabId] = { 44 | tabId, 45 | windowId, 46 | index, 47 | url, 48 | title: prev.title, 49 | }; 50 | } 51 | }); 52 | // use local storage since it should not be synced 53 | setLocalItems({ 54 | tablockCaches, 55 | }); 56 | }); 57 | } 58 | 59 | const recalculateTabPositionToId = debounce( 60 | (windowId: number) => _recalculateTabPositionToId(windowId), 61 | 200 62 | ); 63 | 64 | // Restore stored tablocks if possible 65 | chrome.windows.onCreated.addListener(function (window) { 66 | if (window.type !== 'normal') return; 67 | window.tabs?.forEach((tab) => { 68 | if (tab.id === undefined || tab.id in tablockCaches) return; 69 | const matchedTab = previousTablockCaches.find( 70 | (t) => t.url === tab.url && t.index === tab.index 71 | ); 72 | if (!matchedTab) return; 73 | const obj = { 74 | ...matchedTab, 75 | windowId: window.id, 76 | }; 77 | setLocalItem(`${PREFIX_TABLOCK}${tab.id}`, obj); 78 | tablockCaches[tab.id] = obj; 79 | }); 80 | }); 81 | 82 | chrome.tabs.onCreated.addListener(function (tab) { 83 | recalculateTabPositionToId(tab.windowId); 84 | }); 85 | 86 | // When closing a tab, clean up tab lock titles 87 | chrome.tabs.onRemoved.addListener(function (tabId, info) { 88 | // Do not delete Tablock info when the window is closing. 89 | if (!info.isWindowClosing) { 90 | recalculateTabPositionToId(info.windowId); 91 | removeLocalItems(`${PREFIX_TABLOCK}${tabId}`); 92 | } 93 | }); 94 | 95 | // Keep track of tab position to tabID 96 | chrome.tabs.onMoved.addListener(function (tabId, info) { 97 | const windowId = info.windowId; 98 | // no need for debounce 99 | _recalculateTabPositionToId(windowId); 100 | }); 101 | 102 | chrome.tabs.onDetached.addListener(function (tabId, info) { 103 | const windowId = info.oldWindowId; 104 | // no need for debounce 105 | _recalculateTabPositionToId(windowId); 106 | }); 107 | chrome.tabs.onAttached.addListener(function (tabId, info) { 108 | const windowId = info.newWindowId; 109 | // no need for debounce 110 | _recalculateTabPositionToId(windowId); 111 | }); 112 | 113 | chrome.tabs.onUpdated.addListener(function (tabId, info, tab) { 114 | if (tabId in tablockCaches) { 115 | tablockCaches[tabId] = { 116 | tabId, 117 | windowId: tab.windowId, 118 | index: tab.index, 119 | title: tab.title || '', 120 | url: tab.url, 121 | }; 122 | } 123 | }); 124 | 125 | // Get tablock caches when extension startsup 126 | chrome.runtime.onStartup.addListener(function () { 127 | // tablock caches are stored locally 128 | getAllLocalItems().then(function (items) { 129 | const hasCrashed = items['crash'] as boolean | undefined; 130 | const tlc = items['tablockCaches'] as Record; 131 | Object.keys(tlc).forEach((tabId) => { 132 | previousTablockCaches.push({ 133 | ...tlc[tabId], 134 | }); 135 | }); 136 | previousCrashed = hasCrashed || false; 137 | setLocalItem('crash', true); 138 | }); 139 | getAllLocalItems().then(function (items) { 140 | // Clean up residual Tablock keys stored in storage, since we fill those up through cache 141 | Object.keys(items).forEach((itemKey) => { 142 | if (itemKey.startsWith(PREFIX_TABLOCK)) { 143 | removeLocalItems(itemKey); 144 | } 145 | }); 146 | }); 147 | }); 148 | 149 | if (isChrome) { 150 | // Indicate that all states are saved successfully 151 | chrome.runtime.onSuspend.addListener(function () { 152 | removeLocalItems('crash'); 153 | }); 154 | 155 | // // Flag that won't be cleaned if crashed 156 | chrome.runtime.onSuspendCanceled.addListener(function () { 157 | setLocalItem('crash', true); 158 | }); 159 | } 160 | -------------------------------------------------------------------------------- /src/background/onInstall.ts: -------------------------------------------------------------------------------- 1 | import { TabOption } from '../shared/types'; 2 | import { KEY_DEFAULT_TAB_OPTION, PREFIX_REGEX } from '../shared/utils'; 3 | import { 4 | getAllSyncItems, 5 | removeSyncItems, 6 | setSyncItem, 7 | setLocalItem, 8 | } from '../shared/storageUtils'; 9 | import { setDefaultOption } from '../shared/storageHandler'; 10 | 11 | // On Extension Update 12 | interface LegacyUserOptionsSchema { 13 | options: { [key in TabOption]: boolean }; 14 | } 15 | type LegacyStorageSchema = { 16 | [key: string]: { title: string }; 17 | } & LegacyUserOptionsSchema; 18 | 19 | // UPDATE PREVIOUSLY STORED TITLES ON EXTENSION UPDATE 20 | chrome.runtime.onInstalled.addListener((details) => { 21 | const prev = details.previousVersion; 22 | // Upgrading from v0 or v1 23 | if (prev && (prev.startsWith('0.') || prev.startsWith('1.'))) { 24 | getAllSyncItems().then((items) => { 25 | const storage = items as LegacyStorageSchema; 26 | for (const key in storage) { 27 | // v0 tab lock mistake. 28 | if (key.startsWith('#')) { 29 | removeSyncItems(key); 30 | continue; 31 | } 32 | // v1 options key 33 | if (key === 'options') { 34 | const options = storage.options; 35 | let option: TabOption = 'onetime'; 36 | if (options.domain) option = 'domain'; 37 | if (options.tablock) option = 'tablock'; 38 | if (options.exact) option = 'exact'; 39 | setDefaultOption(option); 40 | removeSyncItems(key); 41 | continue; 42 | } 43 | const item = storage[key]; 44 | // v1 regex URL matcher 45 | if (key.startsWith('Tab#')) { 46 | removeSyncItems(key); // TabLocks shouldn't be stored in sync anymore 47 | setLocalItem(key, item); 48 | } 49 | if (key.startsWith('*') && key.endsWith('*')) { 50 | removeSyncItems(key); 51 | setSyncItem(PREFIX_REGEX, item); 52 | } 53 | } 54 | }); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /src/background/retitle.ts: -------------------------------------------------------------------------------- 1 | import { injectTitle, revertRetitle } from '../shared/injectedScripts'; 2 | 3 | import { storageChangeHandler } from '../shared/storageHandler'; 4 | import { 5 | TabLockTitle, 6 | ExactTitle, 7 | DomainTitle, 8 | RegexTitle, 9 | TabOption, 10 | RuntimeMessageRequest, 11 | NewTitle, 12 | StorageAction, 13 | StoredTitle, 14 | } from '../shared/types'; 15 | 16 | function escapeRegExp(str: string) { 17 | return str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 18 | } 19 | 20 | let tablocks: TabLockTitle[] = []; 21 | let exacts: ExactTitle[] = []; 22 | let domains: DomainTitle[] = []; 23 | let regexes: RegexTitle[] = []; 24 | 25 | const cache: string[] = []; // To avoid eventListener reacting to its own change 26 | let wait = false; 27 | const waitList: (() => void)[] = []; // for each tab? 28 | 29 | function executeNext() { 30 | wait = false; 31 | if (waitList.length) { 32 | waitList.shift()?.(); 33 | } 34 | } 35 | 36 | // Update the tab title 37 | function insertTitle(tabId: number, newTitle: string, option: TabOption) { 38 | function execute() { 39 | wait = true; 40 | cache.push(newTitle); 41 | //if (recursionStopper.shouldStop(tabId)) return; 42 | console.log('Changing the title to ' + newTitle); 43 | const escapedTitle = newTitle.replace(/'/g, "\\'"); 44 | const code = `${injectTitle.toString()}; injectTitle('${escapedTitle}', '${option}');`; 45 | chrome.tabs.executeScript( 46 | tabId, 47 | { 48 | code, 49 | runAt: 'document_end', 50 | }, 51 | executeNext 52 | ); 53 | } 54 | 55 | if (wait) { 56 | waitList.push(execute); 57 | } else { 58 | execute(); 59 | } 60 | } 61 | 62 | // Returns a title specified by regex 63 | async function decodeTitle( 64 | oldTitle: string, 65 | newTitle: NewTitle, 66 | tabId: number, 67 | url: string | null = null, 68 | urlPattern: RegExp | null = null 69 | ): Promise { 70 | if (typeof newTitle === 'object') { 71 | if (newTitle.replacerType === 'function') { 72 | const code = newTitle.func; 73 | return new Promise((resolve, reject) => { 74 | chrome.tabs.executeScript( 75 | tabId, 76 | { 77 | code, 78 | runAt: 'document_end', 79 | }, 80 | (results: string[]) => { 81 | resolve(results[0]); 82 | } 83 | ); 84 | }); 85 | } else { 86 | const pattern = newTitle.captureRegex; 87 | const replacement = newTitle.titleRegex; 88 | const flags = newTitle.flags; 89 | newTitle = oldTitle.replace(new RegExp(pattern, flags), replacement); 90 | } 91 | return ''; 92 | } else if (newTitle.includes('$0')) { 93 | // // Make sure it doesn't use the cached old title and as an original title. 94 | // const newTitleRegex = new RegExp( 95 | // escapeRegExp(newTitle.replace('$0', 'RETITLE_ORIGINAL_TITLE')).replace( 96 | // 'RETITLE_ORIGINAL_TITLE', 97 | // '(.+)' 98 | // ) 99 | // ); 100 | // const match = newTitleRegex.exec(oldTitle); 101 | // if (match) { 102 | // oldTitle = match[1]; 103 | // } 104 | // newTitle = newTitle.replace('$0', oldTitle).replace(/\\/g, ''); // the first $0 turns into the previous title 105 | } 106 | if (url && urlPattern) { 107 | newTitle = newTitle.replace(/\$\{([0-9])\}/g, '$$$1'); 108 | newTitle = url.replace(urlPattern, newTitle); 109 | } 110 | console.log(`New title decoded for ${oldTitle} is: ${newTitle}`); 111 | return newTitle; 112 | } 113 | 114 | const URL_PARAMS_REGEX = /(\?.*$|#.*$)/g; 115 | 116 | function compareURLs(url1: string, url2: string, ignoreParams?: boolean) { 117 | if (ignoreParams) { 118 | url1 = url1.replace(URL_PARAMS_REGEX, ''); 119 | url2 = url2.replace(URL_PARAMS_REGEX, ''); 120 | } 121 | if (url1.endsWith('/')) url1 = url1.slice(0, url1.length - 2); 122 | if (url2.endsWith('/')) url2 = url2.slice(0, url2.length - 2); 123 | return url1 === url2; 124 | } 125 | function compareDomains(url: string, domain: string, useFull?: boolean) { 126 | if (useFull) { 127 | url = url.replace(URL_PARAMS_REGEX, ''); 128 | url = url.replace(URL_PARAMS_REGEX, ''); 129 | } 130 | return url === domain; 131 | } 132 | 133 | // Listens for tab title changes, and update them if necessary. 134 | chrome.tabs.onUpdated.addListener(function (tabId, info, tab) { 135 | if (info.title) { 136 | const infoTitle = info.title; 137 | let url = tab.url || ''; 138 | let index = cache.indexOf(infoTitle); 139 | if (index > -1) { 140 | // TODO: detect titles with $0 141 | cache.splice(index, 1); 142 | return; // I'm the one who changed the title to this 143 | } 144 | console.log(infoTitle); 145 | if (!infoTitle) return; 146 | (async () => { 147 | for (const tabTitle of tablocks) { 148 | if (tabTitle.tabId === tabId) { 149 | insertTitle( 150 | tabId, 151 | await decodeTitle(infoTitle, tabTitle.newTitle!, tabId), 152 | tabTitle.option 153 | ); 154 | return; 155 | } 156 | } 157 | for (const exactTitle of exacts) { 158 | if (compareURLs(url, exactTitle.url, exactTitle.ignoreParams)) { 159 | insertTitle( 160 | tabId, 161 | await decodeTitle(infoTitle, exactTitle.newTitle!, tabId), 162 | exactTitle.option 163 | ); 164 | return; 165 | } 166 | } 167 | for (const domainTitle of domains) { 168 | if (compareDomains(url, domainTitle.domain, true)) { 169 | insertTitle( 170 | tabId, 171 | await decodeTitle(infoTitle, domainTitle.newTitle!, tabId), 172 | domainTitle.option 173 | ); 174 | return; 175 | } 176 | } 177 | for (const regexTitle of regexes) { 178 | const pattern = regexTitle.urlPattern; 179 | try { 180 | const urlPattern = new RegExp(pattern); 181 | if (urlPattern.test(url)) { 182 | insertTitle( 183 | tabId, 184 | await decodeTitle( 185 | infoTitle, 186 | regexTitle.newTitle!, 187 | tabId, 188 | url, 189 | urlPattern 190 | ), 191 | regexTitle.option 192 | ); 193 | } 194 | } catch (e) { 195 | console.log(e); 196 | } 197 | } 198 | })(); 199 | } 200 | }); 201 | 202 | const generateActionHandler = ( 203 | state: T[], 204 | equals: (t1: T, t2: T) => boolean 205 | ) => (action: StorageAction, newTitle: T) => { 206 | switch (action) { 207 | case 'add': 208 | state.push(newTitle); 209 | break; 210 | case 'edit': { 211 | const index = state.findIndex((t) => equals(t, newTitle)); 212 | if (index >= 0) { 213 | state[index] = newTitle; 214 | } 215 | } 216 | case 'remove': { 217 | const index = state.findIndex((t) => equals(t, newTitle)); 218 | if (index >= 0) { 219 | state.splice(index, 1); 220 | } 221 | } 222 | } 223 | }; 224 | 225 | const onTablockChange = generateActionHandler( 226 | tablocks, 227 | (t1, t2) => t1.tabId === t2.tabId 228 | ); 229 | const onExactChange = generateActionHandler( 230 | exacts, 231 | (t1, t2) => t1.url === t2.url 232 | ); 233 | const onDomainChange = generateActionHandler( 234 | domains, 235 | (t1, t2) => t1.domain === t2.domain 236 | ); 237 | const onRegexChange = generateActionHandler( 238 | regexes, 239 | (t1, t2) => t1.urlPattern === t2.urlPattern 240 | ); 241 | 242 | chrome.storage.onChanged.addListener( 243 | storageChangeHandler({ 244 | onTablockChange, 245 | onExactChange, 246 | onDomainChange, 247 | onRegexChange, 248 | }) 249 | ); 250 | 251 | function revertTitle(tabId: number) { 252 | chrome.tabs.executeScript(tabId, { 253 | code: `${revertRetitle.toString()}; revertRetitle();`, 254 | runAt: 'document_end', 255 | }); 256 | } 257 | 258 | // Receives rename message from popup.js 259 | chrome.runtime.onMessage.addListener(function (request: RuntimeMessageRequest) { 260 | console.log(request); 261 | if (request.type === 'rename') { 262 | (async () => { 263 | insertTitle( 264 | request.tabId, 265 | await decodeTitle(request.oldTitle, request.newTitle, 3), 266 | request.option 267 | ); 268 | })(); 269 | } else if (request.type === 'revert') { 270 | revertTitle(request.tabId); 271 | } 272 | }); 273 | -------------------------------------------------------------------------------- /src/options/AdvancedSettings.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | const AdvancedSettings = () => { 4 | return ( 5 |
6 |
    7 |
  • 8 |
    Beta Feature: Regex Replacement
    9 |
    10 | 11 |
    This feature may not work as expected!
    12 |
    13 | There's a beta feature that lets you replace titles using regex. 14 | If you do not know what regex is, I don't recommend using this 15 | feature. 16 |
    17 | The syntax is
    18 | /regex/replacement/flags 19 |
    20 | Notice that there are three forward slashes, so if you want to use 21 | a slash in regex or replacement, you need to escape it with a 22 | backward slash \.
    23 | In replacement, you can use regex's captured groups with 24 | $1, $2 and so on. 25 |
    26 | Possible flags are "g" for global, and "i" for ignore case. Do not 27 | forget the last slash if not using any flags. 28 |
    29 | Examples: 30 |
    31 | /.*/Lazy/ is the same as just setting the title to 32 | "Lazy". 33 |
    34 | /(.*)/LAZ $1/ will replace "old title" to "LAZ old 35 | title". 36 |
    37 | /(.*)/r\/$1/ will replace "Lazy" to "r/Lazy". 38 |
    39 | /([a-z])/0$1/gi will replace "sPonGe" to 40 | "0s0P0o0n0G0e" 41 |
    42 | /f([^o]+)(.*)/FB $2$1/i will replace "Facebook" to 43 | "FB ookaceb" (but why) 44 |
    45 |
    46 |
  • 47 |
  • 48 |
    49 | Beta Feature: Add Your Own URL Pattern 50 |
    51 |
    52 | 53 |
    This feature may not work as expected!
    54 |
    55 | Another beta feature that lets you create your own URL pattern 56 | match. 57 |
    58 | Note that regex matching has the lowest priority when searching 59 | for a URL match. 60 |
    61 | The URL pattern must start and end with an asterisk 62 | * 63 |
    64 | Instead of using $1, $2 to use capture groups, use ${1}, ${2}{' '} 65 | instead for URLs. 66 |
    67 | Examples:
    68 | *reddit\.com/(r/[^/]+)* | Red ${1} will change 69 | https://www.reddit.com/r/funny to 70 | Red r/funny It can be combined with the title regex 71 | mentioned above too. 72 |
    73 | 74 | *\.([^.]+)\.com/(.*)* | /(.*)/${1} $1 ${2}/ 75 | 76 | will change https://www.reddit.com/r/funny to 77 | reddit funny r/funny
    78 |
    79 |
    80 |
    81 |
    82 |
    83 | 87 | 88 | 92 |
    93 |
    94 | 98 | 99 |
    100 |
    101 |
    102 | Add Patterns 103 | 104 | check 105 | 106 |
    107 |
    108 |
    109 |
    110 |
    111 |
  • 112 |
113 |
114 | ); 115 | }; 116 | 117 | export default AdvancedSettings; 118 | -------------------------------------------------------------------------------- /src/options/ContextMenuSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact'; 2 | import { useEffect, useState } from 'preact/compat'; 3 | import Switch from '@material-ui/core/Switch'; 4 | import { createContextMenu } from '../shared/utils'; 5 | 6 | const ContextMenuSwitch = () => { 7 | const [contextMenu, setContextMenu] = useState(false); 8 | 9 | useEffect(() => { 10 | if (contextMenu) { 11 | } 12 | }, [contextMenu]); 13 | 14 | const toggleContextMenu = () => { 15 | if (contextMenu) { 16 | chrome.contextMenus.removeAll(); 17 | } else { 18 | createContextMenu(); 19 | } 20 | setContextMenu(!contextMenu); 21 | }; 22 | 23 | return ( 24 | 29 | ); 30 | }; 31 | 32 | export default ContextMenuSwitch; 33 | -------------------------------------------------------------------------------- /src/options/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Chip from '@material-ui/core/Chip'; 5 | import Container from '@material-ui/core/Container'; 6 | 7 | import ChromeIcon from '../../static/svgs/chrome.svg'; 8 | import FirefoxIcon from '../../static/svgs/firefox.svg'; 9 | import GitHubIcon from '../../static/svgs/github.svg'; 10 | 11 | const isChrome = BROWSER === 'chrome'; 12 | 13 | const chipProps = { 14 | size: 'small', 15 | clickable: true, 16 | color: 'primary', 17 | component: 'a', 18 | variant: 'outlined', 19 | target: '_blank', 20 | rel: 'noopener noreferrer', 21 | } as const; 22 | 23 | const useStyles = makeStyles((theme) => ({ 24 | root: { 25 | fontSize: '0.8em', 26 | background: 'black', 27 | }, 28 | container: { 29 | display: 'flex', 30 | justifyContent: 'space-between', 31 | alignItems: 'center', 32 | }, 33 | links: { 34 | margin: '10px 0', 35 | '& > *': { 36 | margin: '0 10px', 37 | }, 38 | '& svg': { 39 | height: '1.2em', 40 | }, 41 | }, 42 | copyRight: { 43 | flex: 1, 44 | textAlign: 'right', 45 | marginRight: '20px', 46 | }, 47 | })); 48 | 49 | const Chrome = ( 50 | } 52 | label={isChrome ? 'Rate Me!' : 'Chrome'} 53 | href="https://chrome.google.com/webstore/detail/tab-retitle/hilgambgdpjgljhjdaccadahckpdiapo" 54 | {...chipProps} 55 | /> 56 | ); 57 | const Firefox = ( 58 | } 60 | label={isChrome ? 'Firefox' : 'Rate Me!'} 61 | href="https://addons.mozilla.org/en-US/firefox/addon/tab-retitle/" 62 | {...chipProps} 63 | /> 64 | ); 65 | 66 | const Footer = () => { 67 | const styles = useStyles(); 68 | const links = isChrome ? [Chrome, Firefox] : [Firefox, Chrome]; 69 | 70 | return ( 71 |
72 | 73 |
74 | {links} 75 | } 77 | label="GitHub" 78 | href="https://github.com/Lazyuki/ReTitle" 79 | {...chipProps} 80 | /> 81 |
82 |
© 2021 Lazyuki
83 |
84 |
85 | ); 86 | }; 87 | 88 | export default Footer; 89 | -------------------------------------------------------------------------------- /src/options/Header.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { FC } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import AppBar from '@material-ui/core/AppBar'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import Container from '@material-ui/core/Container'; 7 | 8 | const useStyles = makeStyles((theme) => ({ 9 | root: { 10 | position: 'relative', 11 | background: 'black', 12 | color: 'white', 13 | }, 14 | title: { 15 | margin: '10px 0', 16 | fontSize: '2em', 17 | }, 18 | })); 19 | 20 | const Header: FC = ({ children }) => { 21 | const styles = useStyles(); 22 | 23 | return ( 24 | 25 | 26 | 27 | ReTitle Settings 28 | 29 | {children} 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default Header; 36 | -------------------------------------------------------------------------------- /src/options/Home.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Container from '@material-ui/core/Container'; 5 | import Tabs from '@material-ui/core/Tabs'; 6 | import Tab from '@material-ui/core/Tab'; 7 | import Box from '@material-ui/core/Box'; 8 | import Typography from '@material-ui/core/Typography'; 9 | 10 | import Header from './Header'; 11 | import SavedTitles from './SavedTitles'; 12 | import UserSettings from './UserSettings'; 13 | import Footer from './Footer'; 14 | import AdvancedSettings from './AdvancedSettings'; 15 | 16 | const useStyles = makeStyles((theme) => ({ 17 | root: { 18 | display: 'flex', 19 | flexDirection: 'column', 20 | height: '100vh', 21 | }, 22 | main: { 23 | flex: '1', 24 | overflow: 'auto', 25 | }, 26 | tabRoot: { 27 | flexGrow: 1, 28 | backgroundColor: theme.palette.background.paper, 29 | display: 'flex', 30 | height: 224, 31 | }, 32 | tabs: { 33 | borderRight: `1px solid ${theme.palette.divider}`, 34 | }, 35 | })); 36 | 37 | function a11yProps(index: number) { 38 | return { 39 | id: `vertical-tab-${index}`, 40 | 'aria-controls': `vertical-tabpanel-${index}`, 41 | }; 42 | } 43 | 44 | interface TabPanelProps { 45 | children?: any; 46 | index: number; 47 | value: any; 48 | } 49 | 50 | function TabPanel(props: TabPanelProps) { 51 | const { children, value, index, ...other } = props; 52 | 53 | return ( 54 | 67 | ); 68 | } 69 | 70 | const Home = () => { 71 | const styles = useStyles(); 72 | const [tab, setTab] = useState(0); 73 | 74 | const handleChange = (event: any, newValue: number) => { 75 | setTab(newValue); 76 | }; 77 | 78 | return ( 79 |
80 |
81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 |
100 |
101 |
102 | ); 103 | }; 104 | 105 | export default Home; 106 | -------------------------------------------------------------------------------- /src/options/KeyboardShortcutSettings.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState, useEffect } from 'preact/compat'; 3 | import KeyboardIcon from '@material-ui/icons/Keyboard'; 4 | import Button from '@material-ui/core/Button'; 5 | 6 | const KEYBOARD_SHORTCUT_NAME = '_execute_browser_action'; 7 | const defaultShortcut = 'Alt+Shift+X'; 8 | const isChrome = BROWSER === 'chrome'; 9 | const linkURL = isChrome 10 | ? 'chrome://extensions/shortcuts' 11 | : 'https://support.mozilla.org/en-US/kb/manage-extension-shortcuts-firefox'; 12 | 13 | const KeyboardShortcutSettings = () => { 14 | const [shortcut, setShortcut] = useState(defaultShortcut); 15 | 16 | useEffect(() => { 17 | chrome.commands.getAll((commands) => { 18 | for (const command of commands) { 19 | if (command.name === KEYBOARD_SHORTCUT_NAME && command.shortcut) { 20 | setShortcut(command.shortcut); 21 | } 22 | } 23 | }); 24 | }, []); 25 | 26 | return ( 27 | 28 | {shortcut} 29 | 39 | 40 | ); 41 | }; 42 | 43 | export default KeyboardShortcutSettings; 44 | -------------------------------------------------------------------------------- /src/options/OptionsApp.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import Home from './Home'; 4 | import ReTitleThemeWrapper from '../shared/ReTitleThemeWrapper'; 5 | 6 | const OptionsApp = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default OptionsApp; 15 | -------------------------------------------------------------------------------- /src/options/RegexPopup.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | const AdvancedSettings = () => { 4 | const regex101 = 5 | 'https://regex101.com/?flavor=javascript®ex=reddit%5C.com%5C%2Fr%5C%2F(%5B%5E%2F%5D%2B)'; 6 | return ( 7 |
8 |
    9 |
  • 10 |

    11 | Regeular Expression (regex) Replacement 12 |

    13 |
    Regex is a powerful tool.
    14 |
    15 | 16 |
    17 | Regex 101 18 |
    19 | The syntax is
    20 | /regex/replacement/flags 21 |
    22 | Notice that there are three forward slashes, so if you want to use 23 | a slash in regex or replacement, you need to escape it with a 24 | backward slash \.
    25 | In replacement, you can use regex's captured groups with 26 | $1, $2 and so on. 27 |
    28 | Possible flags are "g" for global, and "i" for ignore case. Do not 29 | forget the last slash if not using any flags. 30 |
    31 | Examples: 32 |
    33 | /.*/Lazy/ is the same as just setting the title to 34 | "Lazy". 35 |
    36 | /(.*)/LAZ $1/ will replace "old title" to "LAZ old 37 | title". 38 |
    39 | /(.*)/r\/$1/ will replace "Lazy" to "r/Lazy". 40 |
    41 | /([a-z])/0$1/gi will replace "sPonGe" to 42 | "0s0P0o0n0G0e" 43 |
    44 | /f([^o]+)(.*)/FB $2$1/i will replace "Facebook" to 45 | "FB ookaceb" (but why) 46 |
    47 |
    48 |
  • 49 |
  • 50 |
    51 | Beta Feature: Add Your Own URL Pattern 52 |
    53 |
    54 | 55 |
    This feature may not work as expected!
    56 |
    57 | Another beta feature that lets you create your own URL pattern 58 | match. 59 |
    60 | Note that regex matching has the lowest priority when searching 61 | for a URL match. 62 |
    63 | The URL pattern must start and end with an asterisk 64 | * 65 |
    66 | Instead of using $1, $2 to use capture groups, use ${1}, ${2}{' '} 67 | instead for URLs. 68 |
    69 | Examples:
    70 | *reddit\.com/(r/[^/]+)* | Red ${1} will change 71 | https://www.reddit.com/r/funny to 72 | Red r/funny It can be combined with the title regex 73 | mentioned above too. 74 |
    75 | 76 | *\.([^.]+)\.com/(.*)* | /(.*)/${1} $1 ${2}/ 77 | 78 | will change https://www.reddit.com/r/funny to 79 | reddit funny r/funny
    80 |
    81 |
    82 |
    83 |
  • 84 |
85 |
86 | ); 87 | }; 88 | 89 | export default AdvancedSettings; 90 | -------------------------------------------------------------------------------- /src/options/SavedTitles.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { 3 | useState, 4 | useEffect, 5 | useCallback, 6 | useMemo, 7 | StateUpdater, 8 | } from 'preact/compat'; 9 | import { makeStyles } from '@material-ui/core/styles'; 10 | import List from '@material-ui/core/List'; 11 | import ListItem from '@material-ui/core/ListItem'; 12 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 13 | import ListItemText from '@material-ui/core/ListItemText'; 14 | import DeleteIcon from '@material-ui/icons/Delete'; 15 | import EditIcon from '@material-ui/icons/Edit'; 16 | 17 | import { 18 | StorageChanges, 19 | TabLockTitle, 20 | ExactTitle, 21 | DomainTitle, 22 | RegexTitle, 23 | StorageAction, 24 | StoredTitle, 25 | } from '../shared/types'; 26 | import { storageChangeHandler, initTitles } from '../shared/storageHandler'; 27 | 28 | const noop = () => {}; 29 | 30 | const useStyles = makeStyles((theme) => ({ 31 | root: { 32 | position: 'relative', 33 | }, 34 | })); 35 | 36 | const editTitleState = ( 37 | predicate: (title: T) => boolean, 38 | newTitle: T 39 | ) => (previous: T[]) => { 40 | const index = previous.findIndex(predicate); 41 | if (index >= 0) { 42 | const copy = [...previous]; 43 | copy[index] = newTitle; 44 | return copy; 45 | } else { 46 | return previous; 47 | } 48 | }; 49 | const removeTitleState = ( 50 | predicate: (title: T) => boolean 51 | ) => (previous: T[]) => { 52 | const index = previous.findIndex(predicate); 53 | if (index >= 0) { 54 | const copy = [...previous].splice(index, 1); 55 | return copy; 56 | } else { 57 | return previous; 58 | } 59 | }; 60 | 61 | const generateCallback = ( 62 | stateUpdater: StateUpdater, 63 | predicate: (t1: T, t2: T) => boolean 64 | ) => (action: StorageAction, newTitle: T) => { 65 | switch (action) { 66 | case 'add': 67 | stateUpdater((p) => [...p, newTitle]); 68 | break; 69 | case 'remove': 70 | stateUpdater(removeTitleState((t) => predicate(t, newTitle))); 71 | case 'edit': 72 | stateUpdater(editTitleState((t) => predicate(t, newTitle), newTitle)); 73 | } 74 | }; 75 | 76 | const SavedTitles = () => { 77 | const styles = useStyles(); 78 | const [tabLocks, setTablocks] = useState([]); 79 | const [exacts, setExacts] = useState([]); 80 | const [domains, setDomains] = useState([]); 81 | const [regexes, setRegexes] = useState([]); 82 | 83 | const onTablockChange = useCallback( 84 | generateCallback(setTablocks, (t1, t2) => t1.tabId === t2.tabId), 85 | [] 86 | ); 87 | 88 | const onExactChange = useCallback( 89 | generateCallback(setExacts, (t1, t2) => t1.url === t2.url), 90 | [] 91 | ); 92 | 93 | const onDomainChange = useCallback( 94 | generateCallback(setDomains, (t1, t2) => t1.domain === t2.domain), 95 | [] 96 | ); 97 | 98 | const onRegexChange = useCallback( 99 | generateCallback(setRegexes, (t1, t2) => t1.urlPattern === t2.urlPattern), 100 | [] 101 | ); 102 | 103 | useEffect(() => { 104 | const handler = storageChangeHandler({ 105 | onTablockChange, 106 | onExactChange, 107 | onDomainChange, 108 | onRegexChange, 109 | }); 110 | initTitles({ 111 | onTablockChange, 112 | onExactChange, 113 | onDomainChange, 114 | onRegexChange, 115 | }); 116 | chrome.storage.onChanged.addListener(handler); 117 | return () => chrome.storage.onChanged.removeListener(handler); 118 | }, []); 119 | 120 | return ( 121 |
122 |
    123 |
  • Temporary titles are not shown here
  • 124 |
  • 125 | Use $0 to insert the original title. So if you want 126 | Title to say My Title, set the title name to{' '} 127 | My $0. 128 |
  • 129 |
130 |

Tabs

131 | 132 | {tabLocks.map((t) => { 133 | return ( 134 | 135 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | ); 146 | })} 147 | 148 |

Exact URLs

149 | 150 | {exacts.map((t) => { 151 | return ( 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | ); 162 | })} 163 | 164 |

Domains

165 | 166 | {domains.map((t) => { 167 | return ( 168 | 169 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | ); 180 | })} 181 | 182 |

Regexes

183 | 184 | {regexes.map((t) => { 185 | return ( 186 | 187 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | ); 198 | })} 199 | 200 |
201 | ); 202 | }; 203 | 204 | export default SavedTitles; 205 | -------------------------------------------------------------------------------- /src/options/UserSettings.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState, useEffect, useCallback } from 'preact/compat'; 3 | import Radio from '@material-ui/core/Radio'; 4 | import RadioGroup from '@material-ui/core/RadioGroup'; 5 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 6 | import FormControl from '@material-ui/core/FormControl'; 7 | import Switch from '@material-ui/core/Switch'; 8 | import { makeStyles } from '@material-ui/core/styles'; 9 | 10 | import ContextMenuSwitch from './ContextMenuSwitch'; 11 | import KeyboardShortcutSettings from './KeyboardShortcutSettings'; 12 | import { TabOption } from '../shared/types'; 13 | import { 14 | getDefaultOption, 15 | setDefaultOption, 16 | setTheme, 17 | getTheme, 18 | } from '../shared/storageHandler'; 19 | 20 | const useStyles = makeStyles((theme) => ({ 21 | root: { 22 | fontSize: '0.8em', 23 | borderTop: `1px solid ${theme.palette.primary.main}`, 24 | }, 25 | label: { 26 | margin: '0 12px 0 16px', 27 | fontWeight: 600, 28 | }, 29 | radios: { 30 | margin: '10px 0', 31 | paddingLeft: '20px', 32 | }, 33 | button: { 34 | textAlign: 'center', 35 | }, 36 | saved: { 37 | opacity: 0, 38 | zIndex: -1, 39 | position: 'absolute', 40 | marginTop: '4px', 41 | marginLeft: '-20px', 42 | display: 'inline-block', 43 | transition: '0.2s', 44 | '&[data-shown="true"]': { 45 | opacity: 1, 46 | marginLeft: '10px', 47 | }, 48 | }, 49 | check: { 50 | verticalAlign: 'middle', 51 | color: theme.palette.success.main, 52 | }, 53 | })); 54 | 55 | const UserSettings = () => { 56 | const styles = useStyles(); 57 | const [option, setOption] = useState('onetime'); 58 | const [isDark, setIsDark] = useState( 59 | localStorage.getItem('theme') !== 'light' 60 | ); 61 | 62 | useEffect(() => { 63 | getDefaultOption().then(setDefaultOption); 64 | getTheme().then((theme) => setIsDark(theme === 'dark')); 65 | }, []); 66 | 67 | const handleOptionChange = useCallback((e: any) => { 68 | const newOption: TabOption = e.target.value; 69 | setOption(newOption); 70 | setDefaultOption(newOption); 71 | }, []); 72 | 73 | const toggleTheme = () => { 74 | setTheme(isDark ? 'light' : 'dark'); 75 | setIsDark(!isDark); 76 | }; 77 | 78 | return ( 79 |
80 |
81 | {/* Popup flickers when using light theme, so stick to dark theme until I figure out a workaround */} 82 | 89 | } 90 | labelPlacement="start" 91 | label={} 92 | /> 93 |
94 | 95 | 96 |
97 |
98 | 99 | 100 |
101 |
102 | 103 |

104 | This option will be used as the default value in the extension popup 105 | menu. These options are in the order of priority, so for example{' '} 106 | Set for this tab will be matched instead of 107 | Only exact match if the given tab matches both. 108 |

109 | 110 | 116 | } 119 | label={ 120 |
121 | Set it temporarily 122 | Temporarily sets the title just once which does not persist at 123 | all. Reloading or changing the URL loses the changed title. 124 |
125 | } 126 | /> 127 | } 130 | label={ 131 |
132 | Set for this tab 133 | This will match the current tab no matter the URL, but will be 134 | lost once the tab is closed. This will persist if you close the 135 | window and reopen it with previous tabs. However, if the browser 136 | crashes or the window didn't load the tabs on startup then this 137 | settings will be lost. 138 |
139 | } 140 | /> 141 | } 144 | label={ 145 |
146 | Set for this exact URL 147 | This will match the URL exactly and it will be persistent across 148 | sessions. You can set this to ignore URL parameters such as{' '} 149 | #, &, and ? to be 150 | ignored in the saved titles page. 151 |
152 | } 153 | /> 154 | } 157 | label={ 158 |
159 | Set for this domain 160 | This will match the domain part of the URL and it will be 161 | persistent across sessions. 162 |
163 | } 164 | /> 165 |
166 |
167 |
168 | ); 169 | }; 170 | 171 | export default UserSettings; 172 | -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import OptionsApp from './OptionsApp'; 3 | 4 | render(, document.body); 5 | -------------------------------------------------------------------------------- /src/popup/BookmarkTitle.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact'; 2 | import { useEffect, useState } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Tooltip from '@material-ui/core/Tooltip'; 5 | 6 | const useStyles = makeStyles({ 7 | root: { 8 | cursor: 'pointer', 9 | marginBottom: '10px', 10 | marginRight: '60px', 11 | textOverflow: 'ellipsis', 12 | overflow: 'hidden', 13 | whiteSpace: 'nowrap', 14 | }, 15 | label: { 16 | opacity: 0.7, 17 | fontSize: '12px', 18 | }, 19 | span: { 20 | fontSize: '14px', 21 | }, 22 | }); 23 | 24 | const BookmarkTitle = ({ 25 | url, 26 | setInputValue, 27 | ...rest 28 | }: { 29 | url?: string; 30 | setInputValue: (value: string) => void; 31 | } & JSX.HTMLAttributes) => { 32 | const [bookmarkedTitle, setBookmarkedTitle] = useState(null); 33 | const styles = useStyles(); 34 | 35 | useEffect(() => { 36 | if (url) { 37 | try { 38 | chrome.bookmarks.search({ url }, function (results) { 39 | if (results[0]) { 40 | setBookmarkedTitle(results[0].title); 41 | } 42 | }); 43 | } catch (e) { 44 | // URL not allowed; 45 | } 46 | } 47 | }, [url]); 48 | 49 | return bookmarkedTitle !== null ? ( 50 | 54 |
setInputValue(bookmarkedTitle)} 57 | > 58 | From Bookmark 59 | 60 | {bookmarkedTitle} 61 | 62 |
63 |
64 | ) : null; 65 | }; 66 | 67 | export default BookmarkTitle; 68 | -------------------------------------------------------------------------------- /src/popup/ContentScriptChecker.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState, useEffect, FC } from 'preact/compat'; 3 | const isChrome = BROWSER === 'chrome'; 4 | 5 | // Taken from https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts 6 | const FF_BLOCKED_SITES = [ 7 | 'accounts-static.cdn.mozilla.net', 8 | 'accounts.firefox.com', 9 | 'addons.cdn.mozilla.net', 10 | 'addons.mozilla.org', 11 | 'api.accounts.firefox.com', 12 | 'content.cdn.mozilla.net', 13 | 'content.cdn.mozilla.net', 14 | 'discovery.addons.mozilla.org', 15 | 'input.mozilla.org', 16 | 'install.mozilla.org', 17 | 'oauth.accounts.firefox.com', 18 | 'profile.accounts.firefox.com', 19 | 'support.mozilla.org', 20 | 'sync.services.mozilla.com', 21 | 'testpilot.firefox.com', 22 | ]; 23 | 24 | const ContentScriptChecker: FC<{ 25 | domain: string; 26 | url?: string; 27 | className: string; 28 | }> = ({ domain, url, className, children }) => { 29 | const [isAllowed, setIsAllowed] = useState(true); 30 | useEffect(() => { 31 | browser.tabs.executeScript({ code: '' }).catch(() => { 32 | setIsAllowed(false); 33 | }); 34 | }, []); 35 | if (isChrome) { 36 | if (url && /^(chrome|chrome-extension):/.test(url)) { 37 | return ( 38 |
39 | Extensions are disabled on browser internal URLs 40 |
41 | ); 42 | } 43 | } else { 44 | if (url) { 45 | if (/^(about|moz-extension):/.test(url)) { 46 | return ( 47 |
48 | Add-ons are disabled on browser internal URLs 49 |
50 | ); 51 | } 52 | if (!isAllowed) { 53 | const sanitizedURL = url.split('?')[0].split('#')[0]; 54 | if (/(\.json|\.pdf)$/i.test(sanitizedURL)) { 55 | return ( 56 |
57 | PDFs and JSON files cannot be accessed by add-ons on Firefox for{' '} 58 | 62 | security reasons 63 | 64 | . You can still use this extension with those files on Google 65 | Chrome. 66 |
67 | ); 68 | } else if (FF_BLOCKED_SITES.includes(domain)) { 69 | return ( 70 |
71 | Firefox add-ons cannot access 72 |
73 | {domain} 74 |
75 | websites.{' '} 76 | 80 | Details 81 | 82 |
83 | ); 84 | } else { 85 | return ( 86 |
87 | This extension does not work on this page 88 |
89 | ); 90 | } 91 | } 92 | } 93 | } 94 | return
{children}
; 95 | }; 96 | 97 | export default ContentScriptChecker; 98 | -------------------------------------------------------------------------------- /src/popup/CurrentTitle.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Tooltip from '@material-ui/core/Tooltip'; 4 | 5 | const useStyles = makeStyles({ 6 | root: { 7 | cursor: 'pointer', 8 | marginBottom: '10px', 9 | marginRight: '60px', 10 | textOverflow: 'ellipsis', 11 | overflow: 'hidden', 12 | whiteSpace: 'nowrap', 13 | }, 14 | label: { 15 | opacity: 0.7, 16 | fontSize: '12px', 17 | }, 18 | span: { 19 | fontSize: '14px', 20 | }, 21 | }); 22 | 23 | const CurrentTitle = ({ 24 | currentTitle, 25 | setInputValue, 26 | ...rest 27 | }: { 28 | currentTitle?: string; 29 | setInputValue: (value: string) => void; 30 | } & JSX.HTMLAttributes) => { 31 | const styles = useStyles(); 32 | 33 | return ( 34 | 38 |
setInputValue(currentTitle || '')} 41 | > 42 | Current Title 43 | 44 | {currentTitle} 45 | 46 |
47 |
48 | ); 49 | }; 50 | 51 | export default CurrentTitle; 52 | -------------------------------------------------------------------------------- /src/popup/Form.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState, useEffect, useRef, useCallback } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import Radio from '@material-ui/core/Radio'; 6 | import RadioGroup from '@material-ui/core/RadioGroup'; 7 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 8 | import FormControl from '@material-ui/core/FormControl'; 9 | import Button from '@material-ui/core/Button'; 10 | import Switch from '@material-ui/core/Switch'; 11 | 12 | import ContentScriptChecker from './ContentScriptChecker'; 13 | import Revert from './Revert'; 14 | import Gear from './Gear'; 15 | import CurrentTitle from './CurrentTitle'; 16 | import BookmarkTitle from './BookmarkTitle'; 17 | import { extractDomain } from '../shared/utils'; 18 | import { TabOption } from '../shared/types'; 19 | import { 20 | saveTitle, 21 | getDefaultOption, 22 | setDefaultOption, 23 | } from '../shared/storageHandler'; 24 | import RegexInputGroup from '../shared/RegexInputGroup'; 25 | import { getCurrentOption } from '../shared/injectedScripts'; 26 | 27 | const isChrome = BROWSER === 'chrome'; 28 | 29 | const useStyles = makeStyles({ 30 | root: { 31 | width: '450px', 32 | padding: '20px', 33 | overflow: 'hidden', 34 | }, 35 | warning: { 36 | padding: '40px 20px', 37 | fontSize: '18px', 38 | textAlign: 'center', 39 | '& a': { 40 | color: 'orange !important', 41 | }, 42 | }, 43 | input: { 44 | width: '100%', 45 | }, 46 | radios: { 47 | margin: '10px 0', 48 | paddingLeft: '20px', 49 | }, 50 | button: { 51 | display: 'block', 52 | margin: '0 auto', 53 | }, 54 | version: { 55 | position: 'absolute', 56 | bottom: '20px', 57 | right: '20px', 58 | opacity: 0.5, 59 | fontSize: '12px', 60 | }, 61 | }); 62 | 63 | const Form = () => { 64 | const [tab, setTab] = useState(null); 65 | const [option, setOption] = useState('onetime'); 66 | const [activeOption, setActiveOption] = useState(null); 67 | const [useRegex, setUseRegex] = useState(false); 68 | const [inputValue, setInputValue] = useState(''); 69 | const inputRef = useRef(null); 70 | const styles = useStyles(); 71 | 72 | useEffect(() => { 73 | if (tab && typeof tab.id === 'number') { 74 | chrome.tabs.executeScript( 75 | tab.id, 76 | { 77 | code: `${getCurrentOption.toString()}; getCurrentOption();`, 78 | runAt: 'document_end', 79 | }, 80 | (results) => { 81 | const result = results?.[0]; 82 | if (result) { 83 | setActiveOption(result); 84 | } 85 | } 86 | ); 87 | } 88 | }, [tab]); 89 | 90 | const setInputAndSelect = useCallback( 91 | (newInput?: string) => { 92 | setInputValue(newInput || ''); 93 | setTimeout(() => { 94 | inputRef?.current?.select(); 95 | }, 0); 96 | }, 97 | [inputRef] 98 | ); 99 | 100 | const initialize = useCallback( 101 | (tabs: chrome.tabs.Tab[]) => { 102 | const currentTab = tabs[0]; 103 | setTab(currentTab); 104 | setInputAndSelect(currentTab.title || ''); 105 | getDefaultOption().then(setDefaultOption); 106 | }, 107 | [setInputAndSelect] 108 | ); 109 | 110 | useEffect(() => { 111 | chrome.tabs.query( 112 | { 113 | active: true, 114 | currentWindow: true, 115 | }, 116 | initialize 117 | ); 118 | }, [initialize]); 119 | 120 | const handleOptionChange = useCallback((e: any) => { 121 | setOption(e.target.value); 122 | }, []); 123 | 124 | const setTitle = useCallback(() => { 125 | if (tab) { 126 | saveTitle(inputValue, option, tab); 127 | if (isChrome) { 128 | chrome.runtime.sendMessage({ 129 | type: 'rename', 130 | tabId: tab.id, 131 | oldTitle: tab.title, 132 | newTitle: inputValue, 133 | option, 134 | }); 135 | window.close(); 136 | } else { 137 | browser.runtime 138 | .sendMessage({ 139 | type: 'rename', 140 | tabId: tab.id, 141 | oldTitle: tab.title, 142 | newTitle: inputValue, 143 | option, 144 | }) 145 | .then(() => window.close()); 146 | } 147 | } 148 | }, [inputValue, option, tab]); 149 | 150 | const domain = extractDomain(tab?.url); 151 | 152 | return ( 153 |
154 | {tab && activeOption && } 155 | 156 | 161 | 165 | 166 | setUseRegex((p) => !p)} 171 | name="use-regex" 172 | color="primary" 173 | /> 174 | } 175 | label="Use Regex" 176 | /> 177 | {useRegex ? ( 178 | setInputValue(regexString)} 180 | /> 181 | ) : ( 182 | { 190 | if (e.which == 13 && !e.shiftKey) { 191 | e.preventDefault(); 192 | setTitle(); 193 | return false; 194 | } 195 | }} 196 | onChange={(e: any) => setInputValue(e.target.value)} 197 | onFocus={(e: any) => e.target.select()} 198 | /> 199 | )} 200 | {activeOption &&
Option: {activeOption} is active
} 201 | 202 | 208 | } 211 | label="Set it temporarily" 212 | /> 213 | } 216 | label="Set for this tab" 217 | /> 218 | } 221 | label="Set for this exact URL" 222 | /> 223 | } 226 | label={`Set for this domain: ${domain}`} 227 | disabled={!domain} 228 | /> 229 | 230 | 231 | 239 |
240 | 241 |
v{EXTENSION_VERSION}
242 |
243 | ); 244 | }; 245 | 246 | export default Form; 247 | -------------------------------------------------------------------------------- /src/popup/Gear.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import GearSvg from '../../static/svgs/gear.svg'; 4 | import AccessibleButton from '../shared/AccessibleButton'; 5 | import Tooltip from '@material-ui/core/Tooltip'; 6 | 7 | const useStyles = makeStyles({ 8 | root: { 9 | position: 'absolute', 10 | zIndex: 2, 11 | top: '10px', 12 | right: '10px', 13 | cursor: 'pointer', 14 | width: '35px', 15 | height: '35px', 16 | padding: '5px', 17 | '& svg': { 18 | willChange: 'opacity, transform', 19 | opacity: 0.7, 20 | transition: 'all 0.3s ease-in-out', 21 | }, 22 | '&:hover svg': { 23 | transform: 'rotate(45deg)', 24 | opacity: 1, 25 | }, 26 | }, 27 | }); 28 | 29 | const Gear = () => { 30 | const styles = useStyles(); 31 | return ( 32 | 33 | chrome.runtime.openOptionsPage(() => window.close())} 36 | > 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default Gear; 44 | -------------------------------------------------------------------------------- /src/popup/PopupApp.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import Form from './Form'; 4 | import ReTitleThemeWrapper from '../shared/ReTitleThemeWrapper'; 5 | 6 | const PopupApp = () => { 7 | return ( 8 | 9 |
10 | 11 | ); 12 | }; 13 | 14 | export default PopupApp; 15 | -------------------------------------------------------------------------------- /src/popup/Revert.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import AccessibleButton from '../shared/AccessibleButton'; 4 | import ReplayIcon from '@material-ui/icons/Replay'; 5 | import Tooltip from '@material-ui/core/Tooltip'; 6 | 7 | const useStyles = makeStyles({ 8 | root: { 9 | position: 'absolute', 10 | zIndex: 2, 11 | top: '10px', 12 | right: '40px', 13 | cursor: 'pointer', 14 | width: '35px', 15 | height: '35px', 16 | padding: '5px', 17 | color: '#ff3333', 18 | '& svg': { 19 | willChange: 'opacity, transform', 20 | opacity: 0.7, 21 | transition: 'all 0.3s ease-in-out', 22 | }, 23 | '&:hover svg': { 24 | transform: 'rotate(-45deg)', 25 | opacity: 1, 26 | }, 27 | }, 28 | }); 29 | 30 | const Revert = ({ tabId }: { tabId: number }) => { 31 | const styles = useStyles(); 32 | return ( 33 | 34 | 37 | chrome.runtime.sendMessage({ 38 | type: 'revert', 39 | tabId, 40 | }) 41 | } 42 | > 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default Revert; 50 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import PopupApp from './PopupApp'; 3 | 4 | render(, document.body); 5 | -------------------------------------------------------------------------------- /src/shared/AccessibleButton.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX, FunctionComponent as FC } from 'preact'; 2 | import clsx from 'clsx'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | root: { 7 | appearance: 'none', 8 | background: 'none', 9 | border: 'none', 10 | 11 | '&:hover': { 12 | outline: 'none', 13 | }, 14 | color: theme.palette.text.primary, 15 | }, 16 | })); 17 | 18 | const AccessibleButton: FC> = ({ 19 | className, 20 | ...rest 21 | }) => { 22 | const styles = useStyles(); 23 | return