├── 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  
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 | 
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 |
--------------------------------------------------------------------------------