├── .eslintrc.json ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── no-response.yml ├── prettifier.yml └── stale.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs ├── _config.yml └── index.html ├── icons ├── sox_access_time.svg ├── sox_checked_box.svg ├── sox_chevron_left.svg ├── sox_chevron_right.svg ├── sox_copy.svg ├── sox_export.svg ├── sox_hot.svg ├── sox_import.svg ├── sox_info.svg ├── sox_key.svg ├── sox_launch.svg ├── sox_search.svg ├── sox_settings.svg ├── sox_toggle_off.svg ├── sox_toggle_on.svg ├── sox_top.svg ├── sox_unchecked_box.svg └── sox_wrench.svg ├── sox.common.info.json ├── sox.common.js ├── sox.css ├── sox.dialog.html ├── sox.dialog.js ├── sox.features.info.json ├── sox.features.js ├── sox.github.js ├── sox.sprites.svg └── sox.user.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jquery": true, 6 | "greasemonkey": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "ecmaVersion": 2018 11 | }, 12 | "rules": { 13 | "indent": [ 14 | "error", 15 | 2 16 | ], 17 | "linebreak-style": [ 18 | "error", 19 | "windows" 20 | ], 21 | "quotes": [ 22 | "error", 23 | "single" 24 | ], 25 | "semi": [ 26 | "error", 27 | "always" 28 | ], 29 | "comma-dangle": ["error", "always-multiline"], 30 | "no-console": "off", 31 | "prefer-arrow-callback":"warn", 32 | "no-trailing-spaces":"error", 33 | "prefer-const": "warn", 34 | "one-var": ["warn", "never"], 35 | "no-multiple-empty-lines": ["warn", { "max": 1 }], 36 | "arrow-parens": ["warn", "as-needed"] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at shubatse@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering to contribute to SOX! 4 | 5 | We would appreciate any and all contributions, partial or complete, and try to respond to all issues/pull requests as soon as we can! 6 | 7 | Contributing doesn't have to be with code! Fixing a simple typo, or helping us document code/update the wiki, is equally as important! :) 8 | 9 | Please see the [Contributing Wiki](https://github.com/soscripted/sox/wiki/Contributing) for full details on how you can help out with SOX. 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to improve SOX 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behaviour: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **SOX errors logged in console** 21 | Please click 'enable debugging' in the SOX settings dialog; hit F12 on a Stack Exchange site; and copy & paste the contents of the 'console' here 22 | 23 | **Expected behaviour** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots/GIFs** 27 | If applicable, please add screenshots/GIFs (you can use something like [LICEcap](https://www.cockos.com/licecap/) to make one!) to help explain the problem. 28 | 29 | **Environment** 30 | If this is not auto-populated, please state your full browser version, SOX version number, and userscript manager (e.g. Tampermonkey) 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for SOX 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the feature you'd like** 11 | A clear and concise description of what you would like to see SOX implement. 12 | 13 | **Use case** 14 | A short example of where this feature could be used 15 | 16 | **Suggested descriptions** 17 | If you have suggestions for a short concise description (to include in the settings dialog), please mention them! 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Bug/Feature** 2 | Is this a bug fix or does it implement a feature? 3 | 4 | **Related issue** 5 | Please link to the issue that this pull request is for 6 | 7 | **Description of changes** 8 | Please describe (briefly!) the changes made 9 | -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | 3 | # Number of days of inactivity before an Issue is closed for lack of response 4 | daysUntilClose: 5 5 | # Label requiring a response 6 | responseRequiredLabel: more-information-needed 7 | # Comment to post when closing an Issue for lack of response. Set to `false` to disable 8 | closeComment: > 9 | This issue has been automatically closed because there has been no response 10 | to our request for more information from the original author. With only the 11 | information that is currently in the issue, we don't have enough information 12 | to take action. Please reach out if you have or find the answers we need so 13 | that we can investigate further. 14 | -------------------------------------------------------------------------------- /.github/prettifier.yml: -------------------------------------------------------------------------------- 1 | commitMessage: "Prettify Code" 2 | 3 | tabWidth: 2 4 | useTabs: false 5 | semi: true 6 | singleQuote: true 7 | trailingComma: "es5" 8 | bracketSpacing: true 9 | arrowParens: "avoid" 10 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 15 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - contributions welcome 8 | - confirmed 9 | - approved 10 | # Label to use when marking an issue as stale 11 | staleLabel: rejected 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: true 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 soscripted 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Join the chat at https://gitter.im/soscripted/sox](https://badges.gitter.im/soscripted/sox.svg)](https://gitter.im/soscripted/sox?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 2 | 3 | ### SOX v2.8.0 4 | 5 | Stack Overflow Extras (*SOX*) is a project that stemmed from the [Stack Overflow Optional Features (SOOF)](https://github.com/shu8/Stack-Overflow-Optional-Features) project. 6 | 7 | The SOX userscript adds a bunch of **optional** features to all sites in the Stack Exchange network. These can be toggled on or off from an easy to use control panel (see screenshot below). 8 | 9 | Note: This project has no relation to Stack Overflow or Stack Exchange; it is simply a userscript that enhances the sites! 10 | 11 | ## Installation & Requirements 12 | 13 | 1. Install a userscript manager; these are free extensions available for all popular browsers that allow you to manage and install userscripts, along with exposing certain code functions that SOX requires. 14 | 15 | We recommend [Tampermonkey](http://tampermonkey.net/) for Chrome and Firefox. 16 | 17 | Whilst SOX only explicitly supports Chrome and Firefox, it should work on any popular browser that can run userscripts. 18 | 19 | **Note: Greasemonkey 4 and upwards [is not supported with SOX](https://github.com/soscripted/sox/issues/306).** 20 | 21 | **There seems to be [an issue with Tampermonkey on Firefox](https://github.com/Tampermonkey/tampermonkey/issues/477) where userscripts don't seem to run. If this happens, please restart your browser and/or computer before raising an issue on GitHub, as a restart seems to fix this!** 22 | 23 | 2. Install the script. Clicking on 'install' below will make Tampermonkey prompt you automatically to install it. 24 | 25 | - Official Version: [install](https://github.com/soscripted/sox/raw/v2.8.0/sox.user.js). [view source](https://github.com/soscripted/sox/blob/v2.8.0/sox.user.js) 26 | - Development Version: [install](https://github.com/soscripted/sox/raw/dev/sox.user.js). [view source](https://github.com/soscripted/sox/blob/dev/sox.user.js) 27 | 28 | 3. Go to any site in the Stack Exchange Network (e.g. [Super User](http://superuser.com/) or [Stack Overflow](http://stackoverflow.com/)) (reload if already opened) and confirm the dialog asking you to get an access token. This will open a new page where you can authorize SOX, and can be closed once your access token is saved. A toggle button (gears icon) will be added to your topbar. Click and review and save the settings 29 | 30 | ![newdialog](https://i.stack.imgur.com/q93pM.jpg) 31 | 32 | ## What features are included? 33 | 34 | A full list of all the features is available on the SOX wiki page [here](https://github.com/soscripted/sox/wiki/Features). 35 | 36 | ## Bugs and Feature Requests 37 | 38 | Please post bugs and feature requests as issues on [Github](https://github.com/soscripted/sox), where we can track them easily and push updates quickly. Please **do not** post them as answers on Stack Apps -- they are much harder to manage! 39 | 40 | ## Contribute 41 | 42 | Pull requests to add new features or improve the existing ones, etc. are welcome! Please head to the [Contributing](https://github.com/soscripted/sox/wiki/Contributing) wiki page to get started. 43 | 44 | ## Changes 45 | 46 | Please see the change log [at Stack Apps](http://stackapps.com/a/6358). 47 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SOX by soscripted 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 |
18 |
19 |

SOX

20 |

A userscript for the Stack Exchange websites to add a bunch of optional user-selectable features

21 |

View the Project on GitHub

22 |
23 |
24 |

25 | Join the chat at https://gitter.im/soscripted/sox 26 |

27 | 28 |

SOX

29 | 30 | 44 | 45 |

Stack Overflow Extras (SOX) is a project that stemmed from the Stack Overflow Optional Features (SOOF) project.

46 | 47 |

The SOX userscript adds a bunch of optional features to all sites in the Stack Exchange network. These can be toggled on or off from an easy to use control panel (see screenshot below).

48 | 49 |

Note: This project is not related to Stack Overflow or Stack Exchange; it is simply a userscript that enhances the sites!

50 | 51 |

Installation & Requirements

52 | 53 |
    54 |
  1. Install Greasemonkey (for Firefox), Tampermonkey (for Chrome), or NinjaKit for Safari. These are userscript 55 | managers that must be installed in order for this to work, as the script relies on certain GM_* functions in order to save your settings!
  2. 56 |
  3. 57 |

    Install the script. Clicking on the 'install' button below will make your userscript manager prompt you automatically to install it.

    58 | 59 | 63 |
  4. 64 |
  5. Go to any site in the Stack Exchange Network (e.g. Super User or Stack Overflow). You will automatically be asked to choose and save your settings. A toggle button 65 | (gears icon) will be added to your topbar where you can change these later on:
  6. 66 |
67 | 68 |

newdialog

69 | 70 |

What features are included?

71 | 72 |

A full list of all the features is available on the SOX wiki page here.

73 | 74 |

Bugs and Feature Requests

75 | 76 |

Please post bugs and feature requests as issues on Github, where we can track them easily and push updates quickly. Please do not post them as answers on Stack Apps – they are 77 | much harder to manage!

78 | 79 |

Changes

80 | 81 |

Please see the change log at Stack Apps.

82 |
83 | 87 |
88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /icons/sox_access_time.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_checked_box.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_chevron_left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_chevron_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_export.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_hot.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_import.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_key.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_launch.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_search.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/sox_settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_toggle_off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_toggle_on.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_top.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_unchecked_box.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/sox_wrench.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sox.common.info.json: -------------------------------------------------------------------------------- 1 | { 2 | "privileges": { 3 | "beta": { 4 | "access review queues": 350, 5 | "access to moderator tools": 2000, 6 | "approve tag wiki edits": 1500, 7 | "cast close and reopen votes": 500, 8 | "comment everywhere": 50, 9 | "create chat rooms": 100, 10 | "create gallery chat rooms": 1000, 11 | "create posts": 1, 12 | "create tag synonyms": 1250, 13 | "create tags": 150, 14 | "create wiki posts": 10, 15 | "edit community wiki": 100, 16 | "edit questions and answers": 1000, 17 | "established user": 750, 18 | "flag posts": 15, 19 | "participate in meta": 5, 20 | "protect questions": 3500, 21 | "remove new user restrictions": 10, 22 | "set bounties": 75, 23 | "talk in chat": 20, 24 | "trusted user": 4000, 25 | "view close votes": 250, 26 | "vote down": 125, 27 | "vote up": 15 28 | }, 29 | "graduated": { 30 | "access review queues": 2000, 31 | "access to moderator tools": 10000, 32 | "approve tag wiki edits": 5000, 33 | "cast close and reopen votes": 3000, 34 | "comment everywhere": 5, 35 | "create chat rooms": 100, 36 | "create gallery chat rooms": 1000, 37 | "create posts": 1, 38 | "create tag synonyms": 2500, 39 | "create tags": 500, 40 | "create wiki posts": 10, 41 | "edit community wiki": 100, 42 | "edit questions and answers": 2000, 43 | "established user": 1000, 44 | "flag posts": 15, 45 | "participate in meta": 5, 46 | "protect questions": 15000, 47 | "reduce ads": 200, 48 | "remove new user restrictions": 10, 49 | "set bounties": 75, 50 | "talk in chat": 20, 51 | "trusted user": 20000, 52 | "view close votes": 250, 53 | "vote down": 125, 54 | "vote up": 15 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /sox.common.js: -------------------------------------------------------------------------------- 1 | /* globals CHAT, StackExchange, jQuery */ 2 | (function(sox, $) { 3 | 'use strict'; 4 | const SOX_SETTINGS = 'SOXSETTINGS'; 5 | const commonInfo = JSON.parse(GM_getResourceText('common')); 6 | const lastVersionInstalled = GM_getValue('SOX-lastVersionInstalled'); 7 | var hookAjaxObject = {}; 8 | 9 | sox.info = { 10 | version: (typeof GM_info !== 'undefined' ? GM_info.script.version : 'unknown'), 11 | handler: (typeof GM_info !== 'undefined' ? GM_info.scriptHandler : 'unknown'), 12 | apikey: 'lL1S1jr2m*DRwOvXMPp26g((', 13 | debugging: GM_getValue('SOX-debug', false), 14 | lastVersionInstalled: lastVersionInstalled, 15 | }; 16 | 17 | sox.NEW_TOPBAR = location.href.indexOf('area51') === -1; 18 | 19 | sox.debug = function() { 20 | if (!sox.info.debugging) return; 21 | for (let arg = 0; arg < arguments.length; ++arg) { 22 | console.debug('SOX:', arguments[arg]); 23 | } 24 | }; 25 | 26 | sox.log = function() { 27 | for (let arg = 0; arg < arguments.length; ++arg) { 28 | console.log('SOX:', arguments[arg]); 29 | } 30 | }; 31 | 32 | sox.warn = function() { 33 | for (let arg = 0; arg < arguments.length; ++arg) { 34 | console.warn('SOX:', arguments[arg]); 35 | } 36 | }; 37 | 38 | sox.error = function() { 39 | for (let arg = 0; arg < arguments.length; ++arg) { 40 | console.error('SOX:', arguments[arg]); 41 | } 42 | }; 43 | 44 | sox.loginfo = function() { 45 | for (let arg = 0; arg < arguments.length; ++arg) { 46 | console.info('SOX:', arguments[arg]); 47 | } 48 | }; 49 | 50 | let Chat; 51 | let Stack; 52 | if (location.href.indexOf('github.com') === -1) { //need this so it works on FF -- CSP blocks window.eval() it seems 53 | Chat = (typeof window.CHAT === 'undefined' ? window.eval('typeof CHAT != \'undefined\' ? CHAT : undefined') : CHAT); 54 | Stack = (typeof Chat === 'undefined' 55 | ? (typeof StackExchange === 'undefined' 56 | ? window.eval('if (typeof StackExchange != "undefined") StackExchange') 57 | : (StackExchange || window.StackExchange)) 58 | : undefined); 59 | } 60 | 61 | sox.Stack = Stack; 62 | 63 | sox.exists = function(path) { 64 | if (!Stack) return false; 65 | const toCheck = path.split('.'); 66 | 67 | let cont = true; 68 | let o = Stack; 69 | let i; 70 | 71 | for (i = 0; i < toCheck.length; i++) { 72 | if (!cont) return false; 73 | if (!(toCheck[i] in o)) cont = false; 74 | o = o[toCheck[i]]; 75 | } 76 | return cont; 77 | }; 78 | 79 | sox.ready = function(func) { 80 | $(() => { 81 | if (Stack) { 82 | if (Stack.ready) { 83 | Stack.ready(func()); 84 | } else { 85 | func(); 86 | } 87 | } else { 88 | func(); 89 | } 90 | }); 91 | }; 92 | 93 | sox.settings = { 94 | available: GM_getValue(SOX_SETTINGS, -1) != -1, 95 | load: function() { 96 | const settings = GM_getValue(SOX_SETTINGS); 97 | return settings === undefined ? undefined : JSON.parse(settings); 98 | }, 99 | save: function(settings) { 100 | // If importing, it will already be a string so there's no need to stringify it 101 | GM_setValue(SOX_SETTINGS, typeof settings === 'string' ? settings : JSON.stringify(settings)); 102 | }, 103 | reset: function() { 104 | const keys = GM_listValues(); 105 | sox.debug(keys); 106 | keys.forEach(key => GM_deleteValue(key)); 107 | }, 108 | get accessToken() { 109 | const accessToken = GM_getValue('SOX-accessToken', false); 110 | return (accessToken == -2 ? false : accessToken); //if the user was already asked once, the value is set to -2, so make sure this is returned as false 111 | }, 112 | writeToConsole: function(hideAccessToken) { 113 | sox.loginfo('logging sox stored values --- '); 114 | const keys = GM_listValues(); 115 | for (let i = 0; i < keys.length; i++) { 116 | const key = keys[i]; 117 | if (hideAccessToken && key == 'SOX-accessToken') { 118 | sox.loginfo('access token set'); 119 | } else { 120 | sox.loginfo(key, GM_getValue(key)); 121 | } 122 | } 123 | }, 124 | }; 125 | 126 | function throttle(fn, countMax, time) { 127 | let counter = 0; 128 | 129 | setInterval(() => { 130 | counter = 0; 131 | }, time); 132 | 133 | return function() { 134 | if (counter < countMax) { 135 | counter++; 136 | fn.apply(this, arguments); 137 | } 138 | }; 139 | } 140 | 141 | sox.sprites = { 142 | getSvg: function (name, tooltip, css) { 143 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 144 | const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); 145 | const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); 146 | 147 | if (tooltip) { 148 | svg.setAttribute('title', tooltip); 149 | title.textContent = tooltip; 150 | svg.appendChild(title); 151 | } 152 | 153 | use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `#sox_${name}`); 154 | svg.appendChild(use); 155 | 156 | svg.classList.add('sox-sprite'); 157 | svg.classList.add(`sox-sprite-${name}`); 158 | return svg; 159 | }, 160 | }; 161 | 162 | sox.helpers = { 163 | getFromAPI: function (details, callback) { 164 | let { 165 | ids, 166 | useCache = true, 167 | } = details; 168 | 169 | const { 170 | endpoint, 171 | childEndpoint, 172 | sort = 'creation', 173 | order = 'desc', 174 | sitename, 175 | filter, 176 | limit, 177 | page, 178 | featureId, 179 | cacheDuration = 3, // Minutes to cache data for 180 | } = details; 181 | const baseURL = 'https://api.stackexchange.com/2.2/'; 182 | const queryParams = []; 183 | 184 | // Cache can only be used if the featureId and IDs (as an array) have been provided 185 | useCache = featureId && useCache && Array.isArray(ids); 186 | const apiCache = JSON.parse(GM_getValue('SOX-apiCache', '{}')); 187 | 188 | if (!(featureId in apiCache)) apiCache[featureId] = {}; 189 | const featureCache = apiCache[featureId]; 190 | 191 | if (!(endpoint in featureCache)) featureCache[endpoint] = []; 192 | const endpointCache = featureCache[endpoint]; 193 | 194 | const endpointToIdFieldNames = { 195 | 'questions': 'question_id', 196 | 'answers': 'answer_id', 197 | 'users': 'user_id', 198 | 'comments': 'comment_id', 199 | }; 200 | 201 | if (filter) queryParams.push(`filter=${filter}`); 202 | if (order) queryParams.push(`order=${order}`); 203 | if (limit) queryParams.push(`pagesize=${limit}`); 204 | if (page) queryParams.push(`page=${page}`); 205 | queryParams.push(`sort=${sort}`); 206 | queryParams.push(`site=${sitename}`); 207 | queryParams.push(`key=${sox.info.apikey}`); 208 | queryParams.push(`access_token=${sox.settings.accessToken}`); 209 | const queryString = queryParams.join('&'); 210 | 211 | let finalItems = []; 212 | if (useCache) { 213 | // Count backwards so splicing doesn't change indices 214 | for (let i = ids.length; i >= 0; i--) { 215 | const cachedItemIndex = endpointCache.findIndex(item => { 216 | const idFieldName = endpointToIdFieldNames[endpoint]; 217 | return item[idFieldName] === +ids[i]; 218 | }); 219 | 220 | // Cache results for max. cacheDuraction minutes (convert to milliseconds) 221 | const earliestRequestTime = new Date().getTime() - (60 * cacheDuration * 1000); 222 | if (cachedItemIndex !== -1) { 223 | const cachedItem = endpointCache[cachedItemIndex]; 224 | if (cachedItem.sox_request_time >= earliestRequestTime) { 225 | // If we have a cached item for this ID, delete it from `ids` so we don't request the API for it 226 | sox.debug(`API: [${featureId}:/${endpoint}/${ids[i]}] Using cached API item`); 227 | finalItems.push(cachedItem); 228 | ids.splice(i, 1); 229 | } else { 230 | // The cached item is now stale (too old); delete it 231 | sox.debug(`API: [${featureId}:/${endpoint}/${ids[i]}] Deleting stale cached item`); 232 | endpointCache.splice(cachedItemIndex, 1); 233 | } 234 | } 235 | } 236 | } 237 | 238 | // IDs are optional for endpoints like /questions 239 | if (ids && Array.isArray(ids)) { 240 | if (ids.length) { 241 | ids = ids.join(';'); 242 | } else if (useCache) { 243 | // The cache had details for all IDs; no need to request API at all 244 | sox.debug(`API: [${featureId}:/${endpoint}] API Cache had details for all requested IDs, skipping API request`); 245 | GM_setValue('SOX-apiCache', JSON.stringify(apiCache)); 246 | sox.debug('API: Saving new cache', apiCache); 247 | callback(finalItems); 248 | return; 249 | } 250 | } 251 | 252 | const idPath = ids ? `/${ids}` : ''; 253 | let queryURL; 254 | if (childEndpoint) { 255 | // e.g. /posts/{ids}/revisions 256 | queryURL = `${baseURL}${endpoint}${idPath}/${childEndpoint}?${queryString}`; 257 | } else { 258 | // e.g. /questions/{ids} 259 | queryURL = `${baseURL}${endpoint}${idPath}?${queryString}`; 260 | } 261 | sox.debug(`API: Sending request to URL: '${queryURL}'`); 262 | 263 | fetch(queryURL).then(apiResponse => apiResponse.json()).then(responseJson => { 264 | if (responseJson.backoff) { 265 | sox.error('SOX Error: BACKOFF: ' + responseJson.backoff); 266 | } else if (responseJson.error_id == 502) { 267 | sox.error('THROTTLE VIOLATION', responseJson); 268 | } else if (responseJson.error_id == 403) { 269 | sox.warn('Access token invalid! Opening window to get new one'); 270 | window.open('https://stackexchange.com/oauth/dialog?client_id=7138&scope=no_expiry&redirect_uri=http://soscripted.github.io/sox/'); 271 | alert('Your access token is no longer valid. A window has been opened to request a new one.'); 272 | } else { 273 | if (useCache) { 274 | responseJson.items.forEach(item => { 275 | item.sox_request_time = new Date().getTime(); 276 | finalItems.push(item); 277 | endpointCache.push(item); 278 | }); 279 | GM_setValue('SOX-apiCache', JSON.stringify(apiCache)); 280 | sox.debug('API: saving new cache', apiCache); 281 | } else { 282 | finalItems = responseJson.items; 283 | } 284 | callback(finalItems); 285 | } 286 | }); 287 | }, 288 | observe: function (targets, elements, callback) { 289 | sox.debug(`OBSERVE: '${elements}' on target(s)`, targets); 290 | if (!targets || (Array.isArray(targets) && !targets.length)) return; 291 | 292 | const observer = new MutationObserver(throttle(mutations => { 293 | for (let i = 0; i < mutations.length; i++) { 294 | const mutation = mutations[i]; 295 | const target = mutation.target; 296 | const addedNodes = mutation.addedNodes; 297 | 298 | if (addedNodes) { 299 | for (let n = 0; n < addedNodes.length; n++) { 300 | if ($(addedNodes[n]).find(elements).length) { 301 | sox.debug('fire: node: ', addedNodes[n]); 302 | callback(target); 303 | return; 304 | } 305 | } 306 | } 307 | 308 | if ($(target).is(elements)) { //TODO: maybe add OR to find subelements for childList events? 309 | callback(target); 310 | sox.debug('fire: target: ', target); 311 | return; 312 | } 313 | } 314 | }, 1500)); 315 | 316 | if (Array.isArray(targets)) { 317 | for (let i = 0; i < targets.length; i++) { 318 | const target = targets[i]; 319 | if (!target) continue; 320 | 321 | observer.observe(target, { 322 | attributes: true, 323 | childList: true, 324 | characterData: true, 325 | subtree: true, 326 | }); 327 | } 328 | } else { 329 | observer.observe(targets, { 330 | attributes: true, 331 | childList: true, 332 | characterData: true, 333 | subtree: true, 334 | }); 335 | } 336 | }, 337 | newElement: function(type, elementDetails) { 338 | const extras = {}; 339 | const allowed = ['text', 'checkbox', 'radio', 'textarea', 'span', 'div', 'a']; 340 | 341 | if (allowed.indexOf(type) != -1) { 342 | if (type == 'text') { 343 | type = 'input'; 344 | extras.type = 'input'; 345 | } else if (type == 'checkbox') { 346 | type = 'input'; 347 | extras.type = 'checkbox'; 348 | } else if (type == 'radio') { 349 | type = 'input'; 350 | extras.type = 'radio'; 351 | } else if (type == 'textarea') { 352 | if (!elementDetails.text) { 353 | elementDetails.text = elementDetails.value; 354 | } 355 | } 356 | 357 | $.each(elementDetails, (k, v) => { 358 | extras[k] = v; 359 | }); 360 | return $('<' + type + '/>', extras); 361 | } else { 362 | return false; 363 | } 364 | }, 365 | getIDFromAnchor: function(anchor) { 366 | return anchor.href ? sox.helpers.getIDFromLink(anchor.href) : null; 367 | }, 368 | getSiteNameFromAnchor: function(anchor) { 369 | return anchor.href ? sox.helpers.getSiteNameFromLink(anchor.href) : null; 370 | }, 371 | // answer ID, question ID, user ID, comment ID ("posts/comments/ID" NOT "comment1545_5566") 372 | getIDFromLink: function(link) { 373 | // test cases: https://regex101.com/r/6P9sDX/2 374 | const idMatch = link.match(/\/(\d+)/); 375 | return idMatch ? +idMatch[1] : null; 376 | }, 377 | getSiteNameFromLink: function(link) { 378 | const siteRegex = /(((.+)\.)?(((stackexchange|stackoverflow|superuser|serverfault|askubuntu|stackapps))(?=\.com))|mathoverflow\.net)/; 379 | const siteMatch = link.replace(/https?:\/\//, '').match(siteRegex); 380 | return siteMatch ? siteMatch[1] : null; 381 | }, 382 | createModal: function (params) { 383 | const closeButtonSvg = ``; 386 | 387 | const dialog = document.createElement('aside'); 388 | dialog.className = 's-modal js-modal-overlay js-modal-close js-stacks-managed-popup js-fades-with-aria-hidden sox-custom-dialog'; 389 | dialog.role = 'dialog'; 390 | dialog.ariaHidden = false; 391 | if (params.id) dialog.id = params.id; 392 | 393 | const dialogInnerContainer = document.createElement('div'); 394 | dialogInnerContainer.className = 's-modal--dialog js-modal-dialog'; 395 | dialogInnerContainer.style.minWidth = '568px'; // top: 227.736px; left: 312.653px; 396 | 397 | // if (params.css) $dialog.css(params.css) 398 | // if (params.css) $dialogInnerContainer.css(params.css); 399 | 400 | const header = document.createElement('h1'); 401 | header.className = 's-modal--header fs-headline1 fw-bold mr48 js-first-tabbable sox-custom-dialog-header'; 402 | header.innerHTML = params.header; 403 | const mainContent = document.createElement('div'); 404 | mainContent.className = 's-modal--body sox-custom-dialog-content'; 405 | if (params.html) mainContent.innerHTML = params.html; 406 | 407 | const closeButton = document.createElement('button'); 408 | closeButton.className = 's-modal--close s-btn s-btn__muted js-modal-close js-last-tabbable'; 409 | closeButton.onclick = () => document.querySelector('.sox-custom-dialog').remove(); 410 | closeButton.insertAdjacentHTML('beforeend', closeButtonSvg); 411 | 412 | dialogInnerContainer.appendChild(header); 413 | dialogInnerContainer.appendChild(mainContent); 414 | dialogInnerContainer.appendChild(closeButton); 415 | dialog.appendChild(dialogInnerContainer); 416 | 417 | return dialog; 418 | }, 419 | addButtonToHelpMenu: function (params) { 420 | const liElement = document.createElement('li'); 421 | const anchor = document.createElement('a'); 422 | anchor.href = 'javascript:void(0)'; 423 | anchor.id = params.id; 424 | anchor.innerText = `SOX: ${params.linkText}`; 425 | const span = document.createElement('span'); 426 | span.className = 'item-summary'; 427 | span.innerText = params.summary; 428 | 429 | liElement.addEventListener('click', params.click); 430 | anchor.appendChild(span); 431 | liElement.appendChild(anchor); 432 | document.querySelector('.topbar-dialog.help-dialog.js-help-dialog .modal-content ul').appendChild(liElement); 433 | }, 434 | surroundSelectedText: function(textarea, start, end) { 435 | // same wrapper code on either side (`$...$`) 436 | if (typeof end === 'undefined') end = start; 437 | 438 | /*--- Expected behavior: 439 | When there is some text selected: (unwrap it if already wrapped) 440 | "]text[" --> "**]text[**" 441 | "**]text[**" --> "]text[" 442 | "]**text**[" --> "**]**text**[**" 443 | "**]**text**[**" --> "]**text**[" 444 | When there is no text selected: 445 | "][" --> "**placeholder text**" 446 | "**][**" --> "" 447 | Note that `]` and `[` denote the selected text here. 448 | */ 449 | 450 | const selS = textarea.selectionStart < textarea.selectionEnd ? textarea.selectionStart : textarea.selectionEnd; 451 | const selE = textarea.selectionStart > textarea.selectionEnd ? textarea.selectionStart : textarea.selectionEnd; 452 | const value = textarea.value; 453 | const startLen = start.length; 454 | const endLen = end.length; 455 | 456 | let valBefore = value.substring(0, selS); 457 | let valMid = value.substring(selS, selE); 458 | let valAfter = value.substring(selE); 459 | let generatedWrapper; 460 | 461 | // handle trailing spaces 462 | const trimmedSelection = valMid.match(/^(\s*)(\S?(?:.|\n|\r)*\S)(\s*)$/) || ['', '', '', '']; 463 | 464 | // determine if text is currently wrapped 465 | if (valBefore.endsWith(start) && valAfter.startsWith(end)) { 466 | textarea.value = valBefore.substring(0, valBefore.length - startLen) + valMid + valAfter.substring(endLen); 467 | textarea.selectionStart = valBefore.length - startLen; 468 | textarea.selectionEnd = (valBefore + valMid).length - startLen; 469 | textarea.focus(); 470 | } else { 471 | valBefore += trimmedSelection[1]; 472 | valAfter = trimmedSelection[3] + valAfter; 473 | valMid = trimmedSelection[2]; 474 | 475 | generatedWrapper = start + valMid + end; 476 | 477 | textarea.value = valBefore + generatedWrapper + valAfter; 478 | textarea.selectionStart = valBefore.length + start.length; 479 | textarea.selectionEnd = (valBefore + generatedWrapper).length - end.length; 480 | textarea.focus(); 481 | } 482 | 483 | sox.Stack.MarkdownEditor.refreshAllPreviews(); 484 | }, 485 | getCssProperty: function(element, propertyValue) { 486 | return window.getComputedStyle(element).getPropertyValue(propertyValue); 487 | }, 488 | runAjaxHooks: function() { 489 | let originalOpen = XMLHttpRequest.prototype.open; 490 | XMLHttpRequest.prototype.open = function() { 491 | this.addEventListener('load', function() { 492 | for (const key in hookAjaxObject) { 493 | if (this.responseURL.match(new RegExp(key))) hookAjaxObject[key](); // if the URL matches the regex, then execute the respective function 494 | } 495 | }); 496 | originalOpen.apply(this, arguments); 497 | } 498 | }, 499 | addAjaxListener: function(regexToMatch, functionToExecute) { 500 | if (!regexToMatch) { // all information has been inserted in hookAjaxObject 501 | sox.helpers.runAjaxHooks(); 502 | return; 503 | } 504 | hookAjaxObject[regexToMatch] = functionToExecute; 505 | }, 506 | }; 507 | 508 | sox.site = { 509 | types: { 510 | main: 'main', 511 | meta: 'meta', 512 | chat: 'chat', 513 | beta: 'beta', 514 | }, 515 | id: (sox.exists('options.site.id') ? Stack.options.site.id : undefined), 516 | currentApiParameter: sox.helpers.getSiteNameFromLink(location.href), 517 | get name() { 518 | if (Chat) { 519 | return document.querySelector('#footer-logo a').title; 520 | } else { //using StackExchange object doesn't give correct name (eg. `Biology` is called `Biology Stack Exchange` in the object) 521 | return document.querySelector('.js-topbar-dialog-corral .modal-content.current-site-container .current-site-link div').title; 522 | } 523 | }, 524 | 525 | get type() { 526 | if (Chat) { 527 | return this.types.chat; 528 | } else if (Stack) { 529 | if (sox.exists('options.site') && Stack.options.site.isMetaSite) { 530 | return this.types.meta; 531 | } else { 532 | // check if site is in beta or graduated 533 | if (document.querySelector('.beta-title')) { 534 | return this.types.beta; 535 | } else { 536 | return this.types.main; 537 | } 538 | } 539 | } 540 | return null; 541 | }, 542 | get icon() { 543 | return 'favicon-' + document.querySelector('.current-site a:not([href*=\'meta\']) .site-icon').className.split('favicon-')[1]; 544 | }, 545 | url: location.hostname, // e.g. "meta.stackexchange.com" 546 | href: location.href, // e.g. "https://meta.stackexchange.com/questions/blah/blah" 547 | }; 548 | 549 | sox.location = { 550 | // location helpers 551 | on: function(location) { 552 | return window.location.href.indexOf(location) > -1 ? true : false; 553 | }, 554 | get onUserProfile() { 555 | return this.on('/users/'); 556 | }, 557 | get onQuestion() { 558 | return this.on('/questions/'); 559 | }, 560 | matchWithPattern: function(pattern, urlToMatchWith) { //commented version @ https://jsfiddle.net/shub01/t90kx2dv/ 561 | if (pattern == 'SE1.0') { //SE.com && Area51.SE.com special checking 562 | if (urlToMatchWith) { 563 | if (urlToMatchWith.match(/https?:\/\/stackexchange\.com\/?/) 564 | || (sox.location.matchWithPattern('*://area51.stackexchange.com/*') && sox.site.href.indexOf('.meta.') === -1)) return true; 565 | } else { 566 | if (location.href.match(/https?:\/\/stackexchange\.com\/?/) || 567 | (sox.location.matchWithPattern('*://area51.stackexchange.com/*') && sox.site.href.indexOf('.meta.') === -1)) return true; 568 | } 569 | return false; 570 | } 571 | let currentSiteScheme; let currentSiteHost; let currentSitePath; 572 | if (urlToMatchWith) { 573 | const split = urlToMatchWith.split('/'); 574 | currentSiteScheme = split[0]; 575 | currentSiteHost = split[2]; 576 | currentSitePath = '/' + split.slice(-(split.length - 3)).join('/'); 577 | } else { 578 | currentSiteScheme = location.protocol; 579 | currentSiteHost = location.hostname; 580 | currentSitePath = location.pathname; 581 | } 582 | 583 | const matchSplit = pattern.split('/'); 584 | let matchScheme = matchSplit[0]; 585 | let matchHost = matchSplit[2]; 586 | let matchPath = matchSplit.slice(-(matchSplit.length - 3)).join('/'); 587 | 588 | matchScheme = matchScheme.replace(/\*/g, '.*'); 589 | matchHost = matchHost.replace(/\./g, '\\.').replace(/\*\\\./g, '.*.?').replace(/\\\.\*/g, '.*').replace(/\*$/g, '.*'); 590 | matchPath = '^/' + matchPath.replace(/\//g, '\\/').replace(/\*/g, '.*'); 591 | 592 | if (currentSiteScheme.match(new RegExp(matchScheme)) && currentSiteHost.match(new RegExp(matchHost)) && currentSitePath.match(new RegExp(matchPath))) { 593 | return true; 594 | } 595 | return false; 596 | }, 597 | }; 598 | 599 | sox.user = { 600 | get id() { 601 | if (sox.site.type == sox.site.types.chat) { 602 | return Chat ? Chat.RoomUsers.current().id : undefined; 603 | } else { 604 | return sox.exists('options.user.userId') ? Stack.options.user.userId : undefined; 605 | } 606 | }, 607 | get rep() { 608 | if (sox.site.type == sox.site.types.chat) { 609 | return Chat.RoomUsers.current().reputation; 610 | } else { 611 | return sox.exists('options.user.rep') ? Stack.options.user.rep : undefined; 612 | } 613 | }, 614 | get name() { 615 | if (sox.site.type == sox.site.types.chat) { 616 | return Chat.RoomUsers.current().name; 617 | } else { 618 | const username = document.querySelector('.s-topbar--item.s-user-card .s-avatar'); 619 | return (username ? username.title : ''); 620 | } 621 | }, 622 | get loggedIn() { 623 | return sox.exists('options.user.isRegistered') ? Stack.options.user.isRegistered : undefined; 624 | }, 625 | hasPrivilege: function(privilege) { 626 | if (this.loggedIn) { 627 | const rep = (sox.site.type == 'beta' ? commonInfo.privileges.beta[privilege] : commonInfo.privileges.graduated[privilege]); 628 | return this.rep > rep; 629 | } 630 | return false; 631 | }, 632 | }; 633 | 634 | })(window.sox = window.sox || {}, jQuery); 635 | -------------------------------------------------------------------------------- /sox.css: -------------------------------------------------------------------------------- 1 | /* sprites */ 2 | .sox-sprite { 3 | width: 15px; 4 | height: 15px; 5 | } 6 | 7 | /* -------- sox settings dialog ------- */ 8 | 9 | #sox-settings-dialog { 10 | top: 47px; 11 | right: 208px; 12 | min-height: calc(100vh - 90px); 13 | width: 550px; 14 | display: none; 15 | } 16 | 17 | #sox-settings-dialog.new-topbar { 18 | min-height: calc(100vh - 75px); 19 | } 20 | 21 | #sox-settings-dialog .modal-content { 22 | max-height: none; 23 | min-height: inherit; 24 | } 25 | 26 | #sox-settings-dialog .header h3 { 27 | width: 100%; 28 | } 29 | 30 | #sox-settings-dialog-version { 31 | float: right; 32 | font-size: 14px; 33 | } 34 | 35 | #sox-settings-dialog #search-container { 36 | min-height: 0; 37 | width: 570px; 38 | left: 10px; 39 | } 40 | 41 | #sox-settings-dialog #search-container .sox-sprite-search { 42 | width: 20px; 43 | height: 20px; 44 | top: 5px; 45 | } 46 | 47 | #sox-settings-dialog:not(.new-topbar) #search-container .sox-sprite-search { 48 | position: relative; 49 | left: -485px; 50 | } 51 | 52 | #sox-settings-dialog.new-topbar #search-container .sox-sprite-search { 53 | position: relative; 54 | left: -485px; 55 | } 56 | 57 | #sox-settings-dialog #search-container #search { 58 | max-width: 90%; 59 | } 60 | 61 | #sox-settings-dialog-features { 62 | padding: 0 10px 10px 10px; 63 | overflow-y: scroll; 64 | height: calc(100vh - 250px); 65 | } 66 | 67 | #sox-settings-dialog-features .modal-content div { 68 | margin-bottom: 3px; 69 | padding: 2px; 70 | } 71 | 72 | #sox-settings-dialog-features .modal-content div:hover { 73 | background-color: var(--black-075); 74 | } 75 | 76 | #sox-settings-dialog-features .modal-content label>input { 77 | margin-right: 5px; 78 | } 79 | 80 | #sox-settings-dialog-actions { 81 | height: 40px; 82 | padding: 0 10px; 83 | } 84 | 85 | #sox-settings-dialog-save { 86 | margin-left: 5px; 87 | float: right; 88 | } 89 | 90 | #sox-settings-dialog-actions .action { 91 | float: right; 92 | padding: 13px 11px; 93 | } 94 | 95 | #sox-settings-dialog .sox-new-version-item { 96 | padding: 5px; 97 | } 98 | 99 | .sox-settings-button { 100 | background-image: none !important; 101 | font-size: 14px; 102 | cursor: pointer; 103 | text-align: center; 104 | } 105 | 106 | .sox-settings-button:hover, .sox-settings-button.is-selected { 107 | color: #9fa6ad; 108 | background-color: var(--theme-topbar-item-background-hover); 109 | } 110 | 111 | #sox-settings-dialog-check-toggle>i { 112 | padding: 14px 0; 113 | } 114 | 115 | #sox-settings-dialog-features textarea.featureSetting { 116 | min-width: 80%; 117 | } 118 | 119 | .network-items .fa:before { 120 | /*https://github.com/soscripted/sox/pull/127*/ 121 | color: #9fa6ad; 122 | } 123 | 124 | .sox-feature-info, 125 | .sox-feature-settings { 126 | background-color: var(--black-025); 127 | border-radius: 9px; 128 | margin-left: 20px; 129 | padding: 8px; 130 | width: 90%; 131 | line-height: 1.5; 132 | padding: 5px !important; 133 | } 134 | 135 | .sox-feature .sox-sprite-info, 136 | .sox-feature .sox-sprite-wrench { 137 | font-size: 12px; 138 | cursor: pointer; 139 | } 140 | 141 | .sox-feature .sox-sprite-wrench { 142 | margin-left: 5px; 143 | transform: rotate(90deg); 144 | } 145 | 146 | .sox-feature .sox-sprite-info { 147 | float: right; 148 | width: 17px; 149 | height: 17px; 150 | fill: gray; 151 | } 152 | 153 | #sox-settings-dialog-features-packs { 154 | margin-bottom: 5px; 155 | } 156 | 157 | .sox-settings-dialog-feature-packs-list { 158 | padding: 10px; 159 | margin: 5px; 160 | } 161 | 162 | .sox-settings-dialog-feature-packs-list li.sox-settings-dialog-feature-pack { 163 | display: inline; 164 | margin: 5px; 165 | padding: 10px; 166 | border-radius: 9px; 167 | background-color: lightgrey; 168 | color: #3c4146; /* dark mode compatibility */ 169 | cursor: pointer; 170 | } 171 | 172 | .sox-settings-dialog-feature-packs-list li.sox-settings-dialog-feature-pack.clear-feature-pack-selection { 173 | background-color: inherit; 174 | color: inherit; 175 | } 176 | 177 | #sox-settings-dialog .sox-feature.feature-fade-out, #sox-settings-dialog .sox-feature.disabled-feature { 178 | opacity: 0.5; 179 | } 180 | 181 | #sox-settings-dialog.dark-mode .sox-settings-dialog-feature-pack:hover { 182 | background-color: #3b9de2 !important; 183 | } 184 | 185 | #sox-settings-dialog.dark-mode .sox-settings-dialog-feature-pack, 186 | #sox-settings-dialog.dark-mode #sox-settings-dialog-features .modal-content div:hover { 187 | background-color: black !important; 188 | } 189 | 190 | #sox-settings-dialog.dark-mode .sox-feature-settings, 191 | #sox-settings-dialog.dark-mode .sox-feature-info { 192 | background-color: black; 193 | } 194 | 195 | #sox-settings-dialog.dark-mode .sox-sprite { 196 | fill: #ccc; 197 | } 198 | 199 | /* -------- sox specific features' CSS ------- */ 200 | 201 | #sox-scrollToTop { 202 | position: fixed; 203 | right: 0; 204 | bottom: 0; 205 | background-color: rgba(200, 200, 200, .7); 206 | text-align: center; 207 | cursor: pointer; 208 | } 209 | 210 | #sox-scrollToTop .sox-sprite-top { 211 | width: 45px; 212 | height: 45px; 213 | } 214 | 215 | /*Main centered divs, SE-style:*/ 216 | 217 | .sox-centered { 218 | width: 400px; 219 | z-index: 1001; 220 | top: 102px; 221 | left: 615.5px; 222 | display: inline-block; 223 | margin-top: -95.5px; 224 | margin-left: -216px; 225 | overflow: auto; 226 | height: 90%; 227 | } 228 | 229 | /*Specifically for quickCommentShortcuts -- the div's too wide! https://github.com/shu8/Stack-Overflow-Optional-Features/issues/36*/ 230 | 231 | #quickCommentShortcuts.sox-centered { 232 | width: 75%; 233 | height: 90%; 234 | z-index: 1001; 235 | top: 20%; 236 | left: 14%; 237 | display: inline-block; 238 | overflow: auto; 239 | } 240 | 241 | /*standOutDupeCloseMigrated signs */ 242 | 243 | .standOutDupeCloseMigrated-duplicate, 244 | .standOutDupeCloseMigrated-closed, 245 | .standOutDupeCloseMigrated-migrated, 246 | .standOutDupeCloseMigrated-onhold { 247 | color: #FFF; 248 | padding: 2px; 249 | border-radius: 4px; 250 | font-size: 12px; 251 | display: inline-block; 252 | } 253 | 254 | .standOutDupeCloseMigrated-duplicate { 255 | background: #FA0; 256 | } 257 | 258 | .standOutDupeCloseMigrated-closed { 259 | background: #F00; 260 | } 261 | 262 | .standOutDupeCloseMigrated-migrated { 263 | background: #19F; 264 | } 265 | 266 | .standOutDupeCloseMigrated-onhold { 267 | background: #808080; 268 | } 269 | 270 | /*metaNewQuestionAlert for the mod diamond and dialog */ 271 | 272 | #metaNewQuestionAlertDialog { 273 | top: 50px; 274 | right: 242px; 275 | width: 377px; 276 | } 277 | 278 | #metaNewQuestionAlertDialogList:empty::after { 279 | content: "No new meta questions at this time."; 280 | } 281 | 282 | /*addHotText for the 'this question is hot' banner */ 283 | 284 | .sox-hot .sox-sprite-hot { 285 | float: left; 286 | width: 35px; 287 | height: 35px; 288 | margin-right: 3px; 289 | fill: red; 290 | } 291 | 292 | .sox-hot.question-list .sox-sprite-hot { 293 | width: 20px; 294 | height: 20px; 295 | } 296 | 297 | /*quickCommentShortcutsMain: */ 298 | 299 | .quickCommentShortcutsReminder { 300 | height: 40%; 301 | width: 13%; 302 | left: 0; 303 | top: 10%; 304 | position: fixed; 305 | background-color: gray; 306 | color: white; 307 | text-align: center; 308 | overflow: auto; 309 | } 310 | 311 | /*Side by Side Editing (SBS, addSBSBtn, startSBS): https://github.com/szego/SE-Answers_scripts/blob/side-by-side/editing-and-toggling/side-by-side-editing.user.js:*/ 312 | 313 | #sidebar.sbs-on { 314 | display: none !important; 315 | } 316 | 317 | #content.sbs-on { 318 | width: 1360px !important; 319 | } 320 | 321 | .draft-saved.sbs-on { 322 | margin-left: 35px !important; 323 | } 324 | 325 | .draft-discarded.sbs-on { 326 | margin-left: 35px !important; 327 | } 328 | 329 | .draft-saved.sbs-on.sbs-newq { 330 | margin-left: 40px !important; 331 | height: 15px !important; 332 | float: left !important; 333 | } 334 | 335 | .draft-discarded.sbs-on.sbs-newq { 336 | margin-left: 40px !important; 337 | height: 15px !important; 338 | float: left !important; 339 | } 340 | 341 | .votecell.sbs-on { 342 | display: none !important; 343 | } 344 | 345 | .hide-preview.sbs-on { 346 | margin-left: 35px !important; 347 | } 348 | 349 | .post-editor.sbs-on { 350 | width: 1360px !important; 351 | } 352 | 353 | .sbs-on-left-side { 354 | width: 660px; 355 | } 356 | 357 | .wmd-button-bar.sbs-on { 358 | float: none !important; 359 | } 360 | 361 | .wmd-container.sbs-on { 362 | float: left !important; 363 | } 364 | 365 | .wmd-preview.sbs-on { 366 | width: 685px; 367 | clear: none !important; 368 | float: right !important; 369 | } 370 | 371 | .wmd-preview.sbs-on.sbs-newq { 372 | margin-top: 10px !important; 373 | } 374 | 375 | .tag-editor-p.sbs-on.sbs-newq { 376 | float: left !important; 377 | } 378 | 379 | .form-item.sbs-on.sbs-newq { 380 | float: left !important; 381 | } 382 | 383 | .post-form.sbs-on { 384 | /*https://github.com/soscripted/sox/issues/247*/ 385 | width: 1335px; 386 | } 387 | 388 | .edit-comment.sbs-on { 389 | clear: both; 390 | position: initial; 391 | } 392 | 393 | /*linkedPostsInline for the displayed post text; https://github.com/shu8/Stack-Overflow-Optional-Features/issues/48 */ 394 | 395 | .linkedPostsInline-loaded-body-sox { 396 | background-color: var(--blue-050); 397 | border-radius: 20px; 398 | overflow-wrap: break-word; 399 | width: auto; 400 | padding: 10px; 401 | -webkit-box-shadow: inset 1px 1px 4px 1px rgba(0, 0, 0, 0.5); 402 | -moz-box-shadow: inset 1px 1px 4px 1px rgba(0, 0, 0, 0.5); 403 | box-shadow: inset 1px 1px 4px 1px rgba(0, 0, 0, 0.5); 404 | } 405 | 406 | .linkedPostsInline-loaded-body-sox .post-text { 407 | width: auto; 408 | font-size: 90%; 409 | } 410 | 411 | /*findAndReplace*/ 412 | 413 | .findReplaceToolbar { 414 | display: block; 415 | background-color: transparent; 416 | padding: 0 12px; 417 | border: 1px solid #c8ccd0; 418 | border-bottom: 0; 419 | border-top: 0; 420 | } 421 | 422 | #findReplace { 423 | cursor: pointer; 424 | } 425 | 426 | .findReplaceToolbar.findReplace>input[type='text'] { 427 | height: 10px; 428 | width: 25%; 429 | } 430 | 431 | .findReplaceToolbar.findReplace>input[type='button'] { 432 | width: 10%; 433 | height: 100%; 434 | } 435 | 436 | /*chatEasyAccess - for the links (b elements)*/ 437 | 438 | .chatEasyAccess b { 439 | cursor: pointer; 440 | } 441 | 442 | /*highlightQuestions -- for the tags*/ 443 | 444 | .sox-tagged-interesting { 445 | position: relative; 446 | } 447 | 448 | .sox-tagged-interesting:before { 449 | content: ''; 450 | display: block; 451 | position: absolute; 452 | top: 0; 453 | left: 0; 454 | width: 2px; 455 | height: 100%; 456 | background: black; 457 | } 458 | 459 | /*commentReplies -- for the reply buttons*/ 460 | 461 | .soxReplyLink { 462 | cursor: pointer; 463 | float: right; 464 | } 465 | 466 | /*flagPercentageBar -- for the percentage bar itself*/ 467 | 468 | #sox-flagPercentProgressBar { 469 | background: var(--black-025); 470 | height: 10px; 471 | width: 250px; 472 | margin: 6px 10px 10px 0; 473 | padding: 0px; 474 | margin: auto; 475 | } 476 | 477 | #sox-flagPercentProgressBar:after { 478 | content: ''; 479 | display: block; 480 | height: 100%; 481 | } 482 | 483 | #sox-flagPercentHelpful { 484 | margin-bottom: 5px; 485 | text-align: center; 486 | } 487 | 488 | .sox-flagPercentProgressBar-container { 489 | background-color: var(--black-025); 490 | } 491 | 492 | /*sox-copyCode -- for the button and textarea (completely hide it)*/ 493 | 494 | .sox-copyCodeButton { 495 | float: right; 496 | position: sticky; 497 | top: 0; 498 | /* z-index for compatibility with https://github.com/SmartManoj/SmartUserScripts/blob/master/SO_Lines.user.js */ 499 | z-index: 50; 500 | display: none; 501 | /* relative position prevents code from being in front of button */ 502 | position: relative; 503 | background-color: var(--highlight-bg); 504 | margin-left: -15px; 505 | } 506 | 507 | .sox-copyCodeTextarea { 508 | position: fixed; 509 | top: 0; 510 | left: 0; 511 | height: 1px; 512 | width: 1px; 513 | padding: 0; 514 | border: 0; 515 | outline: 0; 516 | box-shadow: none; 517 | background: transparent; 518 | overflow: hidden; 519 | } 520 | 521 | /*openLinksInNewTab -- for the external link sign*/ 522 | 523 | .sox-openLinksInNewTab-externalLink { 524 | display: inline !important; 525 | margin-left: 3px; 526 | } 527 | 528 | /*colorAnswerer -- for the username*/ 529 | 530 | .sox-answerer { 531 | color: var(--blue-700); 532 | background-color: var(--bc-medium); 533 | padding: 1px 5px !important; 534 | border-radius: 3px; 535 | } 536 | 537 | /*hotNetworkQuestionsFiltering -- for the questions to hide*/ 538 | 539 | .sox-hot-network-question-filter-hide { 540 | display: none !important; 541 | } 542 | 543 | /*addAuthorNameToInboxNotifications -- for the author span*/ 544 | 545 | .sox-notification-author { 546 | color: #848d95; 547 | } 548 | 549 | /*hotNetworkQuestionsFiltering -- for the tags icon and tags list shown after hovering over it*/ 550 | 551 | .getQuestionTags { 552 | margin-left: 5px; 553 | } 554 | 555 | .sox-hnq-question-tags-tooltip { 556 | display: block; 557 | margin-top: 5px; 558 | margin-left: 5px; 559 | background-color: #eeeefe; 560 | border: 1px solid darkgrey; 561 | font-size: 11px; 562 | padding: 2px; 563 | white-space: normal; 564 | } 565 | 566 | /* onlyShowCommentActionsOnHover -- the function just adds this CSS to avoid having to do it in JS*/ 567 | /* thanks @Makyen */ 568 | 569 | .sox-onlyShowCommentActionsOnHover:not(:hover) .comment-up-off, 570 | .sox-onlyShowCommentActionsOnHover:not(:hover) .js-comment-flag:not(.fc-red-500) { 571 | visibility: hidden; 572 | } 573 | 574 | /* autoShowCommentImages -- for the image displayed */ 575 | .sox-autoShowCommentImages-image { 576 | padding: 2px; 577 | margin: 2px; 578 | border: 1px dotted black; 579 | display: block; 580 | } 581 | 582 | /* editComment -- for the dialog and checkboxes shown when editing */ 583 | .sox-editComment-currentValues { 584 | border: 1px solid black; 585 | padding: 12px; 586 | } 587 | 588 | .sox-editComment-deleteDialogButton, 589 | .sox-editComment-editDialogButton { 590 | min-height: 2.2em !important; 591 | padding: 5px !important; 592 | font-size: 12px; 593 | margin-left: 5px; 594 | } 595 | 596 | .sox-editComment-reason { 597 | display: inline-block !important; 598 | background-color: inherit; 599 | color: inherit; 600 | margin-top: 7px; 601 | padding: 3px; 602 | } 603 | 604 | .sox-editComment-reason:hover { 605 | background-color: gray; 606 | color: white; 607 | } 608 | 609 | #currentValues section { 610 | display: inline-block !important; 611 | padding: 0px 10px; 612 | } 613 | 614 | /* customMagicLinks -- for the settings table in the dialog */ 615 | .sox-customMagicLinks-settings-table { 616 | margin-bottom: 10px; 617 | } 618 | 619 | .sox-customMagicLinks-settings-table td, 620 | .sox-customMagicLinks-settings-table th { 621 | border: 1px solid #ddd; 622 | padding: 8px; 623 | } 624 | 625 | .sox-customMagicLinks-settings-table th { 626 | font-weight: bold; 627 | } 628 | 629 | /* linkedToFrom -- for the >/< chevrons */ 630 | .sox-linkedToFrom-chevron { 631 | width: 22px; 632 | height: 22px; 633 | float: right; 634 | } 635 | 636 | /* quickAuthorInfo -- for the user details */ 637 | .sox-quickAuthorInfo-details { 638 | color: #848d95; 639 | font-size: 11px; 640 | padding-top: 38px 641 | } 642 | 643 | .sox-quickAuthorInfo-details .sox-sprite-access_time { 644 | margin-right: 5px; 645 | } 646 | 647 | .sox-quickAuthorInfo-unregistered { 648 | margin-left: 5px; 649 | font-size: smaller; 650 | } 651 | 652 | .sox-last-seen { 653 | position: relative; 654 | bottom: 2.5px; 655 | } 656 | 657 | /* markEmployees -- for the SE icon */ 658 | .sox-markEmployees-logo { 659 | margin-left: 5px; 660 | height: 15px; 661 | width: 15px; 662 | } 663 | 664 | /* openImagesAsModals -- for the small '(source)' link in header */ 665 | .sox-openImagesAsModals-sourceLink { 666 | font-size: 18px; 667 | margin-left: 10px; 668 | font-weight: bold; 669 | font-style: italic; 670 | } 671 | 672 | /* sox-scrollChatRoomsList -- for the user popup and sidebar scrollbar */ 673 | .sox-scrollChatRoomsList-user-popup { 674 | max-height: calc(100vh - 90px); 675 | } 676 | 677 | .sox-scrollChatRoomsList-user-popup ul.no-bullets { 678 | max-height: calc(100vh - 500px); 679 | overflow-y: auto; 680 | } 681 | 682 | .sox-scrollChatRoomsList-sidebar { 683 | max-height: calc(40vh); 684 | overflow: auto; 685 | } 686 | 687 | .sox-scrollChatRoomsList-sidebar > #my-rooms { 688 | height: 100%; 689 | overflow: auto; 690 | } 691 | 692 | /* tabularReviewerStats -- for the table with user's stats */ 693 | .sox-tabularReviewerStats-table tr, 694 | .sox-tabularReviewerStats-table th, 695 | .sox-tabularReviewerStats-table td:not(:first-child) { 696 | border: 1px solid #cccccc; 697 | border-collapse: collapse; 698 | } 699 | 700 | .sox-tabularReviewerStats-table th, .sox-tabularReviewerStats-table td { 701 | padding: 4px; 702 | } 703 | 704 | /* stickyVoteButtons -- for the vote buttons on the left of the post */ 705 | .sox-stickyVoteButtons { 706 | z-index: 2; 707 | } 708 | 709 | .sox-stickyVoteButtons > .js-voting-container { 710 | position: -webkit-sticky; 711 | position: sticky; 712 | } 713 | 714 | .sox-stickyVoteButtons .s-popover { 715 | width: max-content; 716 | } 717 | 718 | /* displayName -- for the CSS of the username */ 719 | .sox-displayName { 720 | color: color(--white); 721 | padding-right: 12px; 722 | font-size: 13px; 723 | } 724 | 725 | /* flagPercentages -- for the percentages */ 726 | .sox-percentage-span { 727 | margin-left: 5px; 728 | color: #999; 729 | font-size: 12px; 730 | } 731 | 732 | /* topAnswers -- for the scores of the answers */ 733 | #sox-top-answers { 734 | padding-bottom: 10px; 735 | border-bottom: 1px solid #eaebec; 736 | } 737 | 738 | /* warnNotLoggedIn -- for the div warning the user */ 739 | #loggedInReminder { 740 | position: fixed; 741 | right: 0; 742 | bottom: 50px; 743 | background-color: rgba(200, 200, 200, 1); 744 | width: 200px; 745 | text-align: center; 746 | padding: 5px; 747 | color: black; 748 | font-weight: bold; 749 | } 750 | 751 | /* disableVoteButtons -- for the CSS of the disabled voting buttons */ 752 | .sox-disabled-button { 753 | cursor: default; 754 | opacity: 0.5; 755 | pointer-events: none; 756 | } 757 | 758 | /* addTagsToHNQs -- for the tags' span */ 759 | .sox-addTagsToHNQs-span { 760 | color: grey; 761 | display: block; 762 | margin-bottom: 10px; 763 | } 764 | -------------------------------------------------------------------------------- /sox.dialog.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | sox settings v1.0.3dev 5 |

6 |
7 |
8 |

search

9 |
10 | 14 |
15 |
16 |
17 |

Feature Packs

18 |
19 |
    20 |
  • Major UI tweaks
  • 21 |
  • Key features
  • 22 |
  • Power User features
  • 23 |
  • clear
  • 24 |
25 |
26 |
27 | 45 |
46 | -------------------------------------------------------------------------------- /sox.dialog.js: -------------------------------------------------------------------------------- 1 | (function(sox, $) { 2 | 'use strict'; 3 | 4 | sox.dialog = { 5 | init: function(options) { 6 | if (!$('.s-topbar').length) return; 7 | sox.debug('initializing SOX dialog'); 8 | 9 | const version = options.version; 10 | const features = options.features; 11 | const settings = options.settings; 12 | const lastVersionInstalled = options.lastVersionInstalled; 13 | const html = GM_getResourceText('dialog'); 14 | const $soxSettingsDialog = $(html); 15 | const $soxSettingsDialogFeatures = $soxSettingsDialog.find('#sox-settings-dialog-features'); 16 | const $soxSettingsDialogVersion = $soxSettingsDialog.find('#sox-settings-dialog-version'); 17 | const $soxSettingsSave = $soxSettingsDialog.find('#sox-settings-dialog-save'); 18 | const $soxSettingsReset = $soxSettingsDialog.find('#sox-settings-dialog-reset'); 19 | const $soxSettingsDebugging = $soxSettingsDialog.find('#sox-settings-dialog-debugging'); 20 | const $soxSettingsNewAccessTokenButton = $soxSettingsDialog.find('#sox-settings-dialog-access-token'); 21 | const $soxSettingsToggle = $soxSettingsDialog.find('#sox-settings-dialog-check-toggle'); 22 | const $soxSettingsClose = $soxSettingsDialog.find('#sox-settings-dialog-close'); 23 | const $searchBox = $soxSettingsDialog.find('#search'); 24 | const $importSettingsButton = $soxSettingsDialog.find('#sox-settings-import'); 25 | const $exportSettingsButton = $soxSettingsDialog.find('#sox-settings-export'); 26 | const $featurePackButtons = $soxSettingsDialog.find('.sox-settings-dialog-feature-pack'); 27 | 28 | // Array of HTML strings that will be displayed as `li` items if the user has installed a new version. 29 | const changes = [ 30 | "Fix bugs in various features due to SE layout changes", 31 | "Add feature to add answer count to question header", 32 | "Behind-the-scenes performance improvements (e.g., reduce usage of jQuery for efficiency - thanks @double-beep!)", 33 | 'Deprecate "align badges by their class on user profile pages" feature (now natively implemented!)', 34 | 'Deprecate "differentiate spoilers from empty blockquotes" (now native!)', 35 | ]; 36 | 37 | function addCategory(name) { 38 | const $div = $('
', { 39 | 'class': 'header category', 40 | 'id': 'header-for-' + name, 41 | }); 42 | 43 | const $h3 = $('

', { 44 | text: name.toLowerCase(), 45 | }); 46 | 47 | const $content = $('
', { 48 | id: name, 49 | 'class': 'modal-content features', 50 | }); 51 | $div.append($h3); 52 | 53 | if (!$soxSettingsDialogFeatures.find('div#header-for-' + name).length) { 54 | $soxSettingsDialogFeatures.append($div); 55 | $div.after($content); 56 | } 57 | } 58 | 59 | function addFeature(category, name, description, featureSettings, extendedDescription, metaLink, featurePacks, usesApi) { 60 | const blockFeatureSelection = usesApi && !sox.settings.accessToken; 61 | 62 | const $div = $('
', { 63 | 'class': 'sox-feature ' + (featurePacks.length ? featurePacks.join(' ') : '') + (blockFeatureSelection ? ' disabled-feature' : ''), 64 | 'title': blockFeatureSelection ? 'You must get an access token to enable this feature (click the key button at the bottom of the SOX dialog)' : '', 65 | }); 66 | 67 | const $info = $(sox.sprites.getSvg('info')).hover(function() { 68 | if (extendedDescription && !$(this).parent().find('.sox-feature-info').length) { 69 | $(this).parent().append($('
', { 70 | 'class': 'sox-feature-info', 71 | 'html': extendedDescription + (metaLink ? ' [meta]' : ''), 72 | })); 73 | } 74 | }); 75 | 76 | const $label = $('