├── img ├── 128.png ├── icon.png ├── icon.xcf ├── mark.png ├── icon-checked.png └── icon-checked.xcf ├── screenshot.png ├── .gitignore ├── packages.dhall ├── static ├── settings.html └── settings.css ├── src ├── Main.purs ├── Data.purs ├── SettingsFFI.purs ├── SettingsFFI.js ├── background.js └── Settings.purs ├── spago.dhall ├── package.json ├── manifest.json ├── test └── main.js ├── LICENSE └── README.md /img/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klntsky/switch-to-audible-tab/HEAD/img/128.png -------------------------------------------------------------------------------- /img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klntsky/switch-to-audible-tab/HEAD/img/icon.png -------------------------------------------------------------------------------- /img/icon.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klntsky/switch-to-audible-tab/HEAD/img/icon.xcf -------------------------------------------------------------------------------- /img/mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klntsky/switch-to-audible-tab/HEAD/img/mark.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klntsky/switch-to-audible-tab/HEAD/screenshot.png -------------------------------------------------------------------------------- /img/icon-checked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klntsky/switch-to-audible-tab/HEAD/img/icon-checked.png -------------------------------------------------------------------------------- /img/icon-checked.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klntsky/switch-to-audible-tab/HEAD/img/icon-checked.xcf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bower_components/ 2 | /.web-ext-artifacts/ 3 | /node_modules/ 4 | /.pulp-cache/ 5 | /output/ 6 | /generated-docs/ 7 | /.psc-package/ 8 | /.psc* 9 | /.purs* 10 | /.psa* 11 | *.xpi 12 | /.spago/ 13 | /.cache/ 14 | package-lock.json 15 | static/settings.js 16 | /dist/ 17 | -------------------------------------------------------------------------------- /packages.dhall: -------------------------------------------------------------------------------- 1 | let upstream = 2 | https://github.com/purescript/package-sets/releases/download/psc-0.14.4-20211005/packages.dhall sha256:2ec351f17be14b3f6421fbba36f4f01d1681e5c7f46e0c981465c4cf222de5be 3 | 4 | let overrides = {=} 5 | 6 | let additions = {=} 7 | 8 | in upstream ⫽ overrides ⫽ additions 9 | -------------------------------------------------------------------------------- /static/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Switch to Audible Tab Preferences 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Main.purs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import Prelude 4 | import SettingsFFI (load) 5 | import Effect (Effect) 6 | import Settings (initialSettings, mkComponent) 7 | import Halogen.Aff (awaitBody, runHalogenAff) 8 | import Halogen.VDom.Driver (runUI) 9 | 10 | main :: Effect Unit 11 | main = do 12 | runHalogenAff do 13 | body <- awaitBody 14 | state <- load initialSettings 15 | runUI (mkComponent state) unit body 16 | -------------------------------------------------------------------------------- /spago.dhall: -------------------------------------------------------------------------------- 1 | { sources = [ "src/**/*.purs" ] 2 | , name = "switch-to-audible-tab" 3 | , dependencies = 4 | [ "aff" 5 | , "aff-promise" 6 | , "argonaut" 7 | , "argonaut-codecs" 8 | , "arrays" 9 | , "control" 10 | , "effect" 11 | , "either" 12 | , "foldable-traversable" 13 | , "halogen" 14 | , "integers" 15 | , "maybe" 16 | , "newtype" 17 | , "prelude" 18 | , "profunctor-lenses" 19 | , "tuples" 20 | , "web-dom" 21 | ] 22 | , packages = ./packages.dhall 23 | } 24 | -------------------------------------------------------------------------------- /src/Data.purs: -------------------------------------------------------------------------------- 1 | module Data where 2 | 3 | import Prelude 4 | 5 | -- | Should be in sync with background.js 6 | type ValidSettings = 7 | { includeMuted :: Boolean 8 | , allWindows :: Boolean 9 | , includeFirst :: Boolean 10 | , sortBackwards :: Boolean 11 | , menuOnTab :: Boolean 12 | , markAsAudible :: Array { domain :: String 13 | , enabled :: Boolean 14 | , withSubdomains :: Boolean 15 | } 16 | , websitesOnlyIfNoAudible :: Boolean 17 | , followNotifications :: Boolean 18 | , notificationsTimeout :: Int 19 | , maxNotificationDuration :: Int 20 | , notificationsFirst :: Boolean 21 | } 22 | -------------------------------------------------------------------------------- /src/SettingsFFI.purs: -------------------------------------------------------------------------------- 1 | module SettingsFFI 2 | ( save 3 | , load 4 | , setFocus 5 | , isValidDomain 6 | , isGoogle 7 | , openHotkeySettings 8 | ) 9 | where 10 | 11 | import Prelude 12 | import Effect.Aff (Aff) 13 | import Data.Argonaut (Json) 14 | import Control.Promise (Promise) 15 | import Control.Promise as Promise 16 | import Effect (Effect) 17 | import Data.Argonaut.Decode (decodeJson) 18 | import Data.Maybe (fromMaybe) 19 | import Data.Either (hush) 20 | import Web.DOM (Element) 21 | import Data (ValidSettings) 22 | 23 | 24 | save :: ValidSettings -> Aff Unit 25 | save = Promise.toAffE <<< save_ 26 | 27 | 28 | load :: ValidSettings -> Aff ValidSettings 29 | load a = map (fromMaybe a <<< hush <<< decodeJson) <<< 30 | Promise.toAffE $ load_ a 31 | 32 | foreign import setFocus :: Element -> Effect Unit 33 | foreign import save_ :: ValidSettings -> Effect (Promise Unit) 34 | foreign import load_ :: ValidSettings -> Effect (Promise Json) 35 | foreign import isValidDomain :: String -> Boolean 36 | foreign import isGoogle :: Boolean 37 | foreign import openHotkeySettings :: Effect Unit 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "switch-to-audible-tab", 3 | "private": true, 4 | "scripts": { 5 | "test": "ava --verbose", 6 | "copy-polyfill": "cp node_modules/webextension-polyfill/dist/browser-polyfill.min.js dist/browser-polyfill.js", 7 | "build": "npm run copy-polyfill && spago bundle-app --to static/settings.js && npm run uglify", 8 | "uglify": "parcel build --no-source-maps --target browser --out-file static/settings.js static/settings.js", 9 | "pack": "zip -r ./switch-to-audible-tab.xpi img static dist src/background.js manifest.json && zip -r ./switch-to-audible-tab.zip img static dist src/background.js manifest.json", 10 | "pack-source": "zip -r ./switch-to-audible-tab-source.zip img src manifest.json README.md package.json package-lock.json static spago.dhall packages.dhall .gitignore LICENSE" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://gitlab.com/klntsky/switch-to-audible-tab.git" 15 | }, 16 | "devDependencies": { 17 | "ava": "^1.4.1", 18 | "parcel": "^1.12.3", 19 | "webextension-polyfill": "^0.8.0", 20 | "webextensions-geckodriver": "^0.6.1" 21 | }, 22 | "dependencies": {} 23 | } 24 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Switch to Audible Tab", 4 | "version": "0.0.10", 5 | "description": "Focus on tab that is currently making sound", 6 | "icons": { 7 | "128": "img/128.png" 8 | }, 9 | "background": { 10 | "scripts": ["dist/browser-polyfill.js", "src/background.js"] 11 | }, 12 | "options_ui": { 13 | "page": "static/settings.html", 14 | "browser_style": false, 15 | "open_in_tab": true 16 | }, 17 | "browser_action": { 18 | "default_icon": { 19 | "128": "img/128.png" 20 | }, 21 | "default_title": "Switch to audible tab (Alt+Shift+A)" 22 | }, 23 | "commands": { 24 | "_execute_browser_action": { 25 | "description": "Switch to audible tab", 26 | "suggested_key": { 27 | "default": "Alt+Shift+A" 28 | } 29 | } 30 | }, 31 | "permissions": [ 32 | "tabs", "storage", "contextMenus" 33 | ], 34 | "applications": { 35 | "gecko": { 36 | "id": "{0cd726db-f954-44f2-bf4f-7ed0de734de2}", 37 | "strict_min_version": "57.0" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/main.js: -------------------------------------------------------------------------------- 1 | /* global test require __dirname */ 2 | const fs = require('fs'); 3 | const webExtensionsGeckoDriver = require('webextensions-geckodriver'); 4 | 5 | const manifestFile = __dirname + '/../manifest.json'; 6 | const manifest = require(manifestFile); 7 | 8 | const { firefox, webdriver } = webExtensionsGeckoDriver; 9 | 10 | const fxOptions = 11 | new firefox.Options() 12 | .headless() 13 | .windowSize({ height: 600, width: 800 }); 14 | 15 | const test = require('ava'); 16 | 17 | test("test", async t => { 18 | const webExtension = await webExtensionsGeckoDriver( 19 | manifestFile, 20 | { fxOptions } 21 | ); 22 | 23 | const geckodriver = webExtension.geckodriver; 24 | 25 | const button = await geckodriver.wait(webdriver.until.elementLocated( 26 | // browser_actions automatically have applications.gecko.id as prefix 27 | // special chars in the id are replaced with _ 28 | webdriver.By.id( 29 | manifest.applications.gecko.id.replace('@', '_') + '-browser-action' 30 | ) 31 | ), 1000); 32 | 33 | t.is(await button.getAttribute('tooltiptext'), manifest.browser_action.default_title); 34 | 35 | geckodriver.quit(); 36 | t.pass(); 37 | }); 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /src/SettingsFFI.js: -------------------------------------------------------------------------------- 1 | /* global browser exports */ 2 | 3 | exports.isGoogle = navigator.vendor === "Google Inc."; 4 | 5 | exports.save_ = function (settings) { 6 | return function () { 7 | return browser.storage.local.set({ settings: settings }); 8 | }; 9 | }; 10 | 11 | exports.load_ = function (defaults) { 12 | return function () { 13 | return browser.storage.local.get({ settings: defaults }).then(function (res) { 14 | return res.settings; 15 | }); 16 | }; 17 | }; 18 | 19 | exports.setFocus = function(elem) { 20 | return function() { 21 | elem.focus(); 22 | }; 23 | }; 24 | 25 | // Adapted from https://github.com/miguelmota/is-valid-domain 26 | exports.isValidDomain = function (v, opts) { 27 | if (typeof v !== 'string') 28 | return false; 29 | if (!(opts instanceof Object)) 30 | opts = {}; 31 | 32 | var parts = v.split('.'); 33 | if (parts.length <= 1) 34 | return false; 35 | 36 | var tld = parts.pop(); 37 | var tldRegex = /^(?:xn--)?[a-zA-Z0-9]+$/gi; 38 | 39 | if (!tldRegex.test(tld)) 40 | return false; 41 | if (opts.subdomain == false && parts.length > 1) 42 | return false; 43 | 44 | var isValid = parts.every(function(host, index) { 45 | if (opts.wildcard && index === 0 && host === '*' && parts.length > 1) 46 | return true; 47 | 48 | var hostRegex = /^(?!:\/\/)([a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/gi; 49 | 50 | return hostRegex.test(host); 51 | }); 52 | 53 | return isValid; 54 | }; 55 | 56 | exports.openHotkeySettings = function () { 57 | browser.tabs.create({url: 'chrome://extensions/shortcuts'}); 58 | }; 59 | -------------------------------------------------------------------------------- /static/settings.css: -------------------------------------------------------------------------------- 1 | h3 { 2 | font-size: 1rem; 3 | margin-top: 1rem; 4 | margin-bottom: 0.4rem; 5 | } 6 | 7 | .button { 8 | vertical-align: middle; 9 | background-color: #EEE; 10 | color: black; 11 | border-radius: 4px; 12 | border: 1px solid #999; 13 | font: 16px normal inherit; 14 | font-size: 14px; 15 | padding: 3px 6px 3px 6px; 16 | border-bottom: 2px solid #999; 17 | cursor: pointer; 18 | min-width: 90px; 19 | text-align: center; 20 | text-transform: uppercase; 21 | font-family: sans; 22 | margin: 10px; 23 | } 24 | 25 | body { 26 | overflow-y: hidden; 27 | font: 400 17px/1.43 'PT Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, 'Helvetica Neue', sans-serif; 28 | min-height: 300px; 29 | } 30 | 31 | input[type=checkbox] { 32 | vertical-align: middle; 33 | margin-top: 2px; 34 | } 35 | 36 | .invalid { 37 | box-shadow: 0px 0px 4px 1px rgba(255,0,0,1); 38 | } 39 | 40 | .tooltip { 41 | position: relative; 42 | display: inline-block; 43 | border-bottom: 1px dotted black; 44 | background-color: #06C; 45 | color: white; 46 | border-radius: 20px; 47 | width: 18px; 48 | vertical-align: middle; 49 | text-align: center; 50 | font-size: 12px; 51 | margin-left: 10px; 52 | -webkit-user-select: none; 53 | -moz-user-select: none; 54 | -ms-user-select: none; 55 | user-select: none; 56 | } 57 | 58 | .tooltip .tooltiptext { 59 | visibility: hidden; 60 | width: 280px; 61 | background-color: black; 62 | color: #fff; 63 | border-radius: 6px; 64 | padding: 5px 0; 65 | position: absolute; 66 | z-index: 1; 67 | bottom: 150%; 68 | left: 50%; 69 | margin-left: -160px; 70 | } 71 | 72 | .tooltiptext { 73 | padding: 7px !important; 74 | text-align: left; 75 | } 76 | 77 | .tooltip .tooltiptext::after { 78 | content: ""; 79 | position: absolute; 80 | top: 100%; 81 | left: 54%; 82 | margin-left: -5px; 83 | border-width: 5px; 84 | border-style: solid; 85 | border-color: black transparent transparent transparent; 86 | } 87 | 88 | .tooltip:hover .tooltiptext { 89 | visibility: visible; 90 | } 91 | 92 | input[type=text]:focus, input[type=number]:focus { 93 | outline: 1px solid blue; 94 | } 95 | 96 | input.invalid:focus { 97 | outline: 1px solid red; 98 | } 99 | 100 | label { 101 | -webkit-user-select: none; 102 | -moz-user-select: none; 103 | -ms-user-select: none; 104 | user-select: none; 105 | } 106 | 107 | #timeout-field, #duration-field { 108 | width: 5em; 109 | } 110 | 111 | .disabled { 112 | pointer-events:none; 113 | opacity: 0.6; 114 | } 115 | 116 | #container { 117 | position: fixed; 118 | left: 50%; 119 | transform: translate(-50%, 0%); 120 | } 121 | 122 | #dev-note { 123 | font-style: italic; 124 | color: #555; 125 | } 126 | 127 | .dev-note-icon { 128 | font-style: normal; 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Switch to audible tab ![Mozilla Add-on Users](https://img.shields.io/amo/users/switch-to-audible-tab?label=Users%20on%20Firefox) ![Chrome Web Store Users](https://img.shields.io/chrome-web-store/users/obhmhiijebijngjodncffkecfiolonom?label=Users%20on%20Chrome) 2 | 3 | [Install for FireFox](https://addons.mozilla.org/en-US/firefox/addon/switch-to-audible-tab/) / Install from [Chrome Web Store](https://chrome.google.com/webstore/detail/switch-to-audible-tab/obhmhiijebijngjodncffkecfiolonom) / [Gitlab](https://gitlab.com/klntsky/switch-to-audible-tab) / [Github](https://github.com/8084/switch-to-audible-tab) 4 | 5 | ![preview](screenshot.png) 6 | 7 | This WebExtension allows the user to switch to the tab that is currently making sound. 8 | 9 | Default **Alt+Shift+A** hotkey can be used instead of the toolbar button. 10 | 11 | # Configuration options 12 | 13 | ## Hotkey 14 | 15 | Firefox implements unified UI for hotkey preferences. The default hotkey [can be changed in Firefox addons settings](https://support.mozilla.org/en-US/kb/manage-extension-shortcuts-firefox). 16 | 17 | ## Multiple tabs 18 | 19 | If there are multiple audible tabs, the addon will cycle through them and then return to the initial tab (the latter can be opted off at the settings page). 20 | 21 | If there are audible tabs belonging to other windows, these windows will be switched too (this can be opted off as well). 22 | 23 | It is also possible to control the order in which tabs will be visited: available options are left-to-right and right-to-left. This will only make difference if there are more than two tabs in a cycle. 24 | 25 | If there are no audible tabs, the addon will do nothing. 26 | 27 | ## Notifications 28 | 29 | Some websites play short notification sounds when user's attention is needed. Notification following feature makes it possible to react to a notification during some configurable period of time after the notification sound has ended. A sound is treated as a notification if it is not coming from currently active tab AND its duration is less than notification duration limit (configurable). 30 | 31 | Notifications can be given first priority or treated the same. 32 | 33 | ## Muted tabs 34 | 35 | Tabs that are muted by the user are also considered audible (this can be changed at the settings page). 36 | 37 | ## Marking tabs as audible by domain 38 | 39 | There is an option to enter a list of domains which will be marked as audible regardless of actual state. Can be used to avoid spending your time on finding *that bandcamp tab*. 40 | 41 | Also, there is an option to include domains in the list only if there are no "actually" audible tabs. 42 | 43 | ## Default settings 44 | 45 | Although tuning advanced options is highly recommended, the defaults will always stay simple to avoid newcomer confusion. 46 | 47 | # Building from source 48 | 49 | You'll need to install spago & purescript. Via npm: 50 | 51 | ``` 52 | npm install spago purescript 53 | ``` 54 | 55 | Or use alternative methods. 56 | 57 | To build the extension and get the `.xpi` file, run: 58 | 59 | ``` 60 | npm install 61 | npm run build 62 | npm run pack 63 | ``` 64 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | /* global browser */ 2 | 3 | const isGoogle = navigator.vendor === "Google Inc."; 4 | 5 | /** Default settings */ 6 | // This should be synchronised with Settings.purs 7 | const defaults = { 8 | includeMuted: true, 9 | allWindows: true, 10 | includeFirst: true, 11 | sortBackwards: false, 12 | menuOnTab: false, 13 | markAsAudible: [], 14 | websitesOnlyIfNoAudible: false, 15 | followNotifications: true, 16 | notificationsTimeout: 10, 17 | maxNotificationDuration: 10, 18 | notificationsFirst: true, 19 | }; 20 | 21 | // A flag indicating that no tabs are selected by queries. 22 | const NoTabs = Symbol('NoTabs'); 23 | // A flag indicating that the tab switching cycle was ended. 24 | const FromStart = Symbol('FromStart'); 25 | 26 | let settings = null; 27 | // First active tab, i.e. the tab that was active when the user started 28 | // cycling through audible tabs. 29 | let firstActive = null; // or { id: , windowId: , ... } 30 | // Whether we are waiting for tab activation (semaphore variable for switchTo) 31 | let waitingForActivation = false; 32 | let lastTabs = []; 33 | 34 | // Tabs marked as audible by the user 35 | let marked = []; 36 | const MARK_MENU_ID = "mark-as-audible"; 37 | const SETTINGS_MENU_ID = "open-settings"; 38 | 39 | // Used to follow notifications 40 | const possibleNotifications = new Map(); // tabId => timestamp 41 | 42 | const catcher = (f) => async function () { 43 | try { 44 | return await f(...arguments); 45 | } catch (e) { 46 | console.log('Error in', unescape(f), e); 47 | } 48 | }; 49 | 50 | const addMarkedTab = tab => { 51 | if (!marked.some(mkd => mkd.id === tab.id)) { 52 | marked.push(tab); 53 | } 54 | }; 55 | 56 | const removeMarkedTab = tab => { 57 | marked = marked.filter(mkd => mkd.id !== tab.id); 58 | }; 59 | 60 | const updateIcon = isChecked => { 61 | browser.browserAction.setIcon({ 62 | path: isChecked ? 'img/icon-checked.png' : 'img/128.png' 63 | }); 64 | }; 65 | 66 | /** Returns active tab in the current window. */ 67 | const getActiveTab = async () => { 68 | return browser.tabs.query({ active: true, currentWindow: true }) 69 | .then(x => x[0]); 70 | }; 71 | 72 | const runSettingsMigrations = settings => { 73 | // TODO: get a list of properties from defaults itself? 74 | const added_props = [ 75 | 'websitesOnlyIfNoAudible', 76 | 'followNotifications', 77 | 'notificationsTimeout', 78 | 'maxNotificationDuration', 79 | 'notificationsFirst' 80 | ]; 81 | 82 | for (let prop of added_props) { 83 | if (typeof settings[prop] == 'undefined') { 84 | settings[prop] = defaults[prop]; 85 | } 86 | } 87 | 88 | return settings; 89 | }; 90 | 91 | /** Returns settings object */ 92 | const loadSettings = catcher(async () => { 93 | const r = await browser.storage.local.get({ 94 | settings: defaults 95 | }); 96 | 97 | // Set global variable 98 | settings = runSettingsMigrations(r.settings) ; 99 | 100 | return r.settings; 101 | }); 102 | 103 | browser.storage.onChanged.addListener((changes, area) => { 104 | if (typeof changes.settings === 'object') { 105 | settings = changes.settings.newValue; 106 | updateMenuContexts(settings); 107 | } 108 | }); 109 | 110 | const sortTabs = tabs => { 111 | if (firstActive) 112 | tabs = [...tabs, firstActive]; 113 | 114 | // Sort by windowIds, then by indices. 115 | tabs = tabs.sort((a, b) => { 116 | let ordering = a.windowId - b.windowId || a.index - b.index; 117 | if (settings.sortBackwards) { 118 | ordering *= -1; 119 | } 120 | return ordering; 121 | }); 122 | 123 | let ix = tabs.findIndex(x => x === firstActive); 124 | if (ix != -1) { 125 | tabs = [...tabs.slice(ix + 1), ...tabs.slice(0, ix)]; 126 | } 127 | 128 | return tabs; 129 | }; 130 | 131 | const filterRepeating = tabs => { 132 | const ids = new Set(); 133 | 134 | return tabs.filter(tab => { 135 | if (ids.has(tab.id)) { 136 | return false; 137 | } 138 | ids.add(tab.id); 139 | return true; 140 | }); 141 | }; 142 | 143 | /** Given an array of tabs and the active tab, returns next tab's ID. 144 | @param tabs {Tab[]} 145 | @param activeTab {Tab} 146 | @returns {Tab|NoTabs|FromStart} 147 | */ 148 | const nextTab = (tabs, activeTab) => { 149 | if (!tabs.length) 150 | return NoTabs; 151 | 152 | for (let i = 0; i < tabs.length - 1; i++) { 153 | if (tabs[i].id === activeTab.id) { 154 | return tabs[i+1]; 155 | } 156 | }; 157 | 158 | return FromStart; 159 | }; 160 | 161 | browser.contextMenus.create({ 162 | id: MARK_MENU_ID, 163 | type: "checkbox", 164 | title: "Mark this tab as audible", 165 | contexts: ["browser_action"], 166 | }); 167 | 168 | browser.contextMenus.create({ 169 | id: SETTINGS_MENU_ID, 170 | title: "Open Preferences", 171 | contexts: ["browser_action"], 172 | }); 173 | 174 | // Add an item to context menu for tabs. 175 | const updateMenuContexts = catcher(async settings => { 176 | const contexts = ["browser_action"]; 177 | if (settings.menuOnTab && !isGoogle) { 178 | contexts.push("tab"); 179 | } 180 | await browser.contextMenus.update(MARK_MENU_ID, { 181 | contexts 182 | }); 183 | }); 184 | 185 | 186 | loadSettings().then(updateMenuContexts); 187 | getActiveTab().then(tab => firstActive = tab); 188 | 189 | // When some tab gets removed, check if we are referencing it. 190 | browser.tabs.onRemoved.addListener(tabId => { 191 | if (firstActive.id === tabId) { 192 | firstActive = null; 193 | } 194 | marked = marked.filter(mkd => mkd.id !== tabId); 195 | possibleNotifications.delete(tabId); 196 | }); 197 | 198 | // Track the last active tab which was activated by the user or another 199 | // extension 200 | browser.tabs.onActivated.addListener(async ({ tabId, windowId }) => { 201 | const checked = marked.some(mkd => mkd.id === tabId); 202 | // no need to await 203 | browser.contextMenus.update(MARK_MENU_ID, { checked }); 204 | updateIcon(checked); 205 | 206 | if (waitingForActivation) { 207 | waitingForActivation = false; 208 | } else { 209 | const index = (await browser.tabs.query({}).then(r => r.find(r => r.id == tabId))).index; 210 | 211 | // This tab was activated by the user or another extension, 212 | // therefore we need to set it as firstActive. 213 | firstActive = { id: tabId, windowId, index }; 214 | } 215 | }); 216 | 217 | browser.windows.onFocusChanged.addListener(catcher(async (windowId) => { 218 | const activeTab = await getActiveTab(); 219 | const checked = marked.some(mkd => mkd.id === activeTab.id); 220 | updateIcon(checked); 221 | if (lastTabs.every(tab => tab.id !== activeTab.id)) { 222 | firstActive = activeTab; 223 | } 224 | })); 225 | 226 | browser.browserAction.onClicked.addListener(catcher(async () => { 227 | // Choose how to switch to the tab, depending on `settings.allWindows`. 228 | // Maintain waitingForActivation flag. 229 | const switchTo = async (tab, activeTab) => { 230 | 231 | if (!tab || tab.id === activeTab.id || waitingForActivation) 232 | return; 233 | 234 | waitingForActivation = true; 235 | 236 | await browser.tabs.update(tab.id, { active: true }); 237 | 238 | if (settings.allWindows) { 239 | await browser.windows.update(tab.windowId, { focused: true }); 240 | } 241 | 242 | if (!settings.includeFirst) { 243 | firstActive = null; 244 | } 245 | 246 | waitingForActivation = false; 247 | }; 248 | 249 | await updateMenuContexts(settings); 250 | const activeTab = await getActiveTab(); 251 | let tabs = []; 252 | 253 | // Modify query w.r.t. settings.allWindows preference 254 | const refine = query => { 255 | if (!settings.allWindows) { 256 | query.currentWindow = true; 257 | } 258 | return query; 259 | }; 260 | 261 | tabs = [...tabs, ...await browser.tabs.query(refine({ audible: true }))]; 262 | 263 | const areReallyAudible = tabs.length != 0; 264 | 265 | if (settings.includeMuted) 266 | tabs = [...tabs, ...await browser.tabs.query(refine({ muted: true }))]; 267 | 268 | if (marked.length) 269 | tabs = [...tabs, ...marked]; 270 | 271 | // Include websites only if websitesOnlyIfAudible is false or 272 | // there are no "really" audible tabs. 273 | if (!areReallyAudible || !settings.websitesOnlyIfNoAudible) { 274 | const permanentlyMarked = settings.markAsAudible.reduce( 275 | (acc, { domain, enabled, withSubdomains }) => { 276 | if (enabled) { 277 | acc.push(withSubdomains ? `*://*.${domain}/*` : `*://${domain}/*`); 278 | } 279 | return acc; 280 | }, [] 281 | ); 282 | 283 | if (permanentlyMarked.length) 284 | tabs = [...tabs, ...await browser.tabs.query(refine({ url: permanentlyMarked }))]; 285 | } 286 | 287 | if (settings.followNotifications) { 288 | 289 | // Extract notifications from possibleNotifications 290 | const now = Date.now(); 291 | let notifications = [...possibleNotifications.values()].filter(([start, end, tab]) => { 292 | end = end || now; 293 | return end - start < settings.maxNotificationDuration * 1000; 294 | }); 295 | 296 | // Sort by starting time. Newest first. 297 | notifications.sort((a, b) => b[0] - a[0]); 298 | notifications = notifications.map(([_start, _end, tab]) => tab); 299 | 300 | if (settings.notificationsFirst) { 301 | // Prepend before others 302 | tabs = [...notifications, ...sortTabs(tabs)]; 303 | } else { 304 | // Sort everything 305 | tabs = sortTabs([...notifications, ...tabs]); 306 | } 307 | } else { 308 | tabs = sortTabs(tabs); 309 | } 310 | 311 | tabs = filterRepeating(tabs); 312 | 313 | if (firstActive) 314 | tabs = tabs.filter(tab => tab.id !== firstActive.id); 315 | 316 | lastTabs = tabs; 317 | 318 | const next = nextTab(tabs, activeTab); 319 | 320 | switch (next) { 321 | case NoTabs: 322 | if (settings.includeFirst) 323 | switchTo(firstActive, activeTab); 324 | break; 325 | 326 | case FromStart: 327 | // If includeFirst is turned off 328 | if (!settings.includeFirst 329 | // or if the firstActive tab was removed 330 | || !firstActive 331 | || activeTab.id === firstActive.id) { 332 | await switchTo(tabs[0], activeTab); 333 | } else { 334 | await switchTo(firstActive, activeTab); 335 | } 336 | break; 337 | 338 | default: 339 | await switchTo(next, activeTab); 340 | } 341 | })); 342 | 343 | 344 | // WONTFIX: api is not supported, but also we can't use tabs context menus. 345 | !isGoogle && browser.contextMenus.onShown.addListener(async function(info, tab) { 346 | if (info.menuIds.includes(MARK_MENU_ID)) { 347 | let checked = false; 348 | 349 | if (info.viewType === "sidebar") { 350 | checked = marked.some(mkd => mkd.id === tab.id); 351 | } else if (typeof info.viewType === 'undefined') { 352 | // clicked the toolbar button 353 | const activeTab = await getActiveTab(); 354 | checked = marked.some(mkd => mkd.id === activeTab.id); 355 | } 356 | 357 | await browser.contextMenus.update(MARK_MENU_ID, { checked }); 358 | await browser.contextMenus.refresh(); 359 | } 360 | }); 361 | 362 | browser.contextMenus.onClicked.addListener(async function(info, tab) { 363 | 364 | const activeTab = await getActiveTab(); 365 | if (info.menuItemId === SETTINGS_MENU_ID) { 366 | browser.runtime.openOptionsPage(); 367 | } else if (info.menuItemId === MARK_MENU_ID) { 368 | if (info.checked) { 369 | addMarkedTab(tab); 370 | } else { 371 | removeMarkedTab(tab); 372 | } 373 | 374 | if (activeTab.id === tab.id) { 375 | updateIcon(info.checked); 376 | } 377 | } 378 | }); 379 | 380 | browser.tabs.onUpdated.addListener(catcher(async (tabId, changeInfo, tab) => { 381 | if (typeof changeInfo.audible == 'boolean') { 382 | if (changeInfo.audible) { 383 | if ((await getActiveTab()).id != tabId) { 384 | possibleNotifications.set(tabId, [Date.now(), null, tab]); 385 | } 386 | } else { 387 | if (possibleNotifications.has(tabId)) { 388 | const [startTime, _end, _tab] = possibleNotifications.get(tabId); 389 | const now = Date.now(); 390 | possibleNotifications.set(tabId, [startTime, now, tab]); 391 | setTimeout(() => { 392 | // Delete only if we added it. 393 | if (possibleNotifications.get(tabId)[0] == startTime) { 394 | possibleNotifications.delete(tabId); 395 | } 396 | }, settings.notificationsTimeout * 1000); 397 | } 398 | } 399 | } 400 | })); 401 | 402 | browser.runtime.onInstalled.addListener(details => { 403 | if (details.reason == "install") { 404 | browser.runtime.openOptionsPage(); 405 | } 406 | }); 407 | -------------------------------------------------------------------------------- /src/Settings.purs: -------------------------------------------------------------------------------- 1 | module Settings where 2 | 3 | import Prelude 4 | 5 | import Control.Alternative as Alt 6 | import Data.Array (mapWithIndex) 7 | import Data.Array as A 8 | import Data.Either (Either(..)) 9 | import Data.Foldable (and) 10 | import Data.Int as Int 11 | import Data.Lens (over, set, to, view, (%~), (.~), (^.)) 12 | import Data.Lens.Index (ix) 13 | import Data.Lens.Record (prop) 14 | import Data.Maybe (Maybe(..), fromMaybe, isJust) 15 | import Data.Monoid as M 16 | import Data.Newtype (wrap) 17 | import Data.Symbol (SProxy(..)) 18 | import Data.Traversable (for_) 19 | import Data.Tuple.Nested ((/\)) 20 | import Effect.Aff (Aff) 21 | import Halogen as H 22 | import Halogen.HTML (a, br_, div, div_, h2_, h3_, hr_, input, label, text, span) 23 | import Halogen.HTML as HH 24 | import Halogen.HTML.Events (onChecked, onClick) 25 | import Halogen.HTML.Events as HE 26 | import Halogen.HTML.Properties (InputType(..), checked, class_, for, id, ref, type_, value, title, href, target) 27 | import Halogen.HTML.Properties as HP 28 | 29 | import SettingsFFI as FFI 30 | import Data (ValidSettings) 31 | 32 | 33 | type State = 34 | { pageState :: PageState 35 | , validationResult :: ValidationResult 36 | , settings :: Settings 37 | } 38 | 39 | data PageState = Normal | RestoreConfirmation 40 | 41 | derive instance eqPageState :: Eq PageState 42 | 43 | type ValidationResult = 44 | { websites :: Array Boolean 45 | , isValidTimeout :: Boolean 46 | , isValidDuration :: Boolean 47 | } 48 | 49 | goodValidationResult :: ValidationResult 50 | goodValidationResult = 51 | { websites: [] 52 | , isValidTimeout: true 53 | , isValidDuration: true 54 | } 55 | 56 | type Settings = 57 | { includeMuted :: Boolean 58 | , allWindows :: Boolean 59 | , includeFirst :: Boolean 60 | , sortBackwards :: Boolean 61 | , menuOnTab :: Boolean 62 | , markAsAudible :: Array { domain :: String 63 | , enabled :: Boolean 64 | , withSubdomains :: Boolean 65 | } 66 | , websitesOnlyIfNoAudible :: Boolean 67 | , followNotifications :: Boolean 68 | , notificationsTimeout :: String 69 | , maxNotificationDuration :: String 70 | , notificationsFirst :: Boolean 71 | } 72 | 73 | data CheckBox 74 | = IncludeMuted 75 | | AllWindows 76 | | IncludeFirst 77 | | SortBackwards 78 | | MenuOnTab 79 | | DomainEnabled Int 80 | | DomainWithSubdomains Int 81 | | WebsitesOnlyIfNoAudible 82 | | FollowNotifications 83 | | NotificationsFirst 84 | 85 | data Button 86 | = RemoveDomain Int 87 | | RestoreDefaults 88 | | AddDomain 89 | | ConfirmRestore 90 | | CancelRestore 91 | 92 | data Input 93 | = DomainField Int String 94 | | TimeoutField String 95 | | DurationField String 96 | 97 | data Action 98 | = Toggle CheckBox Boolean 99 | | Click Button 100 | | TextInput Input 101 | | OpenHotkeySettings 102 | 103 | -- This should be synchronised with background.js 104 | initialSettings :: ValidSettings 105 | initialSettings = 106 | { includeMuted: true 107 | , allWindows: true 108 | , includeFirst: true 109 | , sortBackwards: false 110 | , menuOnTab: false 111 | , markAsAudible: [] 112 | , websitesOnlyIfNoAudible: false 113 | , followNotifications: true 114 | , notificationsTimeout: 10 115 | , maxNotificationDuration: 10 116 | , notificationsFirst: true 117 | } 118 | 119 | toRuntimeSettings :: ValidSettings -> Settings 120 | toRuntimeSettings = 121 | (_notificationsTimeout %~ show) >>> (_maxNotificationDuration %~ show) 122 | 123 | mkComponent :: forall i q o. ValidSettings -> H.Component q i o Aff 124 | mkComponent s = H.mkComponent 125 | { initialState: const 126 | { pageState: Normal 127 | , validationResult: goodValidationResult 128 | , settings: toRuntimeSettings s 129 | } 130 | , render 131 | , eval: H.mkEval $ H.defaultEval { handleAction = handleAction } 132 | } 133 | 134 | render :: forall m. State -> H.ComponentHTML Action () m 135 | render state = div [ id "container" ] 136 | [ if FFI.isGoogle then h2_ [ text "Switch to Audible Tab Preferences" ] else text "" 137 | , renderGeneralSettings state 138 | , renderNotifications state 139 | , renderContextMenu state 140 | , renderDomains state 141 | , br_, br_ 142 | , renderRestoreDefaults state.pageState 143 | , hr_ 144 | , renderDonateLink 145 | ] 146 | 147 | renderGeneralSettings :: forall m. State -> H.ComponentHTML Action () m 148 | renderGeneralSettings 149 | { settings: { includeMuted, allWindows, includeFirst, sortBackwards } } = 150 | div_ $ ( 151 | if FFI.isGoogle 152 | then 153 | [ h3_ [ text "HOTKEY" ] 154 | , text "Follow " 155 | , a 156 | [ href "chrome://extensions/shortcuts" 157 | , target "_blank" 158 | , onClick $ const OpenHotkeySettings ] 159 | [ text "this link" ] 160 | , text " to set the hotkey in chrome preferences." 161 | ] 162 | else 163 | [ h3_ [ text "HOTKEY" ] 164 | , text "Follow " 165 | , a 166 | [ href "https://support.mozilla.org/en-US/kb/manage-extension-shortcuts-firefox" 167 | , target "_blank" ] 168 | [ text "this instruction" ] 169 | , text " to change the default hotkey in Firefox preferences." 170 | ]) <> 171 | [ h3_ [ text "GENERAL SETTINGS" ] 172 | , div_ 173 | [ input [ type_ InputCheckbox 174 | , checked includeMuted 175 | , onChecked $ Toggle IncludeMuted 176 | , id "includeMuted" 177 | ] 178 | , label 179 | [ for "includeMuted" ] 180 | [ text "Include muted tabs" ] 181 | , tooltip "Treat tabs muted by the user as audible" 182 | ] 183 | , div_ 184 | [ input [ type_ InputCheckbox 185 | , checked allWindows 186 | , onChecked $ Toggle AllWindows 187 | , id "allWindows" 188 | ] 189 | , label 190 | [ for "allWindows" ] 191 | [ text "Search for audible tabs in all windows" ] 192 | ] 193 | , div_ 194 | [ input [ type_ InputCheckbox 195 | , checked sortBackwards 196 | , onChecked $ Toggle SortBackwards 197 | , id "sortBackwards" 198 | ] 199 | , label 200 | [ for "sortBackwards" ] 201 | [ text "Loop in reverse order" ] 202 | , tooltip "When cycling through tabs, visit them in reverse order (i.e. right-to-left). May be useful, because new tabs usually appear last" 203 | ] 204 | , div_ 205 | [ input [ type_ InputCheckbox 206 | , checked includeFirst 207 | , onChecked $ Toggle IncludeFirst 208 | , id "includeFirst" 209 | ] 210 | , label 211 | [ for "includeFirst" ] 212 | [ text "Include initial tab" ] 213 | , tooltip "When cycling through tabs, also include the first tab from which the cycle was started" 214 | ] 215 | ] 216 | 217 | renderNotifications :: forall m o. State -> H.ComponentHTML Action o m 218 | renderNotifications { validationResult, settings } = div_ 219 | [ h3_ [ text "NOTIFICATIONS" ] 220 | , input [ type_ InputCheckbox 221 | , checked settings.followNotifications 222 | , onChecked $ Toggle FollowNotifications 223 | , id "notifications" 224 | ] 225 | , label 226 | [ for "notifications" ] 227 | [ text "Follow notifications" ] 228 | , tooltip $ "Some websites play short notification sounds when user's attention is needed. This option allows to react to a notification during some fixed period of time after the notification sound has ended. A sound is treated as a notification if it is not coming from currently active tab AND its duration is less than notification duration limit (currently set to " <> settings.maxNotificationDuration <> " seconds)." 229 | , br_ 230 | , input $ 231 | [ type_ InputCheckbox 232 | , checked settings.notificationsFirst 233 | , onChecked $ Toggle NotificationsFirst 234 | , id "notifications-first" 235 | ] <> 236 | notificationsDisabledClass 237 | , label 238 | ([ for "notifications-first" ] <> notificationsDisabledClassLabel) 239 | [ text "Prioritize notifications" ] 240 | , tooltip $ "When checked, tabs with notifications will always be shown first, before ordinary audible tabs." 241 | , br_ 242 | , label notificationsDisabledClassLabel 243 | [ text "Keep notifications for: " ] 244 | , input $ 245 | [ type_ InputNumber 246 | , value settings.notificationsTimeout 247 | , HE.onValueInput $ TextInput <<< TimeoutField 248 | , id "timeout-field" 249 | ] <> 250 | -- Highlight if invalid 251 | M.guard (not validationResult.isValidTimeout) 252 | [ class_ (wrap "invalid") 253 | , title "Invalid timeout value (must be a non-negative number)" 254 | ] <> 255 | notificationsDisabledClass 256 | , label notificationsDisabledClassLabel [ text " s." ] 257 | , tooltip "Time interval in seconds during which the addon will treat the tab that played notification sound as audible (after the sound has stopped)" 258 | , br_ 259 | , label notificationsDisabledClassLabel 260 | [ text "Notification duration limit: " ] 261 | , input $ 262 | [ type_ InputNumber 263 | , value settings.maxNotificationDuration 264 | , HE.onValueInput $ TextInput <<< DurationField 265 | , id "duration-field" 266 | ] <> 267 | -- Highlight if invalid 268 | M.guard (not validationResult.isValidDuration) 269 | [ class_ (wrap "invalid") 270 | , title "Invalid duration value (must be a non-negative number)" 271 | ] <> 272 | notificationsDisabledClass 273 | , label notificationsDisabledClassLabel 274 | [ text " s." ] 275 | , tooltip "Used to decide if a sound is a notification or not. If a tab remains audible for less than this number of seconds, it will be treated as a tab with notification. 10 seconds is the recommended value." 276 | ] 277 | where 278 | notificationsDisabledClass 279 | :: forall rest p. Array (HP.IProp (class :: String, disabled :: Boolean | rest) p) 280 | notificationsDisabledClass = 281 | M.guard (not settings.followNotifications) [ class_ (wrap "disabled"), HP.disabled true ] 282 | notificationsDisabledClassLabel 283 | :: forall rest p. Array (HP.IProp (class :: String | rest) p) 284 | notificationsDisabledClassLabel = 285 | M.guard (not settings.followNotifications) [ class_ (wrap "disabled") ] 286 | 287 | renderContextMenu :: forall m o. State -> H.ComponentHTML Action o m 288 | renderContextMenu { settings: { menuOnTab } } = 289 | if FFI.isGoogle then text "" else 290 | div_ 291 | [ h3_ [ text "CONTEXT MENU" ] 292 | , div_ 293 | [ input [ type_ InputCheckbox 294 | , checked menuOnTab 295 | , onChecked $ Toggle MenuOnTab 296 | , id "menuOnTab" 297 | ] 298 | , label 299 | [ for "menuOnTab" ] 300 | [ text "Enable 'Mark as audible' context menu option for tabs" ] 301 | , tooltip "Adds ability to manually mark tabs as audible. You can always do this by right-clicking the extension icon. A tiny indicator will be added to the extension button, showing that currently active tab was manually marked." 302 | ] 303 | ] 304 | 305 | renderDomains :: forall m. State -> H.ComponentHTML Action () m 306 | renderDomains { validationResult, settings: { markAsAudible, websitesOnlyIfNoAudible } } = div_ 307 | [ h3_ [ text "MARK DOMAINS" ] 308 | , text $ 309 | "Domains that will be marked as audible permanently." 310 | , tooltip "List the streaming services you use to navigate to them quickly" 311 | , br_ 312 | , div_ $ 313 | markAsAudible `flip mapWithIndex` 314 | \ix { domain, enabled, withSubdomains } -> 315 | let elId = "withSubdomains" <> show ix in 316 | div_ $ 317 | [ 318 | input 319 | [ type_ InputCheckbox 320 | , onChecked $ Toggle (DomainEnabled ix) 321 | , id $ "domain-checkbox-" <> show ix 322 | , title $ if enabled 323 | then "Enabled" 324 | else "Disabled" 325 | , checked enabled ] 326 | , input $ 327 | [ value domain 328 | , type_ InputText 329 | , HE.onValueInput $ TextInput <<< DomainField ix 330 | ] <> 331 | -- Highlight if invalid 332 | M.guard (Just false == validationResult.websites A.!! ix) 333 | [ class_ (wrap "invalid") 334 | , title "Invalid domain!" ] 335 | , input [ type_ InputCheckbox 336 | , onChecked $ Toggle (DomainWithSubdomains ix) 337 | , id elId 338 | , checked withSubdomains 339 | ] 340 | , label 341 | [ for elId 342 | , title "Whether to include all subdomains of this domain" ] 343 | [ text "Include subdomains" ] 344 | , input [ type_ InputButton 345 | , class_ $ wrap "button" 346 | , onClick $ const $ Click $ RemoveDomain ix 347 | , value "Remove" 348 | , title "Remove this domain from the list" 349 | ] 350 | ] 351 | , input [ type_ InputButton 352 | , class_ $ wrap "button" 353 | , onClick $ const $ Click AddDomain 354 | , value "Add domain" 355 | ] 356 | , br_ 357 | , div_ 358 | [ input [ type_ InputCheckbox 359 | , checked websitesOnlyIfNoAudible 360 | , onChecked $ Toggle WebsitesOnlyIfNoAudible 361 | , id "websitesNoAudible" 362 | ] 363 | , label 364 | [ for "websitesNoAudible" ] 365 | [ text 366 | "Only include domains if there are no \"actually\" audible tabs." 367 | , tooltip "Motivation is that when the sound has stopped, the user may want to jump to the tab where they can click \"play\" again (e.g. a bandcamp tab). But while the sound is playing, there is no reason to cycle through all open tabs from marked websites, because only one of them has sound." 368 | ] 369 | ] 370 | ] 371 | 372 | renderRestoreDefaults :: forall m. PageState -> H.ComponentHTML Action () m 373 | renderRestoreDefaults pageState = div_ case pageState of 374 | Normal -> 375 | [ input [ type_ InputButton 376 | , onClick $ const $ Click RestoreDefaults 377 | , id "button-restore" 378 | , class_ (wrap "button") 379 | , value "Restore defaults" 380 | ] 381 | ] 382 | RestoreConfirmation -> 383 | [ text "Do you really want to reset the settings?" 384 | , input [ type_ InputButton 385 | , onClick $ const $ Click ConfirmRestore 386 | , class_ $ wrap "button" 387 | , value "OK" 388 | ] 389 | , input [ type_ InputButton 390 | , onClick $ const $ Click CancelRestore 391 | , class_ $ wrap "button" 392 | , value "Cancel" 393 | , ref cancelRestoreRef 394 | ] 395 | ] 396 | 397 | renderDonateLink :: forall m a. H.ComponentHTML a () m 398 | renderDonateLink = 399 | div [ id "dev-note" ] 400 | [ span [ class_ $ wrap "dev-note-icon" ] [ text "🍺" ] 401 | , text " This extension is free and " 402 | , a [ href "https://github.com/klntsky/switch-to-audible-tab/", target "_blank" ] 403 | [ text "open-source" ] 404 | , text "." 405 | , br_ 406 | , span [ class_ $ wrap "dev-note-icon" ] [ text "🍩" ] 407 | , text " Consider donating if you like my work: " 408 | , a [ href "https://paypal.me/klntsky", target "_blank" ] 409 | [ text "Paypal" ] 410 | , text ", " 411 | , a [ href "https://liberapay.com/klntsky/donate", target "_blank" ] 412 | [ text "Liberapay" ] 413 | ] 414 | 415 | tooltip :: forall a o m. String -> H.ComponentHTML a o m 416 | tooltip str = span [ class_ (wrap "tooltip") ] 417 | [ text "?" 418 | , span 419 | [ class_ (wrap "tooltiptext") ] 420 | [ text str ] 421 | ] 422 | 423 | handleAction :: forall o. Action -> H.HalogenM State Action () o Aff Unit 424 | handleAction (Click button) = do 425 | case button of 426 | RemoveDomain index -> do 427 | modifySettings $ 428 | _markAsAudible %~ 429 | (\arr -> fromMaybe arr $ A.deleteAt index arr) 430 | AddDomain -> do 431 | modifySettings $ 432 | _markAsAudible %~ 433 | (_ <> pure { domain: "" 434 | , enabled: true 435 | , withSubdomains: false 436 | }) 437 | RestoreDefaults -> do 438 | setPageState RestoreConfirmation 439 | H.getRef cancelRestoreRef >>= \maybeElem -> do 440 | for_ maybeElem $ H.liftEffect <<< FFI.setFocus 441 | ConfirmRestore -> do 442 | modifySettings $ const $ toRuntimeSettings initialSettings 443 | setPageState Normal 444 | CancelRestore -> do 445 | setPageState Normal 446 | saveSettings 447 | handleAction (TextInput input) = do 448 | case input of 449 | DomainField index str -> do 450 | modifySettings $ _markAsAudible <<< ix index <<< _domain .~ str 451 | TimeoutField str -> 452 | modifySettings $ _notificationsTimeout .~ str 453 | DurationField str -> 454 | modifySettings $ _maxNotificationDuration .~ str 455 | saveSettings 456 | handleAction (Toggle checkbox value) = do 457 | modifySettings $ 458 | case checkbox of 459 | IncludeMuted -> (_ { includeMuted = value }) 460 | AllWindows -> (_ { allWindows = value }) 461 | IncludeFirst -> (_ { includeFirst = value }) 462 | SortBackwards -> (_ { sortBackwards = value }) 463 | MenuOnTab -> (_ { menuOnTab = value }) 464 | WebsitesOnlyIfNoAudible -> (_ { websitesOnlyIfNoAudible = value }) 465 | DomainEnabled index -> 466 | _markAsAudible <<< ix index %~ set _enabled value 467 | DomainWithSubdomains index -> 468 | _markAsAudible <<< ix index %~ set _withSubdomains value 469 | FollowNotifications -> 470 | _followNotifications .~ value 471 | NotificationsFirst -> 472 | _notificationsFirst .~ value 473 | saveSettings 474 | handleAction OpenHotkeySettings = do 475 | H.liftEffect FFI.openHotkeySettings 476 | 477 | saveSettings :: forall a i o. H.HalogenM State a i o Aff Unit 478 | saveSettings = do 479 | settings <- H.gets $ view _settings 480 | let validationResult = validate settings :: Either ValidationResult ValidSettings 481 | case validationResult of 482 | Left errors -> do 483 | H.modify_ $ _validationResult .~ errors 484 | Right _ -> do 485 | H.modify_ $ _validationResult .~ goodValidationResult 486 | for_ validationResult \validSettings -> 487 | H.liftAff do 488 | FFI.save validSettings 489 | 490 | validate :: Settings -> Either ValidationResult ValidSettings 491 | validate settings = 492 | let 493 | websites = (settings ^. _markAsAudible) <#> view (_domain <<< to FFI.isValidDomain) 494 | websitesValid = and websites :: Boolean 495 | mbTimeout = do 496 | n <- Int.fromString settings.notificationsTimeout 497 | Alt.guard (n >= 0) 498 | pure n 499 | mbDuration = do 500 | n <- Int.fromString settings.maxNotificationDuration 501 | Alt.guard (n >= 0) 502 | pure n 503 | in 504 | case mbTimeout /\ mbDuration /\ websitesValid of 505 | Just timeout /\ Just duration /\ true -> 506 | Right 507 | { includeMuted: settings.includeMuted 508 | , allWindows: settings.allWindows 509 | , includeFirst: settings.includeFirst 510 | , sortBackwards: settings.sortBackwards 511 | , menuOnTab: settings.menuOnTab 512 | , markAsAudible: settings.markAsAudible 513 | , websitesOnlyIfNoAudible: settings.websitesOnlyIfNoAudible 514 | , followNotifications: settings.followNotifications 515 | , notificationsTimeout: timeout 516 | , maxNotificationDuration: duration 517 | , notificationsFirst: settings.notificationsFirst 518 | } 519 | _ -> 520 | Left 521 | { websites: websites 522 | , isValidTimeout: isJust mbTimeout 523 | , isValidDuration: isJust mbDuration 524 | } 525 | 526 | modifySettings :: forall a i o. (Settings -> Settings) -> H.HalogenM State a i o Aff Unit 527 | modifySettings = H.modify_ <<< over _settings 528 | 529 | setPageState :: forall a i o. PageState -> H.HalogenM State a i o Aff Unit 530 | setPageState = H.modify_ <<< set _pageState 531 | 532 | cancelRestoreRef = wrap "cancel-restore" 533 | 534 | _settings = prop (SProxy :: SProxy "settings") 535 | _pageState = prop (SProxy :: SProxy "pageState") 536 | _withSubdomains = prop (SProxy :: SProxy "withSubdomains") 537 | _domain = prop (SProxy :: SProxy "domain") 538 | _enabled = prop (SProxy :: SProxy "enabled") 539 | _markAsAudible = prop (SProxy :: SProxy "markAsAudible") 540 | _validationResult = prop (SProxy :: SProxy "validationResult") 541 | _notificationsTimeout = prop (SProxy :: SProxy "notificationsTimeout") 542 | _followNotifications = prop (SProxy :: SProxy "followNotifications") 543 | _maxNotificationDuration = prop (SProxy :: SProxy "maxNotificationDuration") 544 | _notificationsFirst = prop (SProxy :: SProxy "notificationsFirst") 545 | --------------------------------------------------------------------------------