├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── config.yml
├── dependabot.yml
└── workflows
│ ├── ci.yml
│ └── lock.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── dev
├── post-install.js
└── webpack-import-glob.cjs
├── eslint.config.mjs
├── package-lock.json
├── package.json
├── src
├── assets
│ ├── icons
│ │ └── legacy_smile.svg
│ ├── logos
│ │ ├── 7tv_logo.png
│ │ ├── bttv_logo.png
│ │ ├── emote_menu_logo.svg
│ │ ├── ffz_logo.png
│ │ └── logo.svg
│ └── sounds
│ │ └── ts-tink.ogg
├── common
│ ├── api
│ │ └── emote-menu.js
│ ├── components
│ │ ├── ColorPicker.jsx
│ │ ├── ColorPicker.module.css
│ │ ├── Emote.jsx
│ │ ├── Emote.module.css
│ │ ├── FontAwesomeSvgIcon.jsx
│ │ ├── LogoIcon.jsx
│ │ ├── ThemeProvider.jsx
│ │ ├── YoutubeEphemeralMessage.jsx
│ │ ├── YoutubeEphemeralMessage.module.css
│ │ └── autocomplete
│ │ │ ├── AutocompletePopover.jsx
│ │ │ ├── AutocompletePopover.module.css
│ │ │ ├── AutocompleteWhisper.jsx
│ │ │ ├── Items.jsx
│ │ │ ├── Items.module.css
│ │ │ ├── ItemsHeader.jsx
│ │ │ └── ItemsHeader.module.css
│ ├── hooks
│ │ ├── ChatInputPartialInput.jsx
│ │ ├── EmoteMenuViewStore.jsx
│ │ ├── Resize.jsx
│ │ └── StorageState.jsx
│ └── stores
│ │ └── emote-menu-view-store.js
├── constants.js
├── i18n
│ ├── index.js
│ └── messages
│ │ ├── cs-CZ.json
│ │ ├── de-DE.json
│ │ ├── es-ES.json
│ │ ├── fr-FR.json
│ │ ├── hu-HU.json
│ │ ├── it-IT.json
│ │ ├── ja-JP.json
│ │ ├── ko-KR.json
│ │ ├── nl-NL.json
│ │ ├── no-NO.json
│ │ ├── pl-PL.json
│ │ ├── pt-BR.json
│ │ ├── pt-PT.json
│ │ ├── ru-RU.json
│ │ ├── sv-SE.json
│ │ ├── tr-TR.json
│ │ └── uk-UA.json
├── index.js
├── modules
│ ├── anon_chat
│ │ └── index.js
│ ├── auto_claim
│ │ └── index.js
│ ├── auto_join_raids
│ │ └── index.js
│ ├── auto_mod_view
│ │ └── index.js
│ ├── auto_theater_mode
│ │ └── index.js
│ ├── channel_points
│ │ ├── index.js
│ │ └── style.css
│ ├── chat
│ │ ├── index.js
│ │ └── style.css
│ ├── chat_admin_messages
│ │ └── index.js
│ ├── chat_commands
│ │ ├── commands
│ │ │ ├── anon-chat.js
│ │ │ ├── b.js
│ │ │ ├── chatters.js
│ │ │ ├── clip.js
│ │ │ ├── followed.js
│ │ │ ├── follows.js
│ │ │ ├── fun.js
│ │ │ ├── fun.module.css
│ │ │ ├── localascii.js
│ │ │ ├── localmod.js
│ │ │ ├── localsub.js
│ │ │ ├── purge.js
│ │ │ ├── sub.js
│ │ │ ├── suboff.js
│ │ │ ├── unban.js
│ │ │ ├── uptime.js
│ │ │ └── viewers.js
│ │ ├── index.js
│ │ └── store.js
│ ├── chat_custom_timeouts
│ │ ├── index.js
│ │ └── style.css
│ ├── chat_deleted_messages
│ │ ├── index.js
│ │ └── style.css
│ ├── chat_direction
│ │ ├── index.js
│ │ └── style.css
│ ├── chat_font_settings
│ │ └── index.js
│ ├── chat_highlight_blacklist_keywords
│ │ ├── index.js
│ │ └── style.css
│ ├── chat_left_side
│ │ ├── index.js
│ │ └── style.css
│ ├── chat_moderator_cards
│ │ ├── index.js
│ │ ├── moderator-card.js
│ │ └── style.css
│ ├── chat_nicknames
│ │ └── index.js
│ ├── chat_replies
│ │ ├── index.js
│ │ └── style.css
│ ├── chat_settings
│ │ ├── index.js
│ │ └── style.css
│ ├── chat_tab_completion
│ │ └── index.js
│ ├── clips
│ │ ├── index.js
│ │ └── style.css
│ ├── conversations
│ │ ├── index.js
│ │ └── style.css
│ ├── directory_live_following
│ │ └── index.js
│ ├── disable_badges
│ │ ├── index.js
│ │ └── style.css
│ ├── disable_channel_points_message_highlights
│ │ ├── index.js
│ │ └── style.css
│ ├── disable_homepage_autoplay
│ │ └── index.js
│ ├── disable_localized_names
│ │ └── index.js
│ ├── disable_name_colors
│ │ ├── index.js
│ │ └── style.css
│ ├── disable_offline_channel_autoplay
│ │ └── index.js
│ ├── doubleclick_mention
│ │ └── index.js
│ ├── emote_autocomplete
│ │ ├── components
│ │ │ ├── EmoteRow.jsx
│ │ │ └── EmoteRow.module.css
│ │ ├── index.js
│ │ ├── twitch
│ │ │ ├── EmoteAutocomplete.jsx
│ │ │ └── EmoteAutocomplete.module.css
│ │ └── youtube
│ │ │ ├── EmoteAutocomplete.jsx
│ │ │ └── EmoteAutocomplete.module.css
│ ├── emote_menu
│ │ ├── components
│ │ │ ├── Button.jsx
│ │ │ ├── Button.module.css
│ │ │ ├── EmoteButton.jsx
│ │ │ ├── EmoteButton.module.css
│ │ │ ├── EmoteMenu.jsx
│ │ │ ├── EmoteMenu.module.css
│ │ │ ├── EmoteMenuPopover.jsx
│ │ │ ├── EmoteMenuPopover.module.css
│ │ │ ├── Emotes.jsx
│ │ │ ├── Emotes.module.css
│ │ │ ├── Header.jsx
│ │ │ ├── Header.module.css
│ │ │ ├── Icons.jsx
│ │ │ ├── Icons.module.css
│ │ │ ├── Preview.jsx
│ │ │ ├── Preview.module.css
│ │ │ ├── Sidebar.jsx
│ │ │ ├── Sidebar.module.css
│ │ │ ├── Tip.jsx
│ │ │ ├── Tip.module.css
│ │ │ ├── VirtualizedList.jsx
│ │ │ └── VirtualizedList.module.css
│ │ ├── hooks
│ │ │ ├── AutoRepositionPopover.jsx
│ │ │ ├── AutoScroll.jsx
│ │ │ ├── GridKeyboardNavigation.jsx
│ │ │ └── HorizontalResize.jsx
│ │ ├── index.js
│ │ ├── stores
│ │ │ └── emote-menu-store.js
│ │ ├── styles
│ │ │ └── Scrollbar.module.css
│ │ ├── twitch
│ │ │ ├── EmoteMenu.jsx
│ │ │ └── EmoteMenu.module.css
│ │ ├── utils
│ │ │ ├── emojis.js
│ │ │ ├── twitch-emotes.js
│ │ │ └── youtube-emotes.js
│ │ └── youtube
│ │ │ ├── EmoteMenu.jsx
│ │ │ └── EmoteMenu.module.css
│ ├── emotes
│ │ ├── abstract-emotes.js
│ │ ├── channel-emotes.js
│ │ ├── emojis.js
│ │ ├── emote.js
│ │ ├── global-emotes.js
│ │ ├── index.js
│ │ ├── personal-emotes.js
│ │ └── style.css
│ ├── frankerfacez
│ │ ├── channel-emotes.js
│ │ └── global-emotes.js
│ ├── global_css
│ │ ├── index.js
│ │ ├── rsuite.less
│ │ ├── style.css
│ │ ├── twitch.js
│ │ └── youtube.js
│ ├── hide_bits
│ │ ├── index.js
│ │ └── style.css
│ ├── hide_chat_clips
│ │ ├── index.js
│ │ └── style.css
│ ├── hide_chat_events
│ │ └── index.js
│ ├── hide_community_highlights
│ │ ├── index.js
│ │ └── style.css
│ ├── hide_prime_promotions
│ │ ├── index.js
│ │ └── style.css
│ ├── hide_sidebar_elements
│ │ ├── index.js
│ │ └── styles.module.css
│ ├── hype_chat
│ │ ├── index.js
│ │ └── styles.module.css
│ ├── send_message
│ │ └── index.js
│ ├── settings
│ │ ├── components
│ │ │ ├── AnimatedLogo.jsx
│ │ │ ├── ChatWindow.jsx
│ │ │ ├── CloseButton.jsx
│ │ │ ├── Settings.jsx
│ │ │ ├── Settings.module.css
│ │ │ ├── Sidenav.jsx
│ │ │ ├── Store.jsx
│ │ │ ├── Table.jsx
│ │ │ ├── Window.jsx
│ │ │ ├── Window.module.css
│ │ │ └── settings
│ │ │ │ ├── global
│ │ │ │ ├── Emotes.jsx
│ │ │ │ └── Emotes.module.css
│ │ │ │ ├── twitch
│ │ │ │ ├── AnonChat.jsx
│ │ │ │ ├── AutoClaim.jsx
│ │ │ │ ├── AutoJoinRaids.jsx
│ │ │ │ ├── AutoModVIew.jsx
│ │ │ │ ├── AutoPlay.jsx
│ │ │ │ ├── AutoTheatreMode.jsx
│ │ │ │ ├── BlacklistKeywords.jsx
│ │ │ │ ├── ChannelPoints.jsx
│ │ │ │ ├── Chat.jsx
│ │ │ │ ├── ChatLayout.jsx
│ │ │ │ ├── ClickToPlay.jsx
│ │ │ │ ├── DeletedMessages.jsx
│ │ │ │ ├── EmoteMenu.jsx
│ │ │ │ ├── HighlightFeedback.jsx
│ │ │ │ ├── HighlightKeywords.jsx
│ │ │ │ ├── HypeChat.jsx
│ │ │ │ ├── MuteInvisiblePlayer.jsx
│ │ │ │ ├── PinnedHighlights.jsx
│ │ │ │ ├── PlayerExtensions.jsx
│ │ │ │ ├── PrimePromotions.jsx
│ │ │ │ ├── ReverseChatDirection.jsx
│ │ │ │ ├── ScrollPlayerControls.jsx
│ │ │ │ ├── ShowDirectoryLiveTab.jsx
│ │ │ │ ├── Sidebar.jsx
│ │ │ │ ├── SplitChat.jsx
│ │ │ │ ├── TabCompletionEmotePriority.jsx
│ │ │ │ ├── Theme.jsx
│ │ │ │ ├── Usernames.jsx
│ │ │ │ ├── Whispers.jsx
│ │ │ │ └── YouTube.jsx
│ │ │ │ └── youtube
│ │ │ │ ├── AutoLiveChatView.jsx
│ │ │ │ ├── EmoteAutocomplete.jsx
│ │ │ │ └── EmoteMenu.jsx
│ │ ├── index.js
│ │ ├── pages
│ │ │ ├── About.jsx
│ │ │ ├── Changelog.jsx
│ │ │ ├── ChannelSettings.jsx
│ │ │ ├── ChatSettings.jsx
│ │ │ └── DirectorySettings.jsx
│ │ ├── styles
│ │ │ ├── about.module.css
│ │ │ ├── header.module.css
│ │ │ ├── popout.module.css
│ │ │ ├── sidenav.module.css
│ │ │ ├── table.module.css
│ │ │ └── window.module.css
│ │ ├── twitch
│ │ │ ├── Settings.jsx
│ │ │ └── settings.css
│ │ └── youtube
│ │ │ ├── DropdownButton.jsx
│ │ │ ├── Settings.jsx
│ │ │ └── Settings.module.css
│ ├── seventv
│ │ ├── channel-emotes.js
│ │ ├── global-emotes.js
│ │ ├── index.js
│ │ └── utils.js
│ ├── split_chat
│ │ ├── index.js
│ │ └── style.css
│ ├── subscribers
│ │ └── index.js
│ ├── video_player
│ │ ├── index.js
│ │ └── style.css
│ ├── vod_chat
│ │ └── index.js
│ └── youtube
│ │ ├── index.js
│ │ └── style.css
├── observers
│ ├── dom.js
│ └── history.js
├── settings.js
├── socket-client.js
├── storage.js
├── utils
│ ├── api.js
│ ├── cdn.js
│ ├── channel.js
│ ├── colors.js
│ ├── debug.js
│ ├── emoji-blacklist.js
│ ├── emote.js
│ ├── extension.js
│ ├── flags.js
│ ├── http-error.js
│ ├── image.js
│ ├── keycodes.js
│ ├── keywords.js
│ ├── legacy-settings.js
│ ├── modules.js
│ ├── mousebuttons.js
│ ├── popover.js
│ ├── regex.js
│ ├── safe-event-emitter.js
│ ├── send-chat-message.js
│ ├── sentry-global-handlers-integration.js
│ ├── sentry.js
│ ├── twitch.js
│ ├── user.js
│ ├── window.js
│ ├── youtube-ephemeral-messages.js
│ └── youtube.js
├── watcher.js
└── watchers
│ ├── channel.js
│ ├── chat.js
│ ├── clips.js
│ ├── conversations.js
│ ├── routes.js
│ └── youtube.js
└── webpack.config.js
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: Create a report to help us resolve an issue
3 | labels: bug
4 | body:
5 | - type: textarea
6 | id: description
7 | attributes:
8 | label: Describe the bug
9 | placeholder: 'A clear and concise description of what the bug is.'
10 | validations:
11 | required: true
12 | - type: textarea
13 | id: reproduction_steps
14 | attributes:
15 | label: Steps to reproduce
16 | placeholder: "Steps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error"
17 | validations:
18 | required: true
19 | - type: textarea
20 | id: expected_behavior
21 | attributes:
22 | label: Expected behavior
23 | placeholder: 'A clear and concise description of what you expected to happen.'
24 | validations:
25 | required: true
26 | - type: textarea
27 | id: screenshots
28 | attributes:
29 | label: Screenshots
30 | placeholder: 'Add screenshots to help explain your problem.'
31 | validations:
32 | required: true
33 | - type: textarea
34 | id: device_information
35 | attributes:
36 | label: Device information
37 | placeholder: "- Device: [e.g. iPhone6]\n- OS: [e.g. iOS8.1]\n- Browser [e.g. stock browser, safari]\n- Version [e.g. 22]"
38 | validations:
39 | required: true
40 | - type: input
41 | id: version
42 | attributes:
43 | label: BetterTTV Version
44 | placeholder: '7.x.x'
45 | validations:
46 | required: true
47 | - type: textarea
48 | id: additional_information
49 | attributes:
50 | label: Additional information
51 | placeholder: 'Add any other context about the problem here.'
52 | - type: checkboxes
53 | id: upgrade_check
54 | attributes:
55 | label: Version Check
56 | options:
57 | - label: I have checked that I'm using the newest version of BetterTTV available.
58 | required: true
59 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Feature request
4 | url: https://github.com/night/betterttv/discussions
5 | about: Create a feature request to help us improve
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: '/'
5 | schedule:
6 | interval: weekly
7 | time: '13:00'
8 | open-pull-requests-limit: 10
9 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - name: Extract tag name
11 | if: ${{ startsWith(github.ref, 'refs/tags/') }}
12 | run: echo "GITHUB_TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
13 | - uses: actions/checkout@v3
14 | - uses: actions/setup-node@v3
15 | with:
16 | node-version: 22
17 | cache: 'npm'
18 | - run: npm ci
19 | - run: npm run lint
20 | - run: npm run build:prod
21 | env:
22 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
23 | - name: Publish release
24 | if: ${{ startsWith(github.ref, 'refs/tags/') }}
25 | uses: ncipollo/release-action@v1
26 | with:
27 | artifacts: build/betterttv.tar.gz
28 | artifactContentType: application/gzip
29 | token: ${{ secrets.GITHUB_TOKEN }}
30 |
--------------------------------------------------------------------------------
/.github/workflows/lock.yml:
--------------------------------------------------------------------------------
1 | name: 'Lock Threads'
2 |
3 | on:
4 | schedule:
5 | - cron: '0 * * * *'
6 |
7 | jobs:
8 | lock:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: dessant/lock-threads@v2
12 | with:
13 | github-token: ${{ github.token }}
14 | issue-lock-inactive-days: '90'
15 | issue-lock-comment: >
16 | This issue has been automatically locked since there
17 | has not been any recent activity after it was closed.
18 | Please open a new issue for related issues or feature requests.
19 | issue-lock-reason: 'resolved'
20 | pr-lock-inactive-days: '90'
21 | pr-lock-comment: >
22 | This pull request has been automatically locked since there
23 | has not been any recent activity after it was closed.
24 | Please open a new pull request for related issues or feature requests.
25 | pr-lock-reason: 'resolved'
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | dist
4 | .DS_Store
5 | .vscode
6 | .idea
7 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "trailingComma": "es5",
4 | "bracketSpacing": false,
5 | "singleQuote": true,
6 | "bracketSameLine": true,
7 | "overrides": [
8 | {
9 | "files": ["*.css"],
10 | "options": {
11 | "parser": "css"
12 | }
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2023 NightDev, LLC
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without limitation of the rights to use, copy, modify, merge,
6 | and/or publish copies of the Software, and to permit persons to whom the
7 | Software is furnished to do so, subject to the following conditions:
8 |
9 | The above copyright notice, any copyright notices herein, and this permission
10 | notice shall be included in all copies or substantial portions of the Software,
11 | the Software, or portions of the Software, may not be sold for profit, and the
12 | Software may not be distributed nor sub-licensed without explicit permission
13 | from the copyright owner.
14 |
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | THE SOFTWARE.
23 |
24 | Should any questions arise concerning your usage of this Software, or to
25 | request permission to distribute this Software, please contact the copyright
26 | holder at https://nightdev.com/contact
27 |
28 | ---------------------------------
29 |
30 | Unofficial TLDR:
31 |
32 | Free to modify for personal use
33 | Need permission to distribute the code
34 | Can't sell addon or features of the addon
35 |
--------------------------------------------------------------------------------
/dev/post-install.js:
--------------------------------------------------------------------------------
1 | import {statSync, writeFileSync, readFileSync, lstatSync} from 'fs';
2 |
3 | import {globSync} from 'glob';
4 | import {escapeRegExp} from '../src/utils/regex.js';
5 |
6 | // RSuite sets global styles which can affect native page styles, so remove them
7 | const NORMALIZE_PATH = './node_modules/rsuite/styles/normalize.less';
8 | statSync(NORMALIZE_PATH);
9 | writeFileSync(NORMALIZE_PATH, '');
10 |
11 | // RSuite uses the prefix `rs` for all of its classes, but we want to namespace to `bttv` class prefix
12 | const OLD_CLASSNAME_PREFIX = 'rs';
13 | const NEW_CLASSNAME_PREFIX = 'bttv-rs';
14 | const files = globSync('node_modules/*rsuite*/**/*.+(js|ts|tsx|less|css)', {}).filter(
15 | (pathname) => !pathname.endsWith('.d.ts')
16 | );
17 | for (const pathname of files) {
18 | if (lstatSync(pathname).isDirectory()) {
19 | console.warn(`[POST INSTALL] Processing file ${pathname}, but it is a directory. skipping...`);
20 | continue;
21 | }
22 |
23 | const data = readFileSync(pathname).toString();
24 |
25 | let classnameRegex;
26 |
27 | if (pathname.endsWith('.less') || pathname.endsWith('.css')) {
28 | classnameRegex = new RegExp(`(\\.|--)(${escapeRegExp(OLD_CLASSNAME_PREFIX)})(,|-|{|\\s)`, 'gm');
29 | } else if (pathname.endsWith('.js') || pathname.endsWith('.ts') || pathname.endsWith('.tsx')) {
30 | classnameRegex = new RegExp(`((?:'|\`|")\\.|'|\`|")(${escapeRegExp(OLD_CLASSNAME_PREFIX)})(-|\\s|'|\`|")`, 'gm');
31 | }
32 |
33 | if (classnameRegex == null) {
34 | continue;
35 | }
36 |
37 | const newData = data.replace(classnameRegex, (match, p1, _p2, p3) => {
38 | if (["'", '"', '`'].includes(p1) && p1 !== p3 && p3 !== '-' && /\s/.test(p3)) {
39 | return match;
40 | }
41 |
42 | return `${p1}${NEW_CLASSNAME_PREFIX}${p3}`;
43 | });
44 |
45 | writeFileSync(pathname, newData);
46 | }
47 |
--------------------------------------------------------------------------------
/dev/webpack-import-glob.cjs:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const {hasMagic, globSync} = require('glob');
3 | const normalizePath = require('normalize-path');
4 |
5 | function replacer(match, quote, filename) {
6 | if (!hasMagic(filename)) return match;
7 | const resourceDir = path.dirname(this.resourcePath);
8 | return globSync(filename, {
9 | cwd: resourceDir,
10 | dotRelative: true,
11 | })
12 | .map(
13 | (file) => `
14 | try {
15 | await import(${quote}${normalizePath(file)}${quote});
16 | } catch (e) {
17 | Sentry.captureException(e);
18 | debug.error('Failed to import ${normalizePath(file)}', e.stack);
19 | }
20 | `
21 | )
22 | .join('');
23 | }
24 |
25 | module.exports = function importGlob(source) {
26 | this.cacheable();
27 | const regex = /.?await import\((['"])(.*?)\1\);?/gm;
28 | return source.replace(regex, replacer.bind(this));
29 | };
30 |
--------------------------------------------------------------------------------
/src/assets/icons/legacy_smile.svg:
--------------------------------------------------------------------------------
1 | image/svg+xml
2 |
--------------------------------------------------------------------------------
/src/assets/logos/7tv_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/night/betterttv/241b6892cf62440565918d05cbea49463882d017/src/assets/logos/7tv_logo.png
--------------------------------------------------------------------------------
/src/assets/logos/bttv_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/night/betterttv/241b6892cf62440565918d05cbea49463882d017/src/assets/logos/bttv_logo.png
--------------------------------------------------------------------------------
/src/assets/logos/emote_menu_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/assets/logos/ffz_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/night/betterttv/241b6892cf62440565918d05cbea49463882d017/src/assets/logos/ffz_logo.png
--------------------------------------------------------------------------------
/src/assets/logos/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/assets/sounds/ts-tink.ogg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/night/betterttv/241b6892cf62440565918d05cbea49463882d017/src/assets/sounds/ts-tink.ogg
--------------------------------------------------------------------------------
/src/common/components/ColorPicker.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 | import {HexColorPicker, HexColorInput} from 'react-colorful';
4 | import styles from './ColorPicker.module.css';
5 |
6 | export default function ColorPicker({className, color, onChange}) {
7 | const popoutRef = React.useRef(null);
8 | const [isPopoutOpen, setIsPopoutOpen] = React.useState(false);
9 |
10 | React.useEffect(() => {
11 | function handleClickOutside(event) {
12 | const currentPopoutRef = popoutRef.current;
13 | if (currentPopoutRef == null || currentPopoutRef.contains(event.target)) {
14 | return;
15 | }
16 | setIsPopoutOpen(false);
17 | }
18 | document.addEventListener('mousedown', handleClickOutside);
19 | return () => document.removeEventListener('mousedown', handleClickOutside);
20 | }, []);
21 |
22 | function handleTogglePopout() {
23 | setIsPopoutOpen(!isPopoutOpen);
24 | }
25 |
26 | return (
27 |
28 |
29 | {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
30 |
31 |
32 |
33 | {isPopoutOpen ? (
34 |
35 |
36 |
37 | ) : null}
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/common/components/ColorPicker.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: relative;
3 | z-index: 10;
4 |
5 | :global {
6 | .react-colorful {
7 | width: 80px;
8 | height: 80px;
9 | }
10 |
11 | .react-colorful__hue {
12 | height: 12px;
13 | }
14 | }
15 | }
16 |
17 | .swatchContainer {
18 | display: flex;
19 | flex-direction: row;
20 | border-radius: 4px;
21 | padding: 2px;
22 | }
23 |
24 | .swatch {
25 | width: 28px;
26 | height: 28px;
27 | border-radius: 4px;
28 | margin-right: 4px;
29 | }
30 |
31 | .hexInput {
32 | color: var(--bttv-rs-text-primary);
33 | background-color: var(--bttv-rs-input-bg);
34 | border: 1px solid var(--bttv-rs-border-primary);
35 | padding: 2px 8px;
36 | font-size: 14px;
37 | line-height: 1.43;
38 | border-radius: 4px;
39 | width: 72px;
40 |
41 | &:hover {
42 | border-color: var(--bttv-rs-input-focus-border);
43 | }
44 |
45 | &:focus {
46 | border-color: var(--bttv-rs-input-focus-border);
47 | outline: 3px solid var(--bttv-rs-color-focus-ring);
48 | }
49 | }
50 |
51 | .popout {
52 | position: absolute;
53 | top: calc(100% + 2px);
54 | left: 0;
55 | border-radius: 4px;
56 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
57 | }
58 |
--------------------------------------------------------------------------------
/src/common/components/Emote.module.css:
--------------------------------------------------------------------------------
1 | .emoteImage {
2 | width: 28px;
3 | height: 28px;
4 | object-fit: contain;
5 | cursor: pointer;
6 | }
7 |
8 | .placeholder {
9 | background-color: var(--bttv-rs-bg-card);
10 | }
11 |
12 | .emoteImageLocked {
13 | opacity: 0.4;
14 | }
15 |
16 | .lockedEmote {
17 | position: relative;
18 | width: 28px;
19 | height: 28px;
20 | }
21 |
22 | .lock {
23 | position: absolute;
24 | bottom: 1px;
25 | right: 1px;
26 | width: 10px;
27 | height: 10px;
28 | color: var(--bttv-rs-text-heading);
29 | }
30 |
--------------------------------------------------------------------------------
/src/common/components/FontAwesomeSvgIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function FontAwesomeSvgIcon({fontAwesomeIcon: {width = 20, height = 20, svgPathData}, ...restProps}) {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/common/components/LogoIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function LogoIcon(props) {
4 | return (
5 |
6 |
10 |
14 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/common/components/ThemeProvider.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {CustomProvider} from 'rsuite';
3 | import {SettingIds} from '../../constants.js';
4 | import useStorageState from '../hooks/StorageState.jsx';
5 |
6 | export default function ThemeProvider({children}) {
7 | const [dark] = useStorageState(SettingIds.DARKENED_MODE);
8 | return {children} ;
9 | }
10 |
--------------------------------------------------------------------------------
/src/common/components/YoutubeEphemeralMessage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import chat from '../../modules/chat/index.js';
3 | import cdn from '../../utils/cdn.js';
4 | import styles from './YoutubeEphemeralMessage.module.css';
5 |
6 | export default function YoutubeEphemeralMessage({message}) {
7 | const messageRef = React.useRef(null);
8 |
9 | React.useEffect(() => {
10 | if (messageRef?.current == null) {
11 | return;
12 | }
13 | chat.messageReplacer(messageRef.current, null);
14 | }, [messageRef]);
15 |
16 | return (
17 |
18 |
19 |
{message}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/common/components/YoutubeEphemeralMessage.module.css:
--------------------------------------------------------------------------------
1 | .message {
2 | display: flex;
3 | align-items: center;
4 | padding: 8px 24px;
5 |
6 | .mascot {
7 | width: 24px;
8 | margin-right: 16px;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/common/components/autocomplete/AutocompletePopover.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React, {useCallback, useEffect, useRef, useState} from 'react';
3 | import {mergeRefs} from 'react-merge-refs';
4 | import Popover from 'rsuite/Popover';
5 | import repositionPopover from '../../../utils/popover.js';
6 | import useResize from '../../hooks/Resize.jsx';
7 | import styles from './AutocompletePopover.module.css';
8 | import Items from './Items.jsx';
9 |
10 | const TOP_PADDING = 2;
11 |
12 | const AutocompletePopover = React.forwardRef(
13 | (
14 | {
15 | triggerRef,
16 | appendToChat,
17 | className,
18 | style,
19 | boundingQuerySelector,
20 | chatInputElement,
21 | onComplete,
22 | getChatInputPartialInput,
23 | renderRow,
24 | computeMatches,
25 | ...props
26 | },
27 | ref
28 | ) => {
29 | const localRef = useRef(null);
30 | const [popoverWidth, setPopoverWidth] = useState(null);
31 |
32 | const reposition = useCallback(() => {
33 | if (chatInputElement == null) {
34 | return;
35 | }
36 | const {width} = chatInputElement.getBoundingClientRect();
37 | setPopoverWidth(width);
38 | repositionPopover(localRef, boundingQuerySelector, TOP_PADDING);
39 | }, [setPopoverWidth, chatInputElement]);
40 |
41 | useResize(reposition);
42 | useEffect(() => {
43 | reposition();
44 | }, [reposition, localRef, style]);
45 |
46 | return (
47 |
53 | reposition()}
56 | onComplete={onComplete}
57 | getChatInputPartialInput={getChatInputPartialInput}
58 | renderRow={renderRow}
59 | computeMatches={computeMatches}
60 | />
61 |
62 | );
63 | }
64 | );
65 |
66 | export default AutocompletePopover;
67 |
--------------------------------------------------------------------------------
/src/common/components/autocomplete/AutocompletePopover.module.css:
--------------------------------------------------------------------------------
1 | .popover {
2 | height: auto;
3 | width: 300px;
4 | overflow: hidden;
5 | margin: 0 !important;
6 |
7 | div[class$='-popover-arrow'] {
8 | display: none !important;
9 | }
10 | }
11 |
12 | @media only screen and (max-width: 400px) {
13 | .popover {
14 | width: 320px;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/common/components/autocomplete/AutocompleteWhisper.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Whisper} from 'rsuite';
3 | import ThemeProvider from '../ThemeProvider.jsx';
4 | import AutocompletePopover from './AutocompletePopover.jsx';
5 |
6 | export default function AutocompleteWhisper({
7 | boundingQuerySelector,
8 | chatInputElement,
9 | onComplete,
10 | getChatInputPartialInput,
11 | renderRow,
12 | computeMatches,
13 | }) {
14 | return (
15 |
16 |
28 | }>
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/common/components/autocomplete/Items.module.css:
--------------------------------------------------------------------------------
1 | .emotes {
2 | display: flex;
3 | flex-direction: column;
4 | margin: 8px;
5 | }
6 |
--------------------------------------------------------------------------------
/src/common/components/autocomplete/ItemsHeader.jsx:
--------------------------------------------------------------------------------
1 | import React, {useMemo} from 'react';
2 | import formatMessage from '../../../i18n/index.js';
3 | import styles from './ItemsHeader.module.css';
4 |
5 | function MatchesHeader(children) {
6 | return {children} ;
7 | }
8 |
9 | export default function ItemsHeader({chatInputPartialEmote}) {
10 | const partialInput = useMemo(
11 | () => chatInputPartialEmote.slice(1, chatInputPartialEmote.length),
12 | [chatInputPartialEmote]
13 | );
14 |
15 | return (
16 |
17 | {formatMessage(
18 | {defaultMessage: 'Matches for {partialInput}'},
19 | {matches: MatchesHeader, partialInput}
20 | )}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/common/components/autocomplete/ItemsHeader.module.css:
--------------------------------------------------------------------------------
1 | .matches {
2 | color: var(--bttv-rs-btn-subtle-text);
3 | }
4 |
5 | .header {
6 | font-size: 14px;
7 | font-weight: 500;
8 | padding-bottom: 6px;
9 | color: var(--bttv-rs-btn-default-active-text);
10 | }
11 |
--------------------------------------------------------------------------------
/src/common/hooks/ChatInputPartialInput.jsx:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 |
3 | export default function useChatInputPartialInput(chatInputElement, getChatInputPartialInput) {
4 | const [partialInput, setPartialInput] = useState('');
5 |
6 | useEffect(() => {
7 | function handleInput(event) {
8 | const value = getChatInputPartialInput();
9 |
10 | if (value == null) {
11 | setPartialInput('');
12 | return;
13 | }
14 |
15 | setPartialInput(value);
16 | event.stopPropagation();
17 | }
18 |
19 | chatInputElement?.addEventListener('input', handleInput);
20 |
21 | return () => {
22 | chatInputElement?.removeEventListener('input', handleInput);
23 | };
24 | }, [getChatInputPartialInput, chatInputElement]);
25 |
26 | return partialInput;
27 | }
28 |
--------------------------------------------------------------------------------
/src/common/hooks/EmoteMenuViewStore.jsx:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 | import emoteMenuViewStore from '../stores/emote-menu-view-store.js';
3 |
4 | export default function useEmoteMenuViewStoreUpdated(shouldUpdate, handleUpdate) {
5 | useEffect(() => {
6 | function handleDirty() {
7 | if (!shouldUpdate) {
8 | return;
9 | }
10 | emoteMenuViewStore.updateEmotes();
11 | }
12 |
13 | handleDirty();
14 |
15 | const removeUpdatedListener = emoteMenuViewStore.on('updated', handleUpdate);
16 | const removeDirtyListener = emoteMenuViewStore.on('dirty', handleDirty);
17 | return () => {
18 | removeUpdatedListener();
19 | removeDirtyListener();
20 | };
21 | }, [shouldUpdate, handleUpdate]);
22 | }
23 |
--------------------------------------------------------------------------------
/src/common/hooks/Resize.jsx:
--------------------------------------------------------------------------------
1 | import debounce from 'lodash.debounce';
2 | import {useEffect} from 'react';
3 |
4 | export default function useResize(callback) {
5 | useEffect(() => {
6 | function handleResize() {
7 | requestAnimationFrame(callback);
8 | // Twitch animates chat moving on zoom changes
9 | setTimeout(() => requestAnimationFrame(callback), 500);
10 | }
11 |
12 | const requestResize = debounce(handleResize, 250);
13 |
14 | window.addEventListener('resize', requestResize);
15 | return () => window.removeEventListener('resize', requestResize);
16 | }, []);
17 | }
18 |
--------------------------------------------------------------------------------
/src/common/hooks/StorageState.jsx:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 | import settings from '../../settings.js';
3 |
4 | export default function useStorageState(settingId) {
5 | const [value, setValue] = useState(settings.get(settingId));
6 |
7 | useEffect(() => {
8 | function callback(newValue) {
9 | setValue(newValue);
10 | }
11 |
12 | setValue(settings.get(settingId));
13 |
14 | const cleanup = settings.on(`changed.${settingId}`, callback);
15 | return () => cleanup();
16 | }, [settingId]);
17 |
18 | function setSetting(newValue) {
19 | if (newValue === value) return;
20 | setValue(newValue);
21 | settings.set(settingId, newValue);
22 | }
23 |
24 | return [value, setSetting];
25 | }
26 |
--------------------------------------------------------------------------------
/src/i18n/index.js:
--------------------------------------------------------------------------------
1 | import {createIntl, createIntlCache} from '@formatjs/intl';
2 | import cookies from 'cookies-js';
3 | import {Settings} from 'luxon';
4 |
5 | const DEFAULT_LOCALE = 'en';
6 | const SUPPORTED_LOCALES = [
7 | DEFAULT_LOCALE,
8 | 'cs-CZ',
9 | 'de-DE',
10 | 'es-ES',
11 | 'fr-FR',
12 | 'hu-HU',
13 | 'it-IT',
14 | 'ja-JP',
15 | 'ko-KR',
16 | 'nl-NL',
17 | 'no-NO',
18 | 'pl-PL',
19 | 'pt-BR',
20 | 'pt-PT',
21 | 'ru-RU',
22 | 'sv-SE',
23 | 'tr-TR',
24 | 'uk-UA',
25 | ];
26 |
27 | let browserLocale = Array.isArray(navigator.languages) ? navigator.languages[0] : null;
28 | browserLocale = browserLocale || navigator.language || navigator.browserLanguage || navigator.userLanguage;
29 | browserLocale = browserLocale.replace('_', '-');
30 | if (!SUPPORTED_LOCALES.includes(browserLocale)) {
31 | browserLocale = browserLocale.split('-')[0];
32 | }
33 | if (!SUPPORTED_LOCALES.includes(browserLocale)) {
34 | browserLocale =
35 | SUPPORTED_LOCALES.find((supportedLocale) => supportedLocale.startsWith(browserLocale)) ?? DEFAULT_LOCALE;
36 | }
37 |
38 | let intl;
39 | const cache = createIntlCache();
40 |
41 | function getSiteLocale() {
42 | let locale = cookies.get('language') ?? cookies.get('PREF')?.split('hl=')[1]?.split('&')[0];
43 | if (locale == null) {
44 | return locale;
45 | }
46 | locale = locale.replace('_', '-');
47 | if (!SUPPORTED_LOCALES.includes(locale)) {
48 | locale = locale.split('-')[0];
49 | }
50 | if (!SUPPORTED_LOCALES.includes(locale)) {
51 | locale = SUPPORTED_LOCALES.find((supportedLocale) => supportedLocale.startsWith(locale)) ?? null;
52 | }
53 | return locale;
54 | }
55 |
56 | export async function load() {
57 | const locale = getSiteLocale() ?? browserLocale;
58 | const messages = locale !== DEFAULT_LOCALE ? (await import(`./messages/${locale}.json`)).default : {};
59 |
60 | intl = createIntl(
61 | {
62 | locale,
63 | defaultLocale: DEFAULT_LOCALE,
64 | messages,
65 | },
66 | cache
67 | );
68 |
69 | Settings.defaultLocale = locale;
70 | }
71 |
72 | export default function formatMessage(descriptor, values = undefined) {
73 | if (intl == null) {
74 | throw new Error('i18n not yet loaded');
75 | }
76 |
77 | return intl.formatMessage(descriptor, values);
78 | }
79 |
--------------------------------------------------------------------------------
/src/modules/auto_join_raids/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds} from '../../constants.js';
2 | import domObserver from '../../observers/dom.js';
3 | import settings from '../../settings.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import watcher from '../../watcher.js';
6 |
7 | const RAID_BANNER_SELECTOR = '[data-test-selector="raid-banner"]';
8 | const RAID_LEAVE_BUTTON_SELECTOR = `${RAID_BANNER_SELECTOR} button[class*="ScCoreButton"]`;
9 |
10 | class AutoJoinRaidsModule {
11 | constructor() {
12 | watcher.on('load.chat', () => this.load());
13 | settings.on(`changed.${SettingIds.AUTO_JOIN_RAIDS}`, () => this.load());
14 | this.removeRaidListener = null;
15 | }
16 |
17 | load() {
18 | const autoJoin = settings.get(SettingIds.AUTO_JOIN_RAIDS);
19 |
20 | if (autoJoin && this.removeRaidListener != null) {
21 | this.removeRaidListener();
22 | this.removeRaidListener = null;
23 | } else if (!autoJoin && this.removeRaidListener == null) {
24 | this.removeRaidListener = domObserver.on(RAID_BANNER_SELECTOR, () => this.leaveRaid());
25 | }
26 | }
27 |
28 | leaveRaid() {
29 | const leaveButton = document.querySelector(RAID_LEAVE_BUTTON_SELECTOR);
30 | if (
31 | leaveButton == null ||
32 | ['raid-cancel-button', 'raid-now-button'].includes(leaveButton.getAttribute('data-test-selector'))
33 | ) {
34 | return;
35 | }
36 |
37 | leaveButton.click();
38 | }
39 | }
40 |
41 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new AutoJoinRaidsModule()]);
42 |
--------------------------------------------------------------------------------
/src/modules/auto_mod_view/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds} from '../../constants.js';
2 | import domObserver from '../../observers/dom.js';
3 | import settings from '../../settings.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import watcher from '../../watcher.js';
6 |
7 | const MOD_VIEW_BUTTON_SELECTOR = '[data-test-selector="mod-view-link"]';
8 | const noReload = window.location.search.includes('no-reload');
9 | const referringPath = document.referrer ? new URL(document.referrer).pathname : null;
10 |
11 | let modViewButtonListener;
12 |
13 | class AutoModViewModule {
14 | constructor() {
15 | watcher.on('load.chat', () => this.load());
16 | settings.on(`changed.${SettingIds.AUTO_MOD_VIEW}`, () => this.load());
17 | }
18 |
19 | load() {
20 | const autoModView = settings.get(SettingIds.AUTO_MOD_VIEW);
21 |
22 | if (!autoModView && modViewButtonListener != null) {
23 | modViewButtonListener();
24 | modViewButtonListener = undefined;
25 | } else if (autoModView && modViewButtonListener == null) {
26 | modViewButtonListener = domObserver.on(MOD_VIEW_BUTTON_SELECTOR, (node, isConnected) => {
27 | if (
28 | !isConnected ||
29 | noReload ||
30 | referringPath?.startsWith('/moderator/') ||
31 | window.location.pathname.startsWith('/moderator/')
32 | ) {
33 | return;
34 | }
35 | node.click();
36 | });
37 | }
38 | }
39 | }
40 |
41 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new AutoModViewModule()]);
42 |
--------------------------------------------------------------------------------
/src/modules/auto_theater_mode/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 | import twitch from '../../utils/twitch.js';
5 | import watcher from '../../watcher.js';
6 |
7 | const TWITCH_THEATER_MODE_CHANGED_DISPATCH_TYPE = 'core.ui.THEATRE_ENABLED';
8 |
9 | class AutoTheaterModeModule {
10 | constructor() {
11 | watcher.on('load.player', () => this.load());
12 | }
13 |
14 | load(reload = true) {
15 | if (settings.get(SettingIds.AUTO_THEATRE_MODE) === false) return;
16 |
17 | const connectStore = twitch.getConnectStore();
18 | if (!connectStore || document.querySelector('.channel-root.channel-root--live.channel-root--watch') == null) return;
19 |
20 | connectStore.dispatch({
21 | type: TWITCH_THEATER_MODE_CHANGED_DISPATCH_TYPE,
22 | });
23 |
24 | // reload once in case the player was reloaded somehow
25 | if (reload) {
26 | setTimeout(() => this.load(false), 1000);
27 | }
28 | }
29 | }
30 |
31 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new AutoTheaterModeModule()]);
32 |
--------------------------------------------------------------------------------
/src/modules/channel_points/index.js:
--------------------------------------------------------------------------------
1 | import {SettingIds, ChannelPointsFlags, PlatformTypes} from '../../constants.js';
2 | import domObserver from '../../observers/dom.js';
3 | import settings from '../../settings.js';
4 | import {hasFlag} from '../../utils/flags.js';
5 | import {loadModuleForPlatforms} from '../../utils/modules.js';
6 |
7 | const CLAIM_BUTTON_SELECTOR = '.claimable-bonus__icon';
8 |
9 | let removeChannelPointsListener;
10 |
11 | class ChannelPoints {
12 | constructor() {
13 | this.loadAutoClaimBonusChannelPoints();
14 | this.loadHideChannelPoints();
15 |
16 | settings.on(`changed.${SettingIds.CHANNEL_POINTS}`, () => {
17 | this.loadAutoClaimBonusChannelPoints();
18 | this.loadHideChannelPoints();
19 | });
20 | }
21 |
22 | loadAutoClaimBonusChannelPoints() {
23 | if (hasFlag(settings.get(SettingIds.CHANNEL_POINTS), ChannelPointsFlags.AUTO_CLAIM)) {
24 | if (removeChannelPointsListener) return;
25 |
26 | removeChannelPointsListener = domObserver.on(CLAIM_BUTTON_SELECTOR, (node, isConnected) => {
27 | if (!isConnected || node.className.includes('ScCoreButtonDestructive')) return;
28 |
29 | node.click();
30 | });
31 |
32 | return;
33 | }
34 |
35 | if (!removeChannelPointsListener) return;
36 |
37 | removeChannelPointsListener();
38 | removeChannelPointsListener = undefined;
39 | }
40 |
41 | loadHideChannelPoints() {
42 | document.body.classList.toggle(
43 | 'bttv-hide-channel-points',
44 | !hasFlag(settings.get(SettingIds.CHANNEL_POINTS), ChannelPointsFlags.CHANNEL_POINTS)
45 | );
46 | }
47 | }
48 |
49 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new ChannelPoints()]);
50 |
--------------------------------------------------------------------------------
/src/modules/channel_points/style.css:
--------------------------------------------------------------------------------
1 | .bttv-hide-channel-points {
2 | div[data-test-selector='community-points-summary'] {
3 | display: none !important;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/modules/chat_admin_messages/index.js:
--------------------------------------------------------------------------------
1 | import sendChatMessage from '../../utils/send-chat-message.js';
2 | import watcher from '../../watcher.js';
3 |
4 | watcher.on('chat.send_admin_message', (message) => sendChatMessage(message));
5 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/anon-chat.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../../i18n/index.js';
2 | import anonChat from '../../anon_chat/index.js';
3 | import commandStore, {PermissionLevels} from '../store.js';
4 |
5 | commandStore.registerCommand({
6 | name: 'join',
7 | commandArgs: [],
8 | description: formatMessage({defaultMessage: 'Usage: "/join" - Temporarily join a chat (anon chat)'}),
9 | handler: () => anonChat.join(),
10 | permissionLevel: PermissionLevels.VIEWER,
11 | });
12 |
13 | commandStore.registerCommand({
14 | name: 'part',
15 | commandArgs: [],
16 | description: formatMessage({defaultMessage: 'Usage: "/part" - Temporarily leave a chat (anon chat)'}),
17 | handler: () => anonChat.part(),
18 | permissionLevel: PermissionLevels.VIEWER,
19 | });
20 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/b.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../../i18n/index.js';
2 | import twitch from '../../../utils/twitch.js';
3 | import commandStore, {PermissionLevels} from '../store.js';
4 |
5 | commandStore.registerCommand({
6 | name: 'b',
7 | commandArgs: [
8 | {name: 'username', isRequired: true},
9 | {name: 'reason', isRequired: false},
10 | ],
11 | description: formatMessage({defaultMessage: `Usage: "/b '<'login'>' [reason]" - Shortcut for /ban`}),
12 | handler: (username, reason) => twitch.sendChatMessage(`/ban ${username} ${reason}`),
13 | permissionLevel: PermissionLevels.MODERATOR,
14 | });
15 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/chatters.js:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import formatMessage from '../../../i18n/index.js';
3 | import {getCurrentChannel} from '../../../utils/channel.js';
4 | import twitch from '../../../utils/twitch.js';
5 | import commandStore, {PermissionLevels} from '../store.js';
6 |
7 | commandStore.registerCommand({
8 | name: 'chatters',
9 | commandArgs: [],
10 | description: formatMessage({defaultMessage: 'Usage: "/chatters" - Retrieves the number of chatters in the chat'}),
11 | handler: () => {
12 | const channel = getCurrentChannel();
13 | const query = gql`
14 | query BTTVGetChannelChattersCount($name: String!) {
15 | channel(name: $name) {
16 | id
17 | chatters {
18 | count
19 | }
20 | }
21 | }
22 | `;
23 |
24 | twitch
25 | .graphqlQuery(query, {name: channel.name})
26 | .then(
27 | ({
28 | data: {
29 | channel: {
30 | chatters: {count},
31 | },
32 | },
33 | }) => twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Current Chatters: {count}'}, {count}))
34 | )
35 | .catch(() => twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Could not fetch chatter count.'})));
36 | },
37 | permissionLevel: PermissionLevels.VIEWER,
38 | });
39 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/clip.js:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import formatMessage from '../../../i18n/index.js';
3 | import {getCurrentChannel} from '../../../utils/channel.js';
4 | import twitch from '../../../utils/twitch.js';
5 | import commandStore, {PermissionLevels} from '../store.js';
6 |
7 | const createClipMutation = gql`
8 | mutation BTTVCreateClip($input: CreateClipInput!) {
9 | createClip(input: $input) {
10 | clip {
11 | url
12 | slug
13 | }
14 | }
15 | }
16 | `;
17 |
18 | const renameClipMutation = gql`
19 | mutation BTTVRenameClip($input: UpdateClipInput!) {
20 | updateClip(input: $input) {
21 | clip {
22 | slug
23 | }
24 | }
25 | }
26 | `;
27 |
28 | commandStore.registerCommand({
29 | name: 'clip',
30 | commandArgs: [{name: 'title', required: false}],
31 | description: formatMessage({defaultMessage: 'Usage: "/clip [title]" - Create a clip of the current stream'}),
32 | handler: async (title) => {
33 | const channel = getCurrentChannel();
34 | const currentPlayer = twitch.getCurrentPlayer();
35 |
36 | const startOffset = currentPlayer?.state?.startOffset;
37 | const broadcastId = currentPlayer?.state?.sessionData?.['BROADCAST-ID'];
38 |
39 | if (channel == null || broadcastId == null || startOffset == null) {
40 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Error: Unable to create clip, is the stream live?'}));
41 | return;
42 | }
43 |
44 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Creating clip, please wait...'}));
45 |
46 | try {
47 | const {data} = await twitch.graphqlMutation(createClipMutation, {
48 | input: {
49 | broadcastID: broadcastId,
50 | broadcasterID: channel.id,
51 | offsetSeconds: startOffset,
52 | },
53 | });
54 |
55 | if (title != null && title.length > 0) {
56 | await twitch.graphqlMutation(renameClipMutation, {input: {slug: data.createClip.clip.slug, title}});
57 | }
58 |
59 | twitch.sendChatMessage(data.createClip.clip.url);
60 | } catch (error) {
61 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Error: Unable to create clip.'}));
62 | }
63 | },
64 | permissionLevel: PermissionLevels.VIEWER,
65 | });
66 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/followed.js:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import {DateTime} from 'luxon';
3 | import formatMessage from '../../../i18n/index.js';
4 | import {getCurrentChannel} from '../../../utils/channel.js';
5 | import twitch from '../../../utils/twitch.js';
6 | import {getCurrentUser} from '../../../utils/user.js';
7 | import commandStore, {PermissionLevels} from '../store.js';
8 |
9 | commandStore.registerCommand({
10 | name: 'followed',
11 | commandArgs: [],
12 | description: formatMessage({
13 | defaultMessage: 'Usage: "/followed" - Tells you for how long you have been following a channel',
14 | }),
15 | handler: () => {
16 | const currentUser = getCurrentUser();
17 | const channel = getCurrentChannel();
18 | if (currentUser == null) {
19 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'You are not logged in.'}));
20 | return;
21 | }
22 | const query = gql`
23 | query BTTVGetFollowingChannel($userId: ID!) {
24 | user(id: $userId) {
25 | id
26 | self {
27 | follower {
28 | followedAt
29 | }
30 | }
31 | }
32 | }
33 | `;
34 |
35 | twitch
36 | .graphqlQuery(query, {userId: channel.id})
37 | .then(
38 | ({
39 | data: {
40 | user: {
41 | self: {
42 | follower: {followedAt},
43 | },
44 | },
45 | },
46 | }) => {
47 | twitch.sendChatAdminMessage(
48 | formatMessage(
49 | {
50 | defaultMessage: 'You followed {name} {duration} ({date, date, medium})',
51 | },
52 | {
53 | name: channel.displayName,
54 | duration: DateTime.now()
55 | .minus(DateTime.now().diff(DateTime.fromISO(followedAt)))
56 | .toRelative(),
57 | date: new Date(followedAt),
58 | }
59 | )
60 | );
61 | }
62 | )
63 | .catch(() =>
64 | twitch.sendChatAdminMessage(
65 | formatMessage({defaultMessage: 'You do not follow {name}.'}, {name: channel.displayName})
66 | )
67 | );
68 | },
69 | permissionLevel: PermissionLevels.VIEWER,
70 | });
71 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/follows.js:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import formatMessage from '../../../i18n/index.js';
3 | import {getCurrentChannel} from '../../../utils/channel.js';
4 | import twitch from '../../../utils/twitch.js';
5 | import commandStore, {PermissionLevels} from '../store.js';
6 |
7 | commandStore.registerCommand({
8 | name: 'follows',
9 | commandArgs: [],
10 | description: formatMessage({defaultMessage: 'Usage: "/follows" - Retrieves the number of followers for the channel'}),
11 | handler: () => {
12 | const channel = getCurrentChannel();
13 | const query = gql`
14 | query BTTVGetChannelFollowerCount($userId: ID!) {
15 | user(id: $userId) {
16 | id
17 | followers(first: 1) {
18 | totalCount
19 | }
20 | }
21 | }
22 | `;
23 |
24 | twitch
25 | .graphqlQuery(query, {userId: channel.id})
26 | .then(
27 | ({
28 | data: {
29 | user: {
30 | followers: {totalCount},
31 | },
32 | },
33 | }) =>
34 | twitch.sendChatAdminMessage(
35 | formatMessage({defaultMessage: 'Current Followers: {totalCount, number}'}, {totalCount})
36 | )
37 | )
38 | .catch(() => twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Could not fetch follower count.'})));
39 | },
40 | permissionLevel: PermissionLevels.VIEWER,
41 | });
42 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/fun.module.css:
--------------------------------------------------------------------------------
1 | .barrelRoll {
2 | transition: transform 2s;
3 | transform: rotate(360deg);
4 | }
5 |
6 | .party {
7 | animation: party 1s infinite;
8 | }
9 |
10 | @keyframes party {
11 | 0% {
12 | filter: sepia(0.5) hue-rotate(0deg) saturate(2.5);
13 | }
14 | 100% {
15 | filter: sepia(0.5) hue-rotate(360deg) saturate(2.5);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/localascii.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../../i18n/index.js';
2 | import twitch from '../../../utils/twitch.js';
3 | import chat from '../../chat/index.js';
4 | import commandStore, {PermissionLevels} from '../store.js';
5 |
6 | commandStore.registerCommand({
7 | name: 'localascii',
8 | commandArgs: [],
9 | description: formatMessage({
10 | defaultMessage: 'Usage: "/localascii" - Turns on local ascii-only mode (only your chat is ascii-only mode)',
11 | }),
12 | handler: () => {
13 | chat.asciiOnly(true);
14 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Local ascii-only mode enabled.'}));
15 | },
16 | permissionLevel: PermissionLevels.VIEWER,
17 | });
18 |
19 | commandStore.registerCommand({
20 | name: 'localasciioff',
21 | commandArgs: [],
22 | description: formatMessage({
23 | defaultMessage: 'Usage: "/localasciioff" - Turns off local ascii-only mode',
24 | }),
25 | handler: () => {
26 | chat.asciiOnly(false);
27 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Local ascii-only mode disabled.'}));
28 | },
29 | permissionLevel: PermissionLevels.VIEWER,
30 | });
31 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/localmod.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../../i18n/index.js';
2 | import twitch from '../../../utils/twitch.js';
3 | import chat from '../../chat/index.js';
4 | import commandStore, {PermissionLevels} from '../store.js';
5 |
6 | commandStore.registerCommand({
7 | name: 'localmod',
8 | commandArgs: [],
9 | description: formatMessage({
10 | defaultMessage: 'Usage: "/localmod" - Turns on local mod-only mode (only your chat is mod-only mode)',
11 | }),
12 | handler: () => {
13 | chat.modsOnly(true);
14 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Local mods-only mode enabled.'}));
15 | },
16 | permissionLevel: PermissionLevels.VIEWER,
17 | });
18 |
19 | commandStore.registerCommand({
20 | name: 'localmodoff',
21 | commandArgs: [],
22 | description: formatMessage({defaultMessage: 'Usage: "/localmodoff" - Turns off local mod-only mode'}),
23 | handler: () => {
24 | chat.modsOnly(false);
25 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Local mods-only mode disabled.'}));
26 | },
27 | permissionLevel: PermissionLevels.VIEWER,
28 | });
29 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/localsub.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../../i18n/index.js';
2 | import twitch from '../../../utils/twitch.js';
3 | import chat from '../../chat/index.js';
4 | import commandStore, {PermissionLevels} from '../store.js';
5 |
6 | commandStore.registerCommand({
7 | name: 'localsub',
8 | commandArgs: [],
9 | description: formatMessage({
10 | defaultMessage: 'Usage: "/localsub" - Turns on local sub-only mode (only your chat is sub-only mode)',
11 | }),
12 | handler: () => {
13 | chat.subsOnly(true);
14 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Local sub-only mode enabled.'}));
15 | },
16 | permissionLevel: PermissionLevels.VIEWER,
17 | });
18 |
19 | commandStore.registerCommand({
20 | name: 'localsuboff',
21 | commandArgs: [],
22 | description: formatMessage({defaultMessage: 'Usage: "/localsuboff" - Turns off local sub-only mode'}),
23 | handler: () => {
24 | chat.subsOnly(false);
25 | twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Local sub-only mode disabled.'}));
26 | },
27 | permissionLevel: PermissionLevels.VIEWER,
28 | });
29 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/purge.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../../i18n/index.js';
2 | import twitch from '../../../utils/twitch.js';
3 | import commandStore, {PermissionLevels} from '../store.js';
4 |
5 | const command = {
6 | commandArgs: [
7 | {name: 'username', isRequired: true},
8 | {name: 'reason', isRequired: false},
9 | ],
10 | description: formatMessage({defaultMessage: `Usage: "/purge '<'login'>' [reason]" - Purges a user's chat`}),
11 | handler: async (username, reason) => twitch.sendChatMessage(`/timeout ${username} 1 ${reason}`),
12 | permissionLevel: PermissionLevels.MODERATOR,
13 | };
14 |
15 | commandStore.registerCommand({name: 'purge', ...command});
16 | commandStore.registerCommand({name: 'p', ...command});
17 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/sub.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../../i18n/index.js';
2 | import twitch from '../../../utils/twitch.js';
3 | import commandStore, {PermissionLevels} from '../store.js';
4 |
5 | commandStore.registerCommand({
6 | name: 'sub',
7 | commandArgs: [],
8 | description: formatMessage({defaultMessage: 'Usage: "/sub" - Shortcut for /subscribers'}),
9 | handler: () => twitch.sendChatMessage(`/subscribers`),
10 | permissionLevel: PermissionLevels.MODERATOR,
11 | });
12 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/suboff.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../../i18n/index.js';
2 | import twitch from '../../../utils/twitch.js';
3 | import commandStore, {PermissionLevels} from '../store.js';
4 |
5 | commandStore.registerCommand({
6 | name: 'suboff',
7 | commandArgs: [],
8 | description: formatMessage({defaultMessage: 'Usage: "/suboff" - Shortcut for /subscribersoff'}),
9 | handler: () => twitch.sendChatMessage(`/subscribersoff`),
10 | permissionLevel: PermissionLevels.MODERATOR,
11 | });
12 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/commands/viewers.js:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import formatMessage from '../../../i18n/index.js';
3 | import {getCurrentChannel} from '../../../utils/channel.js';
4 | import twitch from '../../../utils/twitch.js';
5 | import commandStore, {PermissionLevels} from '../store.js';
6 |
7 | commandStore.registerCommand({
8 | name: 'viewers',
9 | commandArgs: [],
10 | description: formatMessage({
11 | defaultMessage: 'Usage: "/viewers" - Retrieves the number of viewers watching the channel',
12 | }),
13 | handler: () => {
14 | const channel = getCurrentChannel();
15 | const query = gql`
16 | query BTTVGetChannelStreamViewersCount($userId: ID!) {
17 | user(id: $userId) {
18 | id
19 | stream {
20 | id
21 | viewersCount
22 | }
23 | }
24 | }
25 | `;
26 |
27 | twitch
28 | .graphqlQuery(query, {userId: channel.id})
29 | .then(
30 | ({
31 | data: {
32 | user: {
33 | stream: {viewersCount},
34 | },
35 | },
36 | }) =>
37 | twitch.sendChatAdminMessage(
38 | formatMessage({defaultMessage: 'Current Viewers: {viewersCount, number}'}, {viewersCount})
39 | )
40 | )
41 | .catch(() => twitch.sendChatAdminMessage(formatMessage({defaultMessage: 'Could not fetch stream.'})));
42 | },
43 | permissionLevel: PermissionLevels.VIEWER,
44 | });
45 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../../constants.js';
2 | import {loadModuleForPlatforms} from '../../utils/modules.js';
3 |
4 | export default loadModuleForPlatforms([
5 | PlatformTypes.TWITCH,
6 | async () => {
7 | // eslint-disable-next-line import/no-unresolved
8 | await import('./commands/*.js');
9 | },
10 | ]);
11 |
--------------------------------------------------------------------------------
/src/modules/chat_commands/store.js:
--------------------------------------------------------------------------------
1 | import twitch from '../../utils/twitch.js';
2 | import watcher from '../../watcher.js';
3 |
4 | export const PermissionLevels = {
5 | VIEWER: 0,
6 | VIP: 1,
7 | MODERATOR: 2,
8 | BROADCASTER: 3,
9 | };
10 |
11 | class CommandStore {
12 | constructor() {
13 | this.commands = [];
14 | watcher.on('load.chat', () => this.loadCommands());
15 | }
16 |
17 | loadCommands() {
18 | const twitchCommandStore = twitch.getChatCommandStore();
19 | if (twitchCommandStore == null || this.commands.length === 0) {
20 | return;
21 | }
22 | for (const command of this.commands) {
23 | twitchCommandStore.addCommand(command);
24 | }
25 | }
26 |
27 | registerCommand(command) {
28 | this.commands.push(command);
29 | const twitchCommandStore = twitch.getChatCommandStore();
30 | if (twitchCommandStore == null) {
31 | return;
32 | }
33 | twitchCommandStore.addCommand(command);
34 | }
35 | }
36 |
37 | export default new CommandStore();
38 |
--------------------------------------------------------------------------------
/src/modules/chat_custom_timeouts/style.css:
--------------------------------------------------------------------------------
1 | #bttv-custom-timeout-contain {
2 | position: fixed;
3 | top: 0px;
4 | left: 0px;
5 | width: 83px;
6 | height: 224px;
7 | overflow: hidden;
8 |
9 | background: rgba(90, 90, 90, 0.4);
10 |
11 | z-index: 99999;
12 |
13 | .text,
14 | .cursor {
15 | position: absolute;
16 | left: 0px;
17 | top: 100px;
18 | width: 80px;
19 | height: 1px;
20 | background: #f00;
21 | }
22 |
23 | .text {
24 | top: 85px;
25 | height: 30px;
26 | line-height: 30px;
27 | text-align: center;
28 | background: rgba(0, 0, 0, 0.6);
29 | cursor: default;
30 | display: none;
31 | color: #d3d3d3;
32 | }
33 |
34 | &:hover .text {
35 | display: block;
36 | }
37 |
38 | &:hover .cursor {
39 | background: #0f0;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/modules/chat_deleted_messages/style.css:
--------------------------------------------------------------------------------
1 | .bttv-chat-line-deleted {
2 | opacity: 0.5;
3 | }
4 |
--------------------------------------------------------------------------------
/src/modules/chat_direction/style.css:
--------------------------------------------------------------------------------
1 | .bttv-chat-direction-reversed {
2 | .chat-list .chat-scrollable-area__message-container[data-test-selector='chat-scrollable-area__message-container'],
3 | .chat-list--default
4 | .chat-scrollable-area__message-container[data-test-selector='chat-scrollable-area__message-container'],
5 | .chat-list--other
6 | .chat-scrollable-area__message-container[data-test-selector='chat-scrollable-area__message-container'] {
7 | display: flex;
8 | flex-direction: column-reverse;
9 | }
10 |
11 | /*
12 | Vods don't use the normal chat component but a static UL which
13 | lists the chat messages. Therefore it requires different handling
14 | */
15 | .video-chat__message-list-wrapper > div,
16 | .video-chat__message-list-wrapper > div > ul {
17 | flex-direction: column-reverse !important;
18 | justify-content: flex-end !important;
19 | }
20 |
21 | .video-chat__message-list-wrapper > div > ul {
22 | flex-wrap: nowrap !important;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/modules/chat_highlight_blacklist_keywords/style.css:
--------------------------------------------------------------------------------
1 | #bttv-pin-container {
2 | position: absolute;
3 | top: 0px;
4 | right: 0px;
5 | width: 100%;
6 |
7 | box-shadow: 0px 8px 8px -5px #000;
8 |
9 | z-index: 99998;
10 | }
11 |
12 | #bttv-pinned-highlight {
13 | padding: 2px 10px;
14 |
15 | font-weight: bold;
16 | font-size: 0.8572em;
17 | color: #fff;
18 |
19 | background-color: rgba(255, 0, 0, 0.5);
20 |
21 | span {
22 | padding-right: 5px;
23 | }
24 |
25 | .message {
26 | word-break: break-word;
27 | }
28 |
29 | .close {
30 | cursor: pointer;
31 | fill: #fff;
32 | }
33 | }
34 |
35 | .chat-line__message.bttv-highlighted,
36 | .user-notice-line.bttv-highlighted,
37 | .vod-message.bttv-highlighted {
38 | background-color: rgba(255, 0, 0, 0.3);
39 | }
40 |
--------------------------------------------------------------------------------
/src/modules/chat_left_side/index.js:
--------------------------------------------------------------------------------
1 | import {ChatLayoutTypes, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 |
5 | class ChatLeftSide {
6 | constructor() {
7 | settings.on(`changed.${SettingIds.CHAT_LAYOUT}`, () => this.toggleLeftSideChat());
8 | this.toggleLeftSideChat();
9 | }
10 |
11 | toggleLeftSideChat() {
12 | document.body.classList.toggle('bttv-swap-chat', settings.get(SettingIds.CHAT_LAYOUT) === ChatLayoutTypes.LEFT);
13 | }
14 | }
15 |
16 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new ChatLeftSide()]);
17 |
--------------------------------------------------------------------------------
/src/modules/chat_moderator_cards/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../../constants.js';
2 | import {loadModuleForPlatforms} from '../../utils/modules.js';
3 | import twitch from '../../utils/twitch.js';
4 | import watcher from '../../watcher.js';
5 | import ModeratorCard from './moderator-card.js';
6 |
7 | let openModeratorCard;
8 |
9 | class ChatModeratorCardsModule {
10 | constructor() {
11 | watcher.on('chat.moderator_card.open', (element) => this.onOpen(element));
12 | watcher.on('chat.moderator_card.close', () => this.onClose());
13 | document.body.addEventListener('keydown', (e) => this.onKeyDown(e));
14 | }
15 |
16 | onOpen(element) {
17 | const targetUser = twitch.getChatModeratorCardUser(element);
18 | if (!targetUser) return;
19 |
20 | if (openModeratorCard && openModeratorCard.user.id === targetUser.id) {
21 | return;
22 | }
23 |
24 | this.onClose();
25 |
26 | let isOwner = false;
27 | let isModerator = false;
28 | const userMessages = twitch.getChatMessages(targetUser.id);
29 | if (userMessages.length) {
30 | const {message} = userMessages[userMessages.length - 1];
31 | isOwner = twitch.getUserIsOwnerFromTagsBadges(message.badges);
32 | isModerator = twitch.getUserIsModeratorFromTagsBadges(message.badges);
33 | }
34 |
35 | openModeratorCard = new ModeratorCard(
36 | element,
37 | {
38 | id: targetUser.id,
39 | name: targetUser.login,
40 | isOwner,
41 | isModerator,
42 | },
43 | userMessages,
44 | () => this.onClose(false)
45 | );
46 | openModeratorCard.render();
47 | }
48 |
49 | onClose(cleanup = true) {
50 | if (cleanup && openModeratorCard) {
51 | openModeratorCard.cleanup();
52 | }
53 | openModeratorCard = null;
54 | }
55 |
56 | onKeyDown(e) {
57 | if (!openModeratorCard) return;
58 | openModeratorCard.onKeyDown(e);
59 | }
60 | }
61 |
62 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new ChatModeratorCardsModule()]);
63 |
--------------------------------------------------------------------------------
/src/modules/chat_nicknames/index.js:
--------------------------------------------------------------------------------
1 | import formatMessage from '../../i18n/index.js';
2 | import storage from '../../storage.js';
3 |
4 | let nicknames;
5 |
6 | class ChatNicknamesModule {
7 | constructor() {
8 | nicknames = storage.get('nicknames') || {};
9 | }
10 |
11 | set(name) {
12 | let nickname = prompt(
13 | formatMessage({defaultMessage: 'Enter the updated nickname for {name} (Leave blank to reset)'}, {name}),
14 | nicknames[name] || name
15 | );
16 | if (nickname === null) return null;
17 |
18 | nickname = nickname.trim();
19 | nicknames[name] = nickname;
20 |
21 | storage.set('nicknames', nicknames);
22 |
23 | return nickname;
24 | }
25 |
26 | get(name) {
27 | return nicknames[name] || null;
28 | }
29 | }
30 |
31 | export default new ChatNicknamesModule();
32 |
--------------------------------------------------------------------------------
/src/modules/chat_replies/index.js:
--------------------------------------------------------------------------------
1 | import {ChatFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 |
6 | class ChatRepliesModule {
7 | constructor() {
8 | settings.on(`changed.${SettingIds.CHAT}`, this.toggleChatReplies);
9 | this.toggleChatReplies();
10 | }
11 |
12 | toggleChatReplies() {
13 | document.body.classList.toggle(
14 | 'bttv-hide-chat-replies',
15 | !hasFlag(settings.get(SettingIds.CHAT), ChatFlags.CHAT_REPLIES)
16 | );
17 | }
18 | }
19 |
20 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new ChatRepliesModule()]);
21 |
--------------------------------------------------------------------------------
/src/modules/chat_replies/style.css:
--------------------------------------------------------------------------------
1 | .bttv-hide-chat-replies .chat-line__reply-icon {
2 | display: none !important;
3 | }
4 |
--------------------------------------------------------------------------------
/src/modules/chat_settings/style.css:
--------------------------------------------------------------------------------
1 | .chat-settings {
2 | overflow-y: scroll;
3 | max-height: calc(100vh - 200px);
4 |
5 | button[data-a-target='clear-chat-button'] {
6 | display: none;
7 | }
8 | }
9 |
10 | .bttv-chat-settings {
11 | border-top: var(--border-width-default) solid var(--color-border-base) !important;
12 | margin-top: 2rem !important;
13 | padding-top: 2rem !important;
14 |
15 | .settingHeader {
16 | margin-top: 0.5rem !important;
17 | margin-bottom: 0.5rem !important;
18 | padding-left: 0.5rem !important;
19 | padding-right: 0.5rem !important;
20 |
21 | p {
22 | font-size: var(--font-size-6) !important;
23 | color: var(--color-text-alt-2) !important;
24 | text-transform: uppercase !important;
25 | font-weight: 600 !important;
26 | }
27 | }
28 |
29 | .settingRow {
30 | position: relative !important;
31 | width: 100% !important;
32 | }
33 |
34 | .settingButton {
35 | color: inherit;
36 | border-radius: var(--border-radius-medium);
37 | display: block;
38 | width: 100%;
39 | padding: 0.5rem !important;
40 |
41 | &:hover {
42 | color: inherit;
43 | text-decoration: none;
44 | background-color: var(--color-background-interactable-hover);
45 | }
46 | }
47 |
48 | .settingButtonMultipleLabels {
49 | display: flex !important;
50 | justify-content: space-between;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/modules/clips/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../../constants.js';
2 | import colors from '../../utils/colors.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 | import watcher from '../../watcher.js';
5 | import chat from '../chat/index.js';
6 |
7 | const CHAT_MESSAGE_SELECTOR = 'span[data-a-target="chat-message-text"]';
8 | const CHAT_USERNAME_SELECTOR = 'a[href$="/clips"] span';
9 | const SCROLL_INDICATOR_SELECTOR = '.clips-chat .clips-chat__content button';
10 | const SCROLL_CONTAINER_SELECTOR = '.clips-chat .simplebar-scroll-content';
11 |
12 | function scrollOnEmoteLoad(el) {
13 | const indicator = document.querySelector(SCROLL_INDICATOR_SELECTOR) != null;
14 | if (indicator) return;
15 |
16 | el.querySelectorAll('img').forEach((image) => {
17 | image.addEventListener('load', () => {
18 | const scrollContainer = document.querySelector(SCROLL_CONTAINER_SELECTOR);
19 | if (scrollContainer == null) return;
20 | scrollContainer.scrollTop = scrollContainer.scrollHeight;
21 | });
22 | });
23 | }
24 |
25 | class ClipsModule {
26 | constructor() {
27 | watcher.on('clips.message', (el) => this.parseMessage(el));
28 | }
29 |
30 | parseMessage(element) {
31 | const from = element.querySelector(CHAT_USERNAME_SELECTOR);
32 | const colorSpan = from?.closest('a')?.closest('span');
33 |
34 | if (colorSpan != null && colorSpan.style.color) {
35 | const oldColor = colors.getHex(colors.getRgb(colorSpan.style.color));
36 | colorSpan.style.color = chat.calculateColor(oldColor);
37 | }
38 |
39 | const mockUser = {name: from.textContent};
40 | chat.messageReplacer(element.querySelector(CHAT_MESSAGE_SELECTOR), mockUser);
41 |
42 | scrollOnEmoteLoad(element);
43 | }
44 | }
45 |
46 | export default loadModuleForPlatforms([PlatformTypes.TWITCH_CLIPS, () => new ClipsModule()]);
47 |
--------------------------------------------------------------------------------
/src/modules/clips/style.css:
--------------------------------------------------------------------------------
1 | .clip-chat-badge__img {
2 | border-radius: 3px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/modules/conversations/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 | import watcher from '../../watcher.js';
5 | import chat from '../chat/index.js';
6 |
7 | const CHAT_USER_SELECTOR = '.thread-message__message--user-name';
8 | const CHAT_MESSAGE_SELECTOR = 'span[data-a-target="chat-message-text"]';
9 | const SCROLL_CONTAINER_SELECTOR = '.simplebar-scroll-content';
10 |
11 | function scrollOnEmoteLoad(el) {
12 | el.querySelectorAll('img').forEach((image) => {
13 | image.addEventListener('load', () => {
14 | const scrollContainer = image.closest(SCROLL_CONTAINER_SELECTOR);
15 | if (scrollContainer == null) return;
16 | scrollContainer.scrollTop = scrollContainer.scrollHeight;
17 | });
18 | });
19 | }
20 |
21 | class ConversationsModule {
22 | constructor() {
23 | settings.on(`changed.${SettingIds.WHISPERS}`, () => this.toggleHide());
24 | watcher.on('load', () => this.toggleHide());
25 | watcher.on('conversation.message', (threadId, el, message) => this.parseMessage(el, message));
26 | }
27 |
28 | toggleHide() {
29 | document.body.classList.toggle('bttv-hide-conversations', !settings.get(SettingIds.WHISPERS));
30 | }
31 |
32 | parseMessage(element, message) {
33 | if (!message.from) return;
34 | const {id, login: name, displayName, chatColor: color} = message.from;
35 | const mockUser = {
36 | id,
37 | name,
38 | displayName,
39 | color,
40 | };
41 |
42 | const from = element.querySelector(CHAT_USER_SELECTOR);
43 | if (from != null) {
44 | from.style.color = chat.calculateColor(mockUser.color);
45 | }
46 |
47 | chat.messageReplacer(element.querySelectorAll(CHAT_MESSAGE_SELECTOR), mockUser);
48 |
49 | scrollOnEmoteLoad(element);
50 | }
51 | }
52 |
53 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new ConversationsModule()]);
54 |
--------------------------------------------------------------------------------
/src/modules/conversations/style.css:
--------------------------------------------------------------------------------
1 | .bttv-hide-conversations {
2 | button[data-a-target='whisper-box-button'] {
3 | display: none !important;
4 | }
5 |
6 | .whispers-open-threads {
7 | display: none !important;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/modules/directory_live_following/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 | import watcher from '../../watcher.js';
5 |
6 | class DirectoryLiveFollowingModule {
7 | constructor() {
8 | watcher.on('load.directory.following', () => this.load());
9 | }
10 |
11 | load(retries = 0) {
12 | if (settings.get(SettingIds.SHOW_DIRECTORY_LIVE_TAB) === false || retries > 10) return false;
13 | const button = document.querySelector('a[href="/directory/following/live"]');
14 | if (button == null) {
15 | return setTimeout(() => this.load(retries + 1), 250);
16 | }
17 | button.click();
18 | return true;
19 | }
20 | }
21 |
22 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new DirectoryLiveFollowingModule()]);
23 |
--------------------------------------------------------------------------------
/src/modules/disable_badges/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds, UsernameFlags} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import watcher from '../../watcher.js';
6 |
7 | class DisableBadgesModule {
8 | constructor() {
9 | settings.on(`changed.${SettingIds.USERNAMES}`, () => this.load());
10 | watcher.on('load.chat', () => this.load());
11 | }
12 |
13 | load() {
14 | const body = document.getElementsByTagName('body')[0];
15 | if (body == null) {
16 | return;
17 | }
18 |
19 | const shouldDisableBadges = !hasFlag(settings.get(SettingIds.USERNAMES), UsernameFlags.BADGES);
20 | if (shouldDisableBadges) {
21 | body.classList.add('bttv-disable-badges');
22 | } else {
23 | body.classList.remove('bttv-disable-badges');
24 | }
25 | }
26 | }
27 |
28 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new DisableBadgesModule()]);
29 |
--------------------------------------------------------------------------------
/src/modules/disable_badges/style.css:
--------------------------------------------------------------------------------
1 | .bttv-disable-badges .chat-scrollable-area__message-container {
2 | button[data-a-target='chat-badge'] {
3 | display: none;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/modules/disable_channel_points_message_highlights/index.js:
--------------------------------------------------------------------------------
1 | import {ChannelPointsFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import watcher from '../../watcher.js';
6 |
7 | class DisableChannelPointsMessageHighlightsModule {
8 | constructor() {
9 | settings.on(`changed.${SettingIds.CHANNEL_POINTS}`, () => this.load());
10 | watcher.on('load.chat', () => this.load());
11 | }
12 |
13 | load() {
14 | document.body.classList.toggle(
15 | 'bttv-disable-channel-points-message-highlights',
16 | !hasFlag(settings.get(SettingIds.CHANNEL_POINTS), ChannelPointsFlags.MESSAGE_HIGHLIGHTS)
17 | );
18 | }
19 | }
20 |
21 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new DisableChannelPointsMessageHighlightsModule()]);
22 |
--------------------------------------------------------------------------------
/src/modules/disable_channel_points_message_highlights/style.css:
--------------------------------------------------------------------------------
1 | .bttv-disable-channel-points-message-highlights
2 | div[data-test-selector='user-notice-line']:has(> div > .channel-points-reward-line__icon),
3 | .bttv-disable-channel-points-message-highlights
4 | div[data-test-selector='user-notice-line']
5 | .chat-line__message-body--highlighted {
6 | background: initial !important;
7 | border: initial !important;
8 | }
9 |
--------------------------------------------------------------------------------
/src/modules/disable_homepage_autoplay/index.js:
--------------------------------------------------------------------------------
1 | import {AutoPlayFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import twitch from '../../utils/twitch.js';
6 | import watcher from '../../watcher.js';
7 |
8 | const FEATURED_VIDEO_SELECTOR = 'div[data-test-selector="featured-item-video"]';
9 |
10 | class DisableHomepageAutoplayModule {
11 | constructor() {
12 | watcher.on('load.homepage', () => this.load());
13 | }
14 |
15 | load() {
16 | if (hasFlag(settings.get(SettingIds.AUTO_PLAY), AutoPlayFlags.FP_VIDEO)) {
17 | return;
18 | }
19 |
20 | if (document.querySelector(FEATURED_VIDEO_SELECTOR) == null) {
21 | return;
22 | }
23 |
24 | const currentPlayer = twitch.getCurrentPlayer();
25 | if (!currentPlayer) {
26 | return;
27 | }
28 |
29 | const prevMuted = currentPlayer.isMuted();
30 |
31 | currentPlayer.setMuted(true);
32 |
33 | const stopAutoplay = () => {
34 | setTimeout(() => {
35 | if (document.querySelector(FEATURED_VIDEO_SELECTOR) == null) {
36 | return;
37 | }
38 |
39 | currentPlayer.pause();
40 | currentPlayer.setMuted(prevMuted);
41 | }, 0);
42 | if (currentPlayer.emitter) {
43 | currentPlayer.emitter.removeListener('Playing', stopAutoplay);
44 | } else {
45 | currentPlayer.removeEventListener('play', stopAutoplay);
46 | }
47 | };
48 |
49 | if (currentPlayer.emitter) {
50 | currentPlayer.pause();
51 | currentPlayer.emitter.on('Playing', stopAutoplay);
52 | } else {
53 | currentPlayer.addEventListener('play', stopAutoplay);
54 | }
55 | }
56 | }
57 |
58 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new DisableHomepageAutoplayModule()]);
59 |
--------------------------------------------------------------------------------
/src/modules/disable_localized_names/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds, UsernameFlags} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import watcher from '../../watcher.js';
6 |
7 | class DisableLocalizedNamesModule {
8 | constructor() {
9 | watcher.on('chat.message', (el) => this.delocalizeName(el));
10 | }
11 |
12 | delocalizeName(el) {
13 | if (hasFlag(settings.get(SettingIds.USERNAMES), UsernameFlags.LOCALIZED)) return;
14 |
15 | const name = el.querySelector('.chat-author__display-name');
16 | const login = el.querySelector('.chat-author__intl-login');
17 | if (login == null) return;
18 |
19 | name.innerText = login.textContent.replace(/[()]/g, '').trim();
20 | login.remove();
21 | }
22 | }
23 |
24 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new DisableLocalizedNamesModule()]);
25 |
--------------------------------------------------------------------------------
/src/modules/disable_name_colors/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds, UsernameFlags} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import watcher from '../../watcher.js';
6 |
7 | class DisableNameColorsModule {
8 | constructor() {
9 | settings.on(`changed.${SettingIds.USERNAMES}`, () => this.load());
10 | watcher.on('load.chat', () => this.load());
11 | }
12 |
13 | load() {
14 | document.body.classList.toggle(
15 | 'bttv-disable-name-colors',
16 | !hasFlag(settings.get(SettingIds.USERNAMES), UsernameFlags.COLORS)
17 | );
18 | }
19 | }
20 |
21 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new DisableNameColorsModule()]);
22 |
--------------------------------------------------------------------------------
/src/modules/disable_name_colors/style.css:
--------------------------------------------------------------------------------
1 | .bttv-disable-name-colors .chat-scrollable-area__message-container {
2 | .chat-line__message,
3 | .chat-author__display-name,
4 | .chat-author__intl-login {
5 | color: inherit !important;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/modules/disable_offline_channel_autoplay/index.js:
--------------------------------------------------------------------------------
1 | import {AutoPlayFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import twitch from '../../utils/twitch.js';
6 | import watcher from '../../watcher.js';
7 |
8 | class DisableOfflineChannelAutoplayModule {
9 | constructor() {
10 | watcher.on('load.player', () => this.load());
11 | }
12 |
13 | load() {
14 | if (hasFlag(settings.get(SettingIds.AUTO_PLAY), AutoPlayFlags.OFFLINE_CHANNEL_VIDEO)) return;
15 | const currentPlayer = twitch.getCurrentPlayer();
16 | if (!currentPlayer || document.querySelector('.video-player[data-a-player-type="channel_home_carousel"]') == null) {
17 | return;
18 | }
19 |
20 | const prevMuted = currentPlayer.isMuted();
21 |
22 | currentPlayer.setMuted(true);
23 |
24 | const stopAutoplay = () => {
25 | setTimeout(() => {
26 | currentPlayer.pause();
27 | currentPlayer.setMuted(prevMuted);
28 | }, 0);
29 | if (currentPlayer.emitter) {
30 | currentPlayer.emitter.removeListener('Playing', stopAutoplay);
31 | } else {
32 | currentPlayer.removeEventListener('play', stopAutoplay);
33 | }
34 | };
35 |
36 | if (currentPlayer.emitter) {
37 | currentPlayer.pause();
38 | currentPlayer.emitter.on('Playing', stopAutoplay);
39 | } else {
40 | currentPlayer.addEventListener('play', stopAutoplay);
41 | }
42 | }
43 | }
44 |
45 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new DisableOfflineChannelAutoplayModule()]);
46 |
--------------------------------------------------------------------------------
/src/modules/doubleclick_mention/index.js:
--------------------------------------------------------------------------------
1 | import {off, on} from 'delegated-events';
2 | import {PlatformTypes} from '../../constants.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 | import twitch from '../../utils/twitch.js';
5 | import watcher from '../../watcher.js';
6 |
7 | const CHAT_ROOM_SELECTOR = '.chat-list,.chat-list--default,.chat-list--other';
8 | const CHAT_LINE_SELECTOR = '.chat-line__message';
9 | const USERNAME_SELECTORS =
10 | '.chat-line__message span.chat-author__display-name, .chat-line__message span[data-a-target="chat-message-mention"]';
11 |
12 | function clearSelection() {
13 | if (document.selection && document.selection.empty) {
14 | document.selection.empty();
15 | } else if (window.getSelection) {
16 | window.getSelection().removeAllRanges();
17 | }
18 | }
19 |
20 | function handleDoubleClick(e) {
21 | if (e.shiftKey || e.ctrlKey) return;
22 |
23 | document.querySelector('button[data-test-selector="close-viewer-card"]')?.click();
24 |
25 | clearSelection();
26 | let user = e.target.innerText ? e.target.innerText.replace('@', '') : '';
27 | const messageObj = twitch.getChatMessageObject(e.target.closest(CHAT_LINE_SELECTOR));
28 | if (messageObj != null && e.target.getAttribute('data-a-target') !== 'chat-message-mention') {
29 | if (messageObj.user.userDisplayName?.toLowerCase() === messageObj.user.userLogin) {
30 | user = messageObj.user.userDisplayName;
31 | } else {
32 | user = messageObj.user.userLogin;
33 | }
34 | }
35 | const chatInputValue = twitch.getChatInputValue();
36 | if (chatInputValue == null) return;
37 | const input = chatInputValue.trim();
38 | const output = input ? `${input} @${user} ` : `@${user}, `;
39 | twitch.setChatInputValue(output, true);
40 | }
41 |
42 | class DoubleClickMentionModule {
43 | constructor() {
44 | watcher.on('load.chat', () => this.load());
45 | }
46 |
47 | load() {
48 | const chatRoom = document.querySelector(CHAT_ROOM_SELECTOR);
49 | if (chatRoom == null) {
50 | return;
51 | }
52 |
53 | off('dblclick', USERNAME_SELECTORS, handleDoubleClick);
54 | on('dblclick', USERNAME_SELECTORS, handleDoubleClick);
55 | }
56 | }
57 |
58 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new DoubleClickMentionModule()]);
59 |
--------------------------------------------------------------------------------
/src/modules/emote_autocomplete/components/EmoteRow.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 | import {Button} from 'rsuite';
4 | import Emote from '../../../common/components/Emote.jsx';
5 | import styles from './EmoteRow.module.css';
6 |
7 | export default function EmoteRow({key, index, emote, active, setSelected, handleAutocomplete}) {
8 | return (
9 | setSelected(index)}
12 | onClick={() => handleAutocomplete(emote)}
13 | appearance="subtle"
14 | className={classNames(styles.emoteRow, {[styles.active]: active})}>
15 |
16 |
17 |
{emote.code}
18 |
19 | {emote.category.displayName}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/modules/emote_autocomplete/components/EmoteRow.module.css:
--------------------------------------------------------------------------------
1 | .emoteRow {
2 | display: flex;
3 | justify-content: space-between;
4 | align-items: center;
5 | transition: none;
6 | border-radius: 2px;
7 | font-size: 14px;
8 | padding: 8px;
9 | }
10 |
11 | .emoteInfoContainer {
12 | display: flex;
13 | align-items: center;
14 | color: var(--bttv-rs-btn-default-active-text);
15 | }
16 |
17 | .emote {
18 | margin-right: 8px;
19 | width: 24px;
20 | height: 24px;
21 | }
22 |
23 | .active {
24 | background-color: var(--bttv-rs-btn-default-bg) !important;
25 | }
26 |
27 | .categoryName {
28 | color: var(--bttv-rs-text-tertiary);
29 | font-size: 12px;
30 | }
31 |
32 | @media only screen and (max-width: 450px) {
33 | .categoryName {
34 | display: none;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/modules/emote_autocomplete/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../../constants.js';
2 | import {loadModuleForPlatforms} from '../../utils/modules.js';
3 | import TwitchEmoteAutocomplete from './twitch/EmoteAutocomplete.jsx';
4 | import YoutubeEmoteAutocomplete from './youtube/EmoteAutocomplete.jsx';
5 |
6 | export default loadModuleForPlatforms(
7 | [PlatformTypes.TWITCH, () => new TwitchEmoteAutocomplete()],
8 | [PlatformTypes.YOUTUBE, () => new YoutubeEmoteAutocomplete()]
9 | );
10 |
--------------------------------------------------------------------------------
/src/modules/emote_autocomplete/twitch/EmoteAutocomplete.module.css:
--------------------------------------------------------------------------------
1 | :global {
2 | .emote-autocomplete-provider__image[srcset*='/__BTTV__'],
3 | .chat-line__message--emote[srcset*='/__BTTV__'] {
4 | display: none;
5 | }
6 |
7 | /* handle wide emotes like FireSpeed */
8 | .emote-autocomplete-provider__image {
9 | height: 2.8rem;
10 | width: 2.8rem;
11 | object-fit: contain;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/modules/emote_autocomplete/youtube/EmoteAutocomplete.module.css:
--------------------------------------------------------------------------------
1 | .hideNativeAutocomplete {
2 | display: none;
3 | }
4 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/Button.module.css:
--------------------------------------------------------------------------------
1 | .legacyButton {
2 | background-size: 18px;
3 | background-position: center;
4 | background-repeat: no-repeat;
5 | cursor: pointer;
6 | height: 18px;
7 | width: 18px;
8 | filter: grayscale(100%);
9 | opacity: 0.9;
10 | float: left;
11 | padding: 15px;
12 | background-image: url(assets/icons/legacy_smile.svg) !important;
13 | }
14 |
15 | .button {
16 | display: flex;
17 | justify-content: center;
18 | align-items: center;
19 | height: var(--button-size-default, 30px);
20 | width: var(--button-size-default, 30px);
21 | color: var(--bttv-rs-text-primary);
22 | cursor: pointer;
23 | svg {
24 | height: 16px;
25 | width: 16px;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/EmoteButton.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 | import Emote from '../../../common/components/Emote.jsx';
4 | import styles from './EmoteButton.module.css';
5 |
6 | export default function EmoteButton({emote, onClick, onMouseOver, active}) {
7 | const locked = emote.metadata?.isLocked?.() ?? false;
8 |
9 | return (
10 | onClick(emote)}
12 | onMouseOver={() => onMouseOver(emote)}
13 | onFocus={() => onMouseOver(emote)}
14 | type="button"
15 | className={classNames(styles.emote, active ? styles.active : null)}>
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/EmoteButton.module.css:
--------------------------------------------------------------------------------
1 | .emote {
2 | position: relative;
3 | border-radius: 4px;
4 | padding: 4px;
5 | display: flex;
6 | align-items: center;
7 | background-color: initial;
8 | border: none;
9 |
10 | &:focus {
11 | background-color: #464647;
12 | }
13 |
14 | &:focus-visible {
15 | outline: 0px;
16 | }
17 | }
18 |
19 | .active {
20 | background-color: var(--bttv-rs-state-hover-bg);
21 | }
22 |
23 | .placeholder {
24 | background-color: var(--bttv-rs-bg-card);
25 | }
26 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/EmoteMenu.module.css:
--------------------------------------------------------------------------------
1 | .divider {
2 | margin: 0px;
3 | }
4 |
5 | .header {
6 | display: flex;
7 | padding: 8px;
8 | column-gap: 4px;
9 | }
10 |
11 | .contentContainer {
12 | display: flex;
13 | }
14 |
15 | .content {
16 | height: 300px;
17 | width: 100%;
18 | }
19 |
20 | .emotes {
21 | width: 100%;
22 | padding: 0 8px;
23 | }
24 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/EmoteMenuPopover.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React, {useRef, useState} from 'react';
3 | import {mergeRefs} from 'react-merge-refs';
4 | import {Popover} from 'rsuite';
5 | import ThemeProvider from '../../../common/components/ThemeProvider.jsx';
6 | import useAutoPositionPopover from '../hooks/AutoRepositionPopover.jsx';
7 | import useHorizontalResize from '../hooks/HorizontalResize.jsx';
8 | import EmoteMenu from './EmoteMenu.jsx';
9 | import styles from './EmoteMenuPopover.module.css';
10 |
11 | const EmoteMenuPopover = React.forwardRef(
12 | ({toggleWhisper, appendToChat, className, style, boundingQuerySelector, whisperOpen, ...props}, ref) => {
13 | const handleRef = useRef(null);
14 | const [hasTip, setTip] = useState(false);
15 | const localRef = useRef(null);
16 |
17 | function handleSetTip(show) {
18 | if ((show && hasTip) || (!show && !hasTip)) {
19 | return;
20 | }
21 |
22 | setTip(show);
23 | }
24 |
25 | const reposition = useAutoPositionPopover(localRef, boundingQuerySelector, style, hasTip);
26 | const width = useHorizontalResize({
27 | boundingQuerySelector,
28 | handleRef,
29 | reposition: () => window.requestAnimationFrame(() => reposition()),
30 | });
31 |
32 | return (
33 |
34 |
40 |
41 |
42 | appendToChat(...args)}
45 | onSetTip={(show) => handleSetTip(show)}
46 | />
47 |
48 |
49 |
50 | );
51 | }
52 | );
53 |
54 | export default EmoteMenuPopover;
55 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/EmoteMenuPopover.module.css:
--------------------------------------------------------------------------------
1 | .popover {
2 | height: 402px;
3 | width: 390px;
4 | color: #efeff1;
5 | overflow: hidden;
6 | margin: 0 !important;
7 |
8 | div[class$='-popover-arrow'] {
9 | display: none !important;
10 | }
11 |
12 | div[class$='-popover-content'] {
13 | display: flex;
14 | }
15 | }
16 |
17 | .withTip {
18 | height: fit-content;
19 | }
20 |
21 | @media only screen and (max-width: 400px) {
22 | .popover {
23 | width: 320px;
24 | }
25 | }
26 |
27 | .resizeHandle {
28 | position: absolute;
29 | width: 2px;
30 | bottom: 0;
31 | top: 0;
32 | left: 0;
33 | cursor: col-resize;
34 | z-index: 2;
35 | }
36 |
37 | .emoteMenu {
38 | flex: 1;
39 | }
40 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/Emotes.module.css:
--------------------------------------------------------------------------------
1 | .emotesContainer {
2 | composes: scroll from '../styles/Scrollbar.module.css';
3 | width: 100%;
4 | padding: 0 8px;
5 | overflow-x: hidden;
6 | }
7 |
8 | .searched {
9 | padding-top: 8px;
10 | }
11 |
12 | .header {
13 | background-color: var(--bttv-rs-bg-overlay);
14 | color: var(--bttv-rs-text-heading);
15 | z-index: 3000;
16 | display: flex;
17 | align-items: center;
18 | justify-content: left;
19 | position: sticky;
20 | top: 0;
21 | }
22 |
23 | .headerIcon {
24 | display: flex;
25 | width: 30px;
26 | height: 30px;
27 | margin-right: 4px;
28 | align-items: center;
29 | justify-content: center;
30 | padding: 4px;
31 | box-sizing: border-box;
32 |
33 | svg {
34 | width: 100% !important;
35 | }
36 | }
37 |
38 | .headerText {
39 | font-weight: 600;
40 | font-size: 11px;
41 | line-height: 12px;
42 | opacity: 0.8;
43 | }
44 |
45 | .row {
46 | display: flex;
47 | justify-content: left;
48 | width: 100%;
49 | }
50 |
51 | .empty {
52 | display: flex;
53 | align-items: center;
54 | width: 100%;
55 | height: 100%;
56 | flex-direction: column;
57 | row-gap: 8px;
58 | justify-content: center;
59 | color: var(--bttv-rs-text-heading);
60 | }
61 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import * as faSearch from '@fortawesome/free-solid-svg-icons/faSearch';
2 | import * as faTimes from '@fortawesome/free-solid-svg-icons/faTimes';
3 | import {Icon} from '@rsuite/icons';
4 | import React, {useEffect, useRef} from 'react';
5 | import IconButton from 'rsuite/IconButton';
6 | import Input from 'rsuite/Input';
7 | import InputGroup from 'rsuite/InputGroup';
8 | import FontAwesomeSvgIcon from '../../../common/components/FontAwesomeSvgIcon.jsx';
9 | import formatMessage from '../../../i18n/index.js';
10 | import styles from './Header.module.css';
11 |
12 | function Header({value, onChange, toggleWhisper, selected, ...props}) {
13 | const searchInputRef = useRef(null);
14 |
15 | useEffect(() => {
16 | const currentSearchInputRef = searchInputRef.current;
17 | if (currentSearchInputRef == null) {
18 | return;
19 | }
20 | document.activeElement.blur();
21 | setTimeout(() => currentSearchInputRef.focus(), 1);
22 | }, []);
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
36 |
37 | }
39 | appearance="subtle"
40 | onClick={toggleWhisper}
41 | />
42 |
43 | );
44 | }
45 |
46 | export default React.memo(
47 | Header,
48 | (oldProps, newProps) =>
49 | oldProps.selected === newProps.selected &&
50 | newProps.value === oldProps.value &&
51 | newProps.toggleWhisper === oldProps.toggleWhisper
52 | );
53 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/Header.module.css:
--------------------------------------------------------------------------------
1 | .searchPrefix {
2 | background-color: var(--bttv-rs-input-bg) !important;
3 | margin-right: -12px;
4 | }
5 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/Icons.module.css:
--------------------------------------------------------------------------------
1 | .icon {
2 | object-fit: contain;
3 | position: relative;
4 | width: 100%;
5 | height: 100%;
6 | }
7 |
8 | .iconBorderRadius {
9 | border-radius: 4px;
10 | }
11 |
12 | .brandIcon {
13 | position: absolute;
14 | right: -2px;
15 | bottom: -2px;
16 | width: 12px;
17 | height: 12px;
18 | object-fit: contain;
19 | border-radius: 2px;
20 | }
21 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/Preview.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Emote from '../../../common/components/Emote.jsx';
3 | import emoteMenuViewStore from '../../../common/stores/emote-menu-view-store.js';
4 | import formatMessage from '../../../i18n/index.js';
5 | import Icons from './Icons.jsx';
6 | import styles from './Preview.module.css';
7 |
8 | export default function Preview({emote}) {
9 | if (emote == null) return null;
10 |
11 | let icon = null;
12 | if (emote.metadata?.isLocked?.() ?? false) {
13 | icon = Icons.LOCK;
14 | } else if (emoteMenuViewStore.hasFavorite(emote)) {
15 | icon = Icons.STAR;
16 | }
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
{emote.code}
24 |
{formatMessage({defaultMessage: 'from {name}'}, {name: emote.category.displayName})}
25 |
26 |
27 |
{icon}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/Preview.module.css:
--------------------------------------------------------------------------------
1 | .preview {
2 | box-sizing: border-box;
3 | display: flex;
4 | justify-content: space-between;
5 | padding: 4px 8px;
6 | align-items: center;
7 | color: var(--bttv-rs-text-heading);
8 | }
9 |
10 | .content {
11 | display: flex;
12 | align-items: center;
13 | column-gap: 8px;
14 | height: 100%;
15 | }
16 |
17 | .emoteText {
18 | white-space: nowrap;
19 | }
20 |
21 | .emoteImage {
22 | width: 36px;
23 | height: 36px;
24 | object-fit: contain;
25 | }
26 |
27 | .emote {
28 | display: flex;
29 | align-items: center;
30 | justify-content: center;
31 | }
32 |
33 | .emoteCode {
34 | font-weight: 800;
35 | }
36 |
37 | .emoteStatusIcon {
38 | padding: 10px;
39 | }
40 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/Tip.module.css:
--------------------------------------------------------------------------------
1 | .divider {
2 | margin: 0;
3 | }
4 |
5 | .tip {
6 | background-color: var(--bttv-rs-bg-well);
7 | color: var(--bttv-rs-text-heading);
8 | display: flex;
9 | flex-direction: row;
10 | align-items: center;
11 | padding: 4px 8px;
12 | }
13 |
14 | .tipDisplayText {
15 | padding: 0 8px;
16 | flex-grow: 1;
17 | text-overflow: ellipsis;
18 | overflow: hidden;
19 | }
20 |
21 | .textButton {
22 | padding: 0;
23 | }
24 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/components/VirtualizedList.module.css:
--------------------------------------------------------------------------------
1 | .list {
2 | overflow-y: scroll;
3 | position: relative;
4 | }
5 |
6 | .rows {
7 | width: 100%;
8 | position: absolute;
9 | }
10 |
11 | .ghostRows {
12 | visibility: hidden;
13 | }
14 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/hooks/AutoRepositionPopover.jsx:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 | import useResize from '../../../common/hooks/Resize.jsx';
3 | import repositionPopover from '../../../utils/popover.js';
4 |
5 | const TOP_PADDING = 2;
6 |
7 | export default function useAutoPositionPopover(localRef, boundingQuerySelector, style, hasTip) {
8 | function reposition() {
9 | repositionPopover(localRef, boundingQuerySelector, TOP_PADDING);
10 | }
11 |
12 | useEffect(() => {
13 | reposition();
14 | }, [localRef, style, hasTip]);
15 |
16 | useResize(reposition);
17 |
18 | return reposition;
19 | }
20 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/hooks/AutoScroll.jsx:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 | import {EMOTE_MENU_SIDEBAR_ROW_HEIGHT} from '../../../constants.js';
3 |
4 | export default function useAutoScroll(section, containerRef, categories, windowHeight) {
5 | useEffect(() => {
6 | const currentRef = containerRef.current;
7 | if (section.eventKey == null || currentRef == null) return;
8 |
9 | const top = currentRef.scrollTop;
10 | const index = categories.findIndex((category) => category.id === section.eventKey);
11 | const depth = index * EMOTE_MENU_SIDEBAR_ROW_HEIGHT;
12 |
13 | let newTop;
14 | if (depth < top) {
15 | newTop = depth;
16 | }
17 | if (depth + EMOTE_MENU_SIDEBAR_ROW_HEIGHT > top + windowHeight) {
18 | newTop = depth - windowHeight + EMOTE_MENU_SIDEBAR_ROW_HEIGHT;
19 | }
20 | if (newTop == null) {
21 | return;
22 | }
23 |
24 | currentRef.scrollTo({top: newTop, left: 0, behavior: 'smooth'});
25 | }, [section]);
26 | }
27 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../../constants.js';
2 | import {loadModuleForPlatforms} from '../../utils/modules.js';
3 | import TwitchEmoteMenu from './twitch/EmoteMenu.jsx';
4 | import YouTubeEmoteMenu from './youtube/EmoteMenu.jsx';
5 |
6 | export default loadModuleForPlatforms(
7 | [PlatformTypes.TWITCH, async () => new TwitchEmoteMenu()],
8 | [PlatformTypes.YOUTUBE, async () => new YouTubeEmoteMenu()]
9 | );
10 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/styles/Scrollbar.module.css:
--------------------------------------------------------------------------------
1 | .scroll {
2 | overflow-y: scroll;
3 | scrollbar-width: thin;
4 | scrollbar-color: #191c23 transparent;
5 | }
6 |
7 | .scroll::-webkit-scrollbar {
8 | border-radius: 4px;
9 | width: 4px;
10 | }
11 |
12 | .scroll::-webkit-scrollbar-thumb {
13 | background: #191c23;
14 | border-radius: 4px;
15 | }
16 |
17 | .scroll::-webkit-scrollbar-thumb:hover {
18 | background: #191c23;
19 | }
20 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/twitch/EmoteMenu.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: inline-block !important;
3 | vertical-align: bottom !important;
4 | }
5 |
6 | .hideEmoteMenuButtonContainer button[data-a-target='emote-picker-button'] {
7 | display: none;
8 | }
9 |
10 | .button {
11 | border-radius: var(--border-radius-medium);
12 |
13 | &:hover {
14 | background-color: var(--color-background-button-text-hover);
15 | color: var(--color-fill-button-icon-hover);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/modules/emote_menu/utils/emojis.js:
--------------------------------------------------------------------------------
1 | import {EmoteCategories, EmoteProviders} from '../../../constants.js';
2 | import emoji from '../../emotes/emojis.js';
3 | import Icons from '../components/Icons.jsx';
4 |
5 | const emojiByCategory = emoji.getEmotesByCategory();
6 | const emojiCategories = [
7 | {
8 | category: {
9 | id: EmoteCategories.EMOJI_PEOPLE,
10 | provider: EmoteProviders.BETTERTTV,
11 | displayName: 'People',
12 | icon: Icons.PEOPLE,
13 | },
14 | emotes: emojiByCategory.people,
15 | },
16 | {
17 | category: {
18 | id: EmoteCategories.EMOJI_NATURE,
19 | provider: EmoteProviders.BETTERTTV,
20 | displayName: 'Nature',
21 | icon: Icons.LEAF,
22 | },
23 | emotes: emojiByCategory.nature,
24 | },
25 | {
26 | category: {
27 | id: EmoteCategories.EMOJI_FOODS,
28 | provider: EmoteProviders.BETTERTTV,
29 | displayName: 'Foods',
30 | icon: Icons.ICE_CREAM,
31 | },
32 | emotes: emojiByCategory.food,
33 | },
34 | {
35 | category: {
36 | id: EmoteCategories.EMOJI_ACTIVITIES,
37 | provider: EmoteProviders.BETTERTTV,
38 | displayName: 'Activities',
39 | icon: Icons.BASKET_BALL,
40 | },
41 | emotes: emojiByCategory.activity,
42 | },
43 | {
44 | category: {
45 | id: EmoteCategories.EMOJI_TRAVEL,
46 | provider: EmoteProviders.BETTERTTV,
47 | displayName: 'Travel',
48 | icon: Icons.PLANE,
49 | },
50 | emotes: emojiByCategory.travel,
51 | },
52 | {
53 | category: {
54 | id: EmoteCategories.EMOJI_OBJECTS,
55 | provider: EmoteProviders.BETTERTTV,
56 | displayName: 'Objects',
57 | icon: Icons.BOX,
58 | },
59 | emotes: emojiByCategory.objects,
60 | },
61 | {
62 | category: {
63 | id: EmoteCategories.EMOJI_SYMBOLS,
64 | provider: EmoteProviders.BETTERTTV,
65 | displayName: 'Symbols',
66 | icon: Icons.HEART,
67 | },
68 | emotes: emojiByCategory.symbols,
69 | },
70 | {
71 | category: {
72 | id: EmoteCategories.EMOJI_FLAGS,
73 | provider: EmoteProviders.BETTERTTV,
74 | displayName: 'Flags',
75 | icon: Icons.FLAG,
76 | },
77 | emotes: emojiByCategory.flags,
78 | },
79 | ];
80 |
81 | export function getEmojiCategories() {
82 | return emojiCategories;
83 | }
84 |
--------------------------------------------------------------------------------
/src/modules/emotes/abstract-emotes.js:
--------------------------------------------------------------------------------
1 | export default class AbstractEmotes {
2 | constructor() {
3 | this.emotes = new Map();
4 |
5 | if (this.category === undefined) {
6 | throw new TypeError('Must set "category" attribute');
7 | }
8 | }
9 |
10 | getEmotes() {
11 | return [...this.emotes.values()];
12 | }
13 |
14 | getEligibleEmote(code) {
15 | return this.emotes.get(code);
16 | }
17 |
18 | getEligibleEmoteById(emoteId) {
19 | return this.getEmotes().find(({id}) => id === emoteId);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/modules/emotes/global-emotes.js:
--------------------------------------------------------------------------------
1 | import {EmoteCategories, EmoteProviders} from '../../constants.js';
2 | import formatMessage from '../../i18n/index.js';
3 | import api from '../../utils/api.js';
4 | import cdn from '../../utils/cdn.js';
5 | import watcher from '../../watcher.js';
6 | import subscribers from '../subscribers/index.js';
7 | import AbstractEmotes from './abstract-emotes.js';
8 | import Emote from './emote.js';
9 |
10 | const category = {
11 | id: EmoteCategories.BETTERTTV_GLOBAL,
12 | provider: EmoteProviders.BETTERTTV,
13 | displayName: formatMessage({defaultMessage: 'BetterTTV Global Emotes'}),
14 | };
15 |
16 | class GlobalEmotes extends AbstractEmotes {
17 | constructor() {
18 | super();
19 |
20 | this.updateGlobalEmotes();
21 | }
22 |
23 | get category() {
24 | return category;
25 | }
26 |
27 | updateGlobalEmotes() {
28 | api
29 | .get('cached/emotes/global')
30 | .then((emotes) =>
31 | emotes.forEach(({id, code, animated, restrictions, modifier}) => {
32 | let restrictionCallback;
33 | if (restrictions && restrictions.emoticonSet) {
34 | restrictionCallback = (_, user) => {
35 | if (restrictions.emoticonSet !== 'night') return false;
36 | return user ? subscribers.hasLegacySubscription(user.name) : false;
37 | };
38 | }
39 |
40 | this.emotes.set(
41 | code,
42 | new Emote({
43 | id,
44 | category: this.category,
45 | channel: undefined,
46 | code,
47 | images: {
48 | '1x': cdn.emoteUrl(id, '1x'),
49 | '2x': cdn.emoteUrl(id, '2x'),
50 | '4x': cdn.emoteUrl(id, '3x'),
51 | '1x_static': animated ? cdn.emoteUrl(id, '1x', true) : undefined,
52 | '2x_static': animated ? cdn.emoteUrl(id, '2x', true) : undefined,
53 | '4x_static': animated ? cdn.emoteUrl(id, '3x', true) : undefined,
54 | },
55 | animated,
56 | restrictionCallback,
57 | modifier,
58 | })
59 | );
60 | })
61 | )
62 | .then(() => watcher.emit('emotes.updated'));
63 | }
64 | }
65 |
66 | export default new GlobalEmotes();
67 |
--------------------------------------------------------------------------------
/src/modules/frankerfacez/channel-emotes.js:
--------------------------------------------------------------------------------
1 | import {EmoteCategories, EmoteProviders, EmoteTypeFlags, SettingIds} from '../../constants.js';
2 | import formatMessage from '../../i18n/index.js';
3 | import settings from '../../settings.js';
4 | import api from '../../utils/api.js';
5 | import {getCurrentChannel} from '../../utils/channel.js';
6 | import {hasFlag} from '../../utils/flags.js';
7 | import watcher from '../../watcher.js';
8 | import AbstractEmotes from '../emotes/abstract-emotes.js';
9 | import Emote from '../emotes/emote.js';
10 |
11 | const category = {
12 | id: EmoteCategories.FRANKERFACEZ_CHANNEL,
13 | provider: EmoteProviders.FRANKERFACEZ,
14 | displayName: formatMessage({defaultMessage: 'FrankerFaceZ Channel Emotes'}),
15 | };
16 |
17 | class FrankerFaceZChannelEmotes extends AbstractEmotes {
18 | constructor() {
19 | super();
20 |
21 | watcher.on('channel.updated', () => this.updateChannelEmotes());
22 | settings.on(`changed.${SettingIds.EMOTES}`, () => this.updateChannelEmotes());
23 | }
24 |
25 | get category() {
26 | return category;
27 | }
28 |
29 | updateChannelEmotes() {
30 | this.emotes.clear();
31 |
32 | if (!hasFlag(settings.get(SettingIds.EMOTES), EmoteTypeFlags.FFZ_EMOTES)) return;
33 |
34 | const currentChannel = getCurrentChannel();
35 | if (!currentChannel) return;
36 |
37 | api
38 | .get(`cached/frankerfacez/users/${currentChannel.provider}/${currentChannel.id}`)
39 | .then((emotes) =>
40 | emotes.forEach(({id, user, code, images, animated, modifier}) => {
41 | this.emotes.set(
42 | code,
43 | new Emote({
44 | id,
45 | category: this.category,
46 | channel: user,
47 | code,
48 | images,
49 | animated,
50 | modifier,
51 | })
52 | );
53 | })
54 | )
55 | .then(() => watcher.emit('emotes.updated'));
56 | }
57 | }
58 |
59 | export default new FrankerFaceZChannelEmotes();
60 |
--------------------------------------------------------------------------------
/src/modules/frankerfacez/global-emotes.js:
--------------------------------------------------------------------------------
1 | import {EmoteCategories, EmoteProviders, EmoteTypeFlags, SettingIds} from '../../constants.js';
2 | import formatMessage from '../../i18n/index.js';
3 | import settings from '../../settings.js';
4 | import api from '../../utils/api.js';
5 | import {hasFlag} from '../../utils/flags.js';
6 | import watcher from '../../watcher.js';
7 |
8 | import AbstractEmotes from '../emotes/abstract-emotes.js';
9 | import Emote from '../emotes/emote.js';
10 |
11 | const category = {
12 | id: EmoteCategories.FRANKERFACEZ_GLOBAL,
13 | provider: EmoteProviders.FRANKERFACEZ,
14 | displayName: formatMessage({defaultMessage: 'FrankerFaceZ Global Emotes'}),
15 | };
16 |
17 | class GlobalEmotes extends AbstractEmotes {
18 | constructor() {
19 | super();
20 |
21 | settings.on(`changed.${SettingIds.EMOTES}`, () => this.updateGlobalEmotes());
22 |
23 | this.updateGlobalEmotes();
24 | }
25 |
26 | get category() {
27 | return category;
28 | }
29 |
30 | updateGlobalEmotes() {
31 | this.emotes.clear();
32 |
33 | if (!hasFlag(settings.get(SettingIds.EMOTES), EmoteTypeFlags.FFZ_EMOTES)) return;
34 |
35 | api
36 | .get('cached/frankerfacez/emotes/global')
37 | .then((emotes) =>
38 | emotes.forEach(({id, user, code, images, animated, modifier}) => {
39 | this.emotes.set(
40 | code,
41 | new Emote({
42 | id,
43 | category: this.category,
44 | channel: user,
45 | code,
46 | images,
47 | animated,
48 | modifier,
49 | })
50 | );
51 | })
52 | )
53 | .then(() => watcher.emit('emotes.updated'));
54 | }
55 | }
56 |
57 | export default new GlobalEmotes();
58 |
--------------------------------------------------------------------------------
/src/modules/global_css/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../../constants.js';
2 | import extension from '../../utils/extension.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 | import {getPlatform} from '../../utils/window.js';
5 |
6 | class GlobalCSSModule {
7 | constructor() {
8 | loadModuleForPlatforms(
9 | [PlatformTypes.TWITCH, () => import('./twitch.js')],
10 | [PlatformTypes.YOUTUBE, () => import('./youtube.js')]
11 | );
12 | }
13 |
14 | loadGlobalCSS() {
15 | // TODO: this is a crazy hack to enable youtube-specific rsuite overrides
16 | // we should find a better way
17 | if (getPlatform() === PlatformTypes.YOUTUBE) {
18 | document.body.classList.toggle('bttv-youtube', true);
19 | }
20 |
21 | const extensionCSSUrl = extension.url('betterttv.css', true);
22 | if (!extensionCSSUrl) {
23 | return Promise.resolve();
24 | }
25 |
26 | return new Promise((resolve, reject) => {
27 | const css = document.createElement('link');
28 | css.setAttribute('href', extensionCSSUrl);
29 | css.setAttribute('type', 'text/css');
30 | css.setAttribute('rel', 'stylesheet');
31 | css.addEventListener('load', () => resolve());
32 | css.addEventListener('error', (err) => reject(err));
33 | document.body.appendChild(css);
34 | });
35 | }
36 | }
37 |
38 | export default new GlobalCSSModule();
39 |
--------------------------------------------------------------------------------
/src/modules/global_css/rsuite.less:
--------------------------------------------------------------------------------
1 | @theme: 'dark'; //set to "default" for light theme
2 |
3 | @import 'rsuite/styles/index.less';
4 |
5 | @ns: bttv-rs-;
6 | @primary-color-dark: #9146ff;
7 | @primary-color: #9146ff;
8 |
9 | .@{ns}icon.fill-colour use {
10 | fill: currentColor;
11 | }
12 |
13 | .@{ns}panel-heading {
14 | padding-bottom: 0px;
15 | }
16 |
17 | .@{ns}modal-dialog,
18 | .@{ns}modal-content,
19 | .@{ns}modal {
20 | a:not(:active):not(:hover) {
21 | text-decoration: none;
22 | }
23 |
24 | * {
25 | box-sizing: border-box;
26 | }
27 |
28 | *::before,
29 | *::after {
30 | box-sizing: border-box;
31 | }
32 | }
33 |
34 | .@{ns}checkbox label {
35 | line-height: 1.5;
36 | }
37 |
38 | .@{ns}auto-complete-item {
39 | width: unset;
40 | }
41 |
42 | .@{ns}modal {
43 | margin: 0;
44 | }
45 |
46 | .@{ns}modal-wrapper {
47 | display: flex;
48 | justify-content: center;
49 | align-items: center;
50 | }
51 |
52 | .@{ns}table-cell-content {
53 | padding: 0px;
54 | }
55 |
56 | .@{ns}divider-horizontal.@{ns}divider-with-text {
57 | margin: 20px;
58 | }
59 |
60 | .@{ns}radio-checker,
61 | .@{ns}checkbox-checker {
62 | vertical-align: middle;
63 | padding-top: 0px;
64 | padding-bottom: 15px;
65 | }
66 |
67 | .@{ns}modal-wrapper {
68 | z-index: 3100;
69 | }
70 |
71 | .@{ns}modal-backdrop {
72 | z-index: 3100;
73 | }
74 |
75 | .@{ns}popover {
76 | z-index: 3100;
77 | }
78 |
79 | .@{ns}tooltip {
80 | z-index: 3150;
81 | }
82 |
83 | @media screen and (max-width: 769px) {
84 | .@{ns}modal {
85 | width: 95%;
86 | }
87 |
88 | .@{ns}modal-content,
89 | .@{ns}modal-dialog {
90 | width: 100%;
91 | }
92 | }
93 |
94 | .@{ns}nav-item-content {
95 | svg {
96 | width: 100% !important;
97 | }
98 | }
99 |
100 | .@{ns}popover {
101 | -webkit-transform: unset !important;
102 | transform: unset !important;
103 | }
104 |
--------------------------------------------------------------------------------
/src/modules/global_css/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --bttv-brand-color: #d50014;
3 | }
4 |
5 | .bttv-tooltip-wrapper {
6 | display: inline-block;
7 | position: relative;
8 |
9 | &:hover .bttv-tooltip {
10 | display: block;
11 | }
12 | }
13 |
14 | .bttv-tooltip {
15 | padding: 3px 6px;
16 | border-radius: 0.4rem;
17 | background-color: var(--color-background-tooltip);
18 | color: var(--color-text-tooltip);
19 | display: none;
20 | position: absolute;
21 | font-size: 1.3rem;
22 | font-weight: 600;
23 | line-height: 1.2;
24 | text-align: center;
25 | z-index: 2000;
26 | pointer-events: none;
27 | user-select: none;
28 | white-space: pre;
29 | margin-bottom: 6px;
30 |
31 | &.bttv-tooltip--up {
32 | top: auto;
33 | bottom: 100%;
34 | left: 0;
35 | margin-bottom: 6px;
36 |
37 | &.bttv-tooltip--align-center {
38 | left: 50%;
39 | transform: translateX(-50%);
40 |
41 | &:after {
42 | left: 50%;
43 | margin-left: -3px;
44 | }
45 | }
46 |
47 | &.bttv-tooltip--align-right {
48 | left: 100%;
49 | transform: translateX(-100%);
50 |
51 | &:after {
52 | left: 100%;
53 | margin-left: -16px;
54 | }
55 | }
56 |
57 | &:after {
58 | border-radius: 0 0 2px;
59 | top: 100%;
60 | left: 6px;
61 | margin-top: -3px;
62 | }
63 | }
64 |
65 | &:before {
66 | top: -6px;
67 | left: -6px;
68 | width: calc(100% + 12px);
69 | height: calc(100% + 12px);
70 | z-index: -1;
71 | }
72 |
73 | &:after {
74 | background-color: var(--color-background-tooltip);
75 | width: 6px;
76 | height: 6px;
77 | transform: rotate(45deg);
78 | z-index: -1;
79 | }
80 |
81 | &:after,
82 | &:before {
83 | position: absolute;
84 | content: '';
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/modules/global_css/twitch.js:
--------------------------------------------------------------------------------
1 | import {SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import twitch from '../../utils/twitch.js';
4 |
5 | const TWITCH_THEME_CHANGED_DISPATCH_TYPE = 'core.ui.THEME_CHANGED';
6 | const TWITCH_THEME_STORAGE_KEY = 'twilight.theme';
7 | const TwitchThemes = {
8 | LIGHT: 0,
9 | DARK: 1,
10 | };
11 |
12 | let connectStore;
13 |
14 | function setTwitchTheme(value) {
15 | if (!connectStore) return;
16 |
17 | const theme = value === true ? TwitchThemes.DARK : TwitchThemes.LIGHT;
18 | try {
19 | localStorage.setItem(TWITCH_THEME_STORAGE_KEY, JSON.stringify(theme));
20 | } catch (_) {}
21 | connectStore.dispatch({
22 | type: TWITCH_THEME_CHANGED_DISPATCH_TYPE,
23 | theme,
24 | });
25 | }
26 |
27 | settings.on(`changed.${SettingIds.DARKENED_MODE}`, (value, temporary) => {
28 | if (temporary) return;
29 | setTwitchTheme(value);
30 | });
31 |
32 | function matchSystemTheme() {
33 | // Will only match the system theme if the browser is also configured to match the system theme
34 | if (!settings.get(SettingIds.AUTO_THEME_MODE)) return;
35 | if (!connectStore) return;
36 |
37 | const twitchIsDarkMode = connectStore.getState().ui.theme === TwitchThemes.DARK;
38 | const systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
39 | if (systemIsDarkMode !== twitchIsDarkMode) {
40 | setTwitchTheme(!twitchIsDarkMode);
41 | }
42 | }
43 |
44 | settings.on(`changed.${SettingIds.AUTO_THEME_MODE}`, (value, temporary) => {
45 | if (temporary || !value) return;
46 | matchSystemTheme();
47 | });
48 |
49 | (() => {
50 | connectStore = twitch.getConnectStore();
51 | if (!connectStore) return;
52 |
53 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
54 | matchSystemTheme();
55 | });
56 |
57 | matchSystemTheme();
58 |
59 | connectStore.subscribe(() => {
60 | const isDarkMode = connectStore.getState().ui.theme === TwitchThemes.DARK;
61 | if (settings.get(SettingIds.DARKENED_MODE) === isDarkMode) {
62 | return;
63 | }
64 |
65 | settings.set(SettingIds.DARKENED_MODE, isDarkMode, true);
66 | });
67 | })();
68 |
--------------------------------------------------------------------------------
/src/modules/global_css/youtube.js:
--------------------------------------------------------------------------------
1 | import {SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 |
4 | const node = document.querySelector('html');
5 |
6 | function updateTheme() {
7 | const isDarkMode = node.getAttribute('dark') != null;
8 | if (!node.isConnected || settings.get(SettingIds.DARKENED_MODE) === isDarkMode) {
9 | return;
10 | }
11 |
12 | settings.set(SettingIds.DARKENED_MODE, isDarkMode, true);
13 | }
14 |
15 | updateTheme();
16 |
17 | const attributeObserver = new window.MutationObserver(updateTheme);
18 | attributeObserver.observe(node, {attributes: true, attributeFilter: ['dark']});
19 |
--------------------------------------------------------------------------------
/src/modules/hide_bits/index.js:
--------------------------------------------------------------------------------
1 | import {ChatFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 |
6 | class HideBitsModule {
7 | constructor() {
8 | settings.on(`changed.${SettingIds.CHAT}`, () => this.load());
9 | this.load();
10 | }
11 |
12 | load() {
13 | document.body.classList.toggle('bttv-hide-bits', !hasFlag(settings.get(SettingIds.CHAT), ChatFlags.BITS));
14 | }
15 | }
16 |
17 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new HideBitsModule()]);
18 |
--------------------------------------------------------------------------------
/src/modules/hide_bits/style.css:
--------------------------------------------------------------------------------
1 | .bttv-hide-bits {
2 | .pinned-cheer,
3 | .pinned-cheer-v2,
4 | button[data-a-target="bits-button"],
5 | div:has(> div > button.tw-interactable > div.channel-leaderboard-header-rotating__users),
6 | div:has(> div > div > div.channel-leaderboard-header-rotating__users),
7 | div[data-test-selector="last-x-events"],
8 | img.chat-line__message--emote[src^="https://d3aqoihi2n8ty8.cloudfront.net/actions/"],
9 | img.chat-line__message--emote[src^="https://d3aqoihi2n8ty8.cloudfront.net/partner-actions/"],
10 | .chat-badge[alt~="cheer"] {
11 | display: none !important;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/modules/hide_chat_clips/index.js:
--------------------------------------------------------------------------------
1 | import {ChatFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 |
6 | class HideChatClipsModule {
7 | constructor() {
8 | settings.on(`changed.${SettingIds.CHAT}`, () => this.load());
9 | this.load();
10 | }
11 |
12 | load() {
13 | document.body.classList.toggle(
14 | 'bttv-hide-chat-clips',
15 | !hasFlag(settings.get(SettingIds.CHAT), ChatFlags.CHAT_CLIPS)
16 | );
17 | }
18 | }
19 |
20 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new HideChatClipsModule()]);
21 |
--------------------------------------------------------------------------------
/src/modules/hide_chat_clips/style.css:
--------------------------------------------------------------------------------
1 | .bttv-hide-chat-clips {
2 | .chat-card {
3 | display: none !important;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/modules/hide_chat_events/index.js:
--------------------------------------------------------------------------------
1 | import {ChatFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import twitch from '../../utils/twitch.js';
6 | import watcher from '../../watcher.js';
7 |
8 | class HideChatEventsModule {
9 | constructor() {
10 | watcher.on('chat.message.handler', (message) => {
11 | this.handleMessage(message);
12 | });
13 | }
14 |
15 | handleMessage({message, preventDefault}) {
16 | switch (message.type) {
17 | case twitch.getTMIActionTypes()?.FIRST_MESSAGE_HIGHLIGHT:
18 | if (!hasFlag(settings.get(SettingIds.CHAT), ChatFlags.VIEWER_GREETING)) {
19 | preventDefault();
20 | }
21 | break;
22 | case twitch.getTMIActionTypes()?.SUBSCRIPTION:
23 | case twitch.getTMIActionTypes()?.RESUBSCRIPTION:
24 | case twitch.getTMIActionTypes()?.SUBGIFT:
25 | if (!hasFlag(settings.get(SettingIds.CHAT), ChatFlags.SUB_NOTICE)) {
26 | preventDefault();
27 | }
28 | break;
29 | default:
30 | break;
31 | }
32 | }
33 | }
34 |
35 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new HideChatEventsModule()]);
36 |
--------------------------------------------------------------------------------
/src/modules/hide_community_highlights/index.js:
--------------------------------------------------------------------------------
1 | import {ChatFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import domObserver from '../../observers/dom.js';
3 | import settings from '../../settings.js';
4 | import {hasFlag} from '../../utils/flags.js';
5 | import {loadModuleForPlatforms} from '../../utils/modules.js';
6 | import twitch from '../../utils/twitch.js';
7 | import watcher from '../../watcher.js';
8 |
9 | let removeCommunityHighlightsListener;
10 |
11 | class HideCommunityHighlightsModule {
12 | constructor() {
13 | settings.on(`changed.${SettingIds.CHAT}`, this.toggleCommunityHighlights);
14 | watcher.on('load', this.toggleCommunityHighlights);
15 | }
16 |
17 | toggleCommunityHighlights() {
18 | if (!hasFlag(settings.get(SettingIds.CHAT), ChatFlags.COMMUNITY_HIGHLIGHTS)) {
19 | if (removeCommunityHighlightsListener) return;
20 |
21 | removeCommunityHighlightsListener = domObserver.on('.community-highlight-stack__card', (node, isConnected) => {
22 | if (!isConnected) return;
23 |
24 | const communityHighlight = twitch.getCommunityHighlight();
25 | if (
26 | communityHighlight?.event?.type === 'poll' ||
27 | node.querySelector('button[data-test-selector="community-prediction-highlight-header__action-button"]') !=
28 | null
29 | ) {
30 | return;
31 | }
32 |
33 | node.classList.add('bttv-hide-community-highlights');
34 | });
35 | return;
36 | }
37 |
38 | if (!removeCommunityHighlightsListener) return;
39 |
40 | removeCommunityHighlightsListener();
41 | removeCommunityHighlightsListener = undefined;
42 | document.querySelector('.community-highlight-stack__card')?.classList.remove('bttv-hide-community-highlights');
43 | }
44 | }
45 |
46 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new HideCommunityHighlightsModule()]);
47 |
--------------------------------------------------------------------------------
/src/modules/hide_community_highlights/style.css:
--------------------------------------------------------------------------------
1 | .bttv-hide-community-highlights {
2 | display: none;
3 | }
4 |
--------------------------------------------------------------------------------
/src/modules/hide_prime_promotions/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 | import watcher from '../../watcher.js';
5 |
6 | class HidePrimePromotionsModule {
7 | constructor() {
8 | settings.on(`changed.${SettingIds.PRIME_PROMOTIONS}`, this.togglePrimePromotions);
9 | watcher.on('load', this.togglePrimePromotions);
10 | }
11 |
12 | togglePrimePromotions() {
13 | document.body.classList.toggle('bttv-hide-prime-promotions', !settings.get(SettingIds.PRIME_PROMOTIONS));
14 | }
15 | }
16 |
17 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new HidePrimePromotionsModule()]);
18 |
--------------------------------------------------------------------------------
/src/modules/hide_prime_promotions/style.css:
--------------------------------------------------------------------------------
1 | .bttv-hide-prime-promotions {
2 | .top-nav__prime {
3 | display: none;
4 | }
5 |
6 | [data-test-selector='test_selector_prime_tracking_button_wrapper'] [data-a-target='blue-bar'] {
7 | display: none;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/modules/hide_sidebar_elements/styles.module.css:
--------------------------------------------------------------------------------
1 | .hide {
2 | display: none !important;
3 | }
4 |
5 | .hideOfflineChannel {
6 | display: none !important;
7 | }
8 |
9 | .hideStories {
10 | :global {
11 | div.side-nav div[class*='storiesLeftNavSection'],
12 | div.side-nav div:has(> div > button > div[class*='storiesLeftNavSectionCollapsedButton']) {
13 | display: none !important;
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/modules/hype_chat/styles.module.css:
--------------------------------------------------------------------------------
1 | .hideHypeChatMessages {
2 | div[class$='paid-pinned-chat-message-content-wrapper'] {
3 | display: none !important;
4 | }
5 |
6 | div[class$='paid-pinned-chat-message-list'] {
7 | display: none !important;
8 | }
9 | }
10 |
11 | .hideHypeChatButton {
12 | button:has(
13 | path[d='M14.91 2.073 13 9l1.88 1.071a1 1 0 0 1 .036 1.717l-9.825 6.14L7 11 5.12 9.929a1 1 0 0 1-.035-1.717l9.824-6.14zm-6.784 11.6L9 10 7 9l4.874-2.672L11 10l2 1-4.874 2.673z']
14 | ) {
15 | display: none !important;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/modules/settings/components/AnimatedLogo.jsx:
--------------------------------------------------------------------------------
1 | import {motion} from 'framer-motion';
2 | import React from 'react';
3 | import LogoIcon from '../../../common/components/LogoIcon.jsx';
4 | import styles from '../styles/sidenav.module.css';
5 |
6 | export default function AnimatedLogo() {
7 | return (
8 |
9 |
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/modules/settings/components/ChatWindow.jsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import PanelGroup from 'rsuite/PanelGroup';
3 | import {CategoryTypes} from '../../../constants.js';
4 | import formatMessage from '../../../i18n/index.js';
5 | import styles from '../styles/popout.module.css';
6 | import AnimatedLogo from './AnimatedLogo.jsx';
7 | import CloseButton from './CloseButton.jsx';
8 | import {Settings, Search} from './Settings.jsx';
9 |
10 | export default function ChatWindow({open, onClose}) {
11 | const [search, setSearch] = useState('');
12 |
13 | if (!open) return null;
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/modules/settings/components/CloseButton.jsx:
--------------------------------------------------------------------------------
1 | import * as faTimes from '@fortawesome/free-solid-svg-icons/faTimes';
2 | import {Icon} from '@rsuite/icons';
3 | import React from 'react';
4 | import IconButton from 'rsuite/IconButton';
5 | import FontAwesomeSvgIcon from '../../../common/components/FontAwesomeSvgIcon.jsx';
6 |
7 | export default function CloseButton(props) {
8 | const {onClose, ...restProps} = props;
9 |
10 | return (
11 |
12 | }
14 | onClick={onClose}
15 | appearance="subtle"
16 | />
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/modules/settings/components/Settings.module.css:
--------------------------------------------------------------------------------
1 | .unsupportedPanelButton {
2 | margin-top: 24px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/modules/settings/components/Store.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const Components = [];
4 |
5 | export function registerComponent(Component, metadata) {
6 | Components[metadata.settingId] = {
7 | ...metadata,
8 | render: (...props) => ,
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/src/modules/settings/components/Window.module.css:
--------------------------------------------------------------------------------
1 | .modal {
2 | width: 800px;
3 | height: 500px;
4 | }
5 |
6 | .modalContent {
7 | > div {
8 | overflow: hidden;
9 | margin: 0;
10 | padding: 0;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/global/Emotes.module.css:
--------------------------------------------------------------------------------
1 | .modifierImage {
2 | width: 20px;
3 | height: 20px;
4 | }
5 |
6 | .modifiersModalBody {
7 | display: flex;
8 | flex-direction: column;
9 | align-items: left;
10 | row-gap: 8px;
11 | }
12 |
13 | .modifierDescription {
14 | color: var(--bttv-rs-text-secondary);
15 | }
16 |
17 | .modifier {
18 | display: flex;
19 | align-items: center;
20 | justify-content: left;
21 | column-gap: 12px;
22 | }
23 |
24 | .modifiersModalDescription {
25 | margin-bottom: 12px;
26 | }
27 |
28 | .modifierCode {
29 | min-width: 32px;
30 | text-align: center;
31 | }
32 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/AnonChat.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {TagInput} from 'rsuite';
3 | import Panel from 'rsuite/Panel';
4 | import Toggle from 'rsuite/Toggle';
5 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
6 | import {SettingIds, CategoryTypes} from '../../../../../constants.js';
7 | import formatMessage from '../../../../../i18n/index.js';
8 | import styles from '../../../styles/header.module.css';
9 | import {registerComponent} from '../../Store.jsx';
10 |
11 | const SETTING_NAME = formatMessage({defaultMessage: 'Anon Chat'});
12 |
13 | function AnonChat() {
14 | const [value, setValue] = useStorageState(SettingIds.ANON_CHAT);
15 | const [channels, setChannels] = useStorageState(
16 | value ? SettingIds.ANON_CHAT_WHITELISTED_CHANNELS : SettingIds.ANON_CHAT_BLACKLISTED_CHANNELS
17 | );
18 |
19 | return (
20 |
21 |
22 |
23 | {formatMessage({defaultMessage: 'Join chat anonymously without appearing in the userlist'})}
24 |
25 |
setValue(state)} />
26 |
27 |
28 |
29 | {value
30 | ? formatMessage({defaultMessage: 'Whitelist channels that bypass Anon Chat'})
31 | : formatMessage({defaultMessage: 'Blacklist channels that enable Anon Chat'})}
32 |
33 |
setChannels(newValue)}
37 | placeholder={formatMessage({defaultMessage: 'username, etc..'})}
38 | />
39 |
40 |
41 | );
42 | }
43 |
44 | registerComponent(AnonChat, {
45 | settingId: SettingIds.ANON_CHAT,
46 | name: SETTING_NAME,
47 | category: CategoryTypes.CHAT,
48 | keywords: ['anon', 'chat'],
49 | });
50 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/AutoJoinRaids.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {SettingIds, CategoryTypes} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Auto Join Raids'});
11 |
12 | function AutoJoinRaids() {
13 | const [value, setValue] = useStorageState(SettingIds.AUTO_JOIN_RAIDS);
14 |
15 | return (
16 |
17 |
18 |
{formatMessage({defaultMessage: 'Join raids automatically'})}
19 |
setValue(state)} />
20 |
21 |
22 | );
23 | }
24 |
25 | export default registerComponent(AutoJoinRaids, {
26 | settingId: SettingIds.AUTO_JOIN_RAIDS,
27 | name: SETTING_NAME,
28 | category: CategoryTypes.CHANNEL,
29 | keywords: ['auto', 'join', 'raids'],
30 | });
31 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/AutoModVIew.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {SettingIds, CategoryTypes} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Auto Mod View'});
11 |
12 | function AutoModView() {
13 | const [value, setValue] = useStorageState(SettingIds.AUTO_MOD_VIEW);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Enter mod view automatically when you enter channels you moderate'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | export default registerComponent(AutoModView, {
28 | settingId: SettingIds.AUTO_MOD_VIEW,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHANNEL,
31 | keywords: ['auto', 'mod', 'view'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/AutoTheatreMode.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {SettingIds, CategoryTypes} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Auto Theatre Mode'});
11 |
12 | function AutoTheatreMode() {
13 | const [value, setValue] = useStorageState(SettingIds.AUTO_THEATRE_MODE);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Enable theatre mode automatically'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(AutoTheatreMode, {
28 | settingId: SettingIds.AUTO_THEATRE_MODE,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHANNEL,
31 | keywords: ['auto', 'theatre', 'mode'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/ChatLayout.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import FormGroup from 'rsuite/FormGroup';
3 | import Panel from 'rsuite/Panel';
4 | import Radio from 'rsuite/Radio';
5 | import RadioGroup from 'rsuite/RadioGroup';
6 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
7 | import {CategoryTypes, SettingIds, ChatLayoutTypes} from '../../../../../constants.js';
8 | import formatMessage from '../../../../../i18n/index.js';
9 | import styles from '../../../styles/header.module.css';
10 | import {registerComponent} from '../../Store.jsx';
11 |
12 | const SETTING_NAME = formatMessage({defaultMessage: 'Chat Layout'});
13 |
14 | function ChatLayout() {
15 | const [value, setValue] = useStorageState(SettingIds.CHAT_LAYOUT);
16 |
17 | return (
18 |
19 |
20 |
{formatMessage({defaultMessage: 'Change the chat placement.'})}
21 |
22 | setValue(state)}>
23 |
24 |
25 |
{formatMessage({defaultMessage: 'Right'})}
26 |
27 | {formatMessage({defaultMessage: 'Moves the chat to the right of the player.'})}
28 |
29 |
30 |
31 |
32 |
33 |
{formatMessage({defaultMessage: 'Left'})}
34 |
35 | {formatMessage({defaultMessage: 'Moves the chat to the left of the player.'})}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
46 | registerComponent(ChatLayout, {
47 | settingId: SettingIds.CHAT_LAYOUT,
48 | name: SETTING_NAME,
49 | category: CategoryTypes.CHAT,
50 | keywords: ['chat', 'layout', 'position', 'placement', 'left', 'right'],
51 | });
52 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/ClickToPlay.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Click to Play'});
11 |
12 | function ClickToPlay() {
13 | const [value, setValue] = useStorageState(SettingIds.CLICK_TO_PLAY);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Enable clicking on the Twitch player to pause/resume playback'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(ClickToPlay, {
28 | settingId: SettingIds.CLICK_TO_PLAY,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHANNEL,
31 | keywords: ['click', 'play', 'player'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/EmoteMenu.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Toggle} from 'rsuite';
3 | import Panel from 'rsuite/Panel';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds, EmoteMenuTypes} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Emote Menu'});
11 |
12 | function EmoteMenu() {
13 | const [value, setValue] = useStorageState(SettingIds.EMOTE_MENU);
14 | const toggled = value !== EmoteMenuTypes.NONE;
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Enables a more advanced emote menu for chat'})}
20 |
21 |
setValue(state ? EmoteMenuTypes.ENABLED : EmoteMenuTypes.NONE)}
24 | />
25 |
26 | {toggled ? (
27 |
28 |
29 | {formatMessage({defaultMessage: "Replace Twitch's native emote menu."})}
30 |
31 |
setValue(state ? EmoteMenuTypes.ENABLED : EmoteMenuTypes.LEGACY_ENABLED)}
34 | />
35 |
36 | ) : null}
37 |
38 | );
39 | }
40 |
41 | registerComponent(EmoteMenu, {
42 | settingId: SettingIds.EMOTE_MENU,
43 | name: SETTING_NAME,
44 | category: CategoryTypes.CHAT,
45 | keywords: ['emotes', 'popup'],
46 | });
47 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/HighlightFeedback.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Highlight Feedback'});
11 |
12 | function HighlightFeedback() {
13 | const [value, setValue] = useStorageState(SettingIds.HIGHLIGHT_FEEDBACK);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Play a sound for messages directed at you'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(HighlightFeedback, {
28 | settingId: SettingIds.HIGHLIGHT_FEEDBACK,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHAT,
31 | keywords: ['highlight', 'feedback'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/HypeChat.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Hype Chat'});
11 |
12 | function HypeChat() {
13 | const [value, setValue] = useStorageState(SettingIds.HYPE_CHAT);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Show hype chat messages in the chat window'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(HypeChat, {
28 | settingId: SettingIds.HYPE_CHAT,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHAT,
31 | keywords: ['hype', 'chat', 'message'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/MuteInvisiblePlayer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Mute Invisible Player'});
11 |
12 | function MuteInvisiblePlayer() {
13 | const [value, setValue] = useStorageState(SettingIds.MUTE_INVISIBLE_PLAYER);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Mute/unmute streams automatically when you change your browser window/tab'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(MuteInvisiblePlayer, {
28 | settingId: SettingIds.MUTE_INVISIBLE_PLAYER,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHANNEL,
31 | keywords: ['mute', 'invisible', 'player', 'video'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/PinnedHighlights.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Input from 'rsuite/Input';
3 | import Panel from 'rsuite/Panel';
4 | import Toggle from 'rsuite/Toggle';
5 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
6 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
7 | import formatMessage from '../../../../../i18n/index.js';
8 | import styles from '../../../styles/header.module.css';
9 | import {registerComponent} from '../../Store.jsx';
10 |
11 | const SETTING_NAME = formatMessage({defaultMessage: 'Pinned Highlights'});
12 |
13 | function PinnedHighlights() {
14 | const [value, setValue] = useStorageState(SettingIds.PINNED_HIGHLIGHTS);
15 | const [maxPinnedHighlights, setMaxPinnedHighlights] = useStorageState(SettingIds.MAX_PINNED_HIGHLIGHTS);
16 | const [timeoutHighlightsValue, setTimeoutHighlightsValue] = useStorageState(SettingIds.TIMEOUT_HIGHLIGHTS);
17 |
18 | return (
19 |
20 |
21 |
22 | {formatMessage({defaultMessage: 'Pin your highlighted messages above chat'})}
23 |
24 |
25 |
26 |
27 |
{formatMessage({defaultMessage: 'Maximum pinned highlights'})}
28 |
37 |
38 |
39 |
40 | {formatMessage({defaultMessage: 'Hide pinned highlights after 1 minute'})}
41 |
42 |
setTimeoutHighlightsValue(state)} />
43 |
44 |
45 | );
46 | }
47 |
48 | registerComponent(PinnedHighlights, {
49 | settingId: SettingIds.PINNED_HIGHLIGHTS,
50 | name: SETTING_NAME,
51 | category: CategoryTypes.CHAT,
52 | keywords: ['pinned', 'highlights'],
53 | });
54 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/PlayerExtensions.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Player Extensions'});
11 |
12 | function HidePlayerExtensions() {
13 | const [value, setValue] = useStorageState(SettingIds.PLAYER_EXTENSIONS);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: "Show the interactive overlays on top of Twitch's video player"})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(HidePlayerExtensions, {
28 | settingId: SettingIds.PLAYER_EXTENSIONS,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHANNEL,
31 | keywords: ['player', 'extensions', 'addons'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/PrimePromotions.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Prime Promotions'});
11 |
12 | function HidePrimePromotions() {
13 | const [value, setValue] = useStorageState(SettingIds.PRIME_PROMOTIONS);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Show Prime Gaming loot notices, like the ones in the sidebar'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(HidePrimePromotions, {
28 | settingId: SettingIds.PRIME_PROMOTIONS,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHANNEL,
31 | keywords: ['ad', 'prime', 'promotions', 'block'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/ReverseChatDirection.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Reverse Chat Direction'});
11 |
12 | function ReverseChatDirection() {
13 | const [value, setValue] = useStorageState(SettingIds.REVERSE_CHAT_DIRECTION);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Move new chat messages to the top of chat'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(ReverseChatDirection, {
28 | settingId: SettingIds.REVERSE_CHAT_DIRECTION,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHAT,
31 | keywords: ['chat', 'direction', 'up', 'down', 'reverse'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/ScrollPlayerControls.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Scroll Player Controls'});
11 |
12 | function ScrollPlayerControls() {
13 | const [value, setValue] = useStorageState(SettingIds.SCROLL_PLAYER_CONTROLS);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({
20 | defaultMessage:
21 | 'Enable scrolling the Twitch player to change the player volume. Hold ALT when scrolling to seek.',
22 | })}
23 |
24 |
setValue(state)} />
25 |
26 |
27 | );
28 | }
29 |
30 | registerComponent(ScrollPlayerControls, {
31 | settingId: SettingIds.SCROLL_PLAYER_CONTROLS,
32 | name: SETTING_NAME,
33 | category: CategoryTypes.CHANNEL,
34 | keywords: ['volume', 'seek', 'control', 'scroll'],
35 | });
36 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/ShowDirectoryLiveTab.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Show Directory Live Tab'});
11 |
12 | function ShowDirectoryLiveTab() {
13 | const [value, setValue] = useStorageState(SettingIds.SHOW_DIRECTORY_LIVE_TAB);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Swap to Live tab on the Following page automatically'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(ShowDirectoryLiveTab, {
28 | settingId: SettingIds.SHOW_DIRECTORY_LIVE_TAB,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.DIRECTORY,
31 | keywords: ['live', 'tab'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/SplitChat.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import ColorPicker from '../../../../../common/components/ColorPicker.jsx';
5 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
6 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
7 | import formatMessage from '../../../../../i18n/index.js';
8 | import SplitChatModule from '../../../../split_chat/index.js';
9 | import styles from '../../../styles/header.module.css';
10 | import {registerComponent} from '../../Store.jsx';
11 |
12 | const SETTING_NAME = formatMessage({defaultMessage: 'Split Chat'});
13 |
14 | function SplitChat() {
15 | const [value, setValue] = useStorageState(SettingIds.SPLIT_CHAT);
16 | const [colorValue, setColorValue] = useStorageState(SettingIds.SPLIT_CHAT_COLOR);
17 | const defaultColor = SplitChatModule.getDefaultColor();
18 |
19 | function handleColorChange(newColor) {
20 | if (newColor == null || newColor === '' || newColor === defaultColor) {
21 | setColorValue(null);
22 | return;
23 | }
24 | setColorValue(newColor);
25 | }
26 |
27 | return (
28 |
29 |
30 |
31 | {formatMessage({defaultMessage: 'Alternate backgrounds between messages in chat to improve readability'})}
32 |
33 |
setValue(state)} />
34 |
35 |
36 |
{formatMessage({defaultMessage: 'Alternate background color'})}
37 |
handleColorChange(newColor)} />
38 |
39 |
40 | );
41 | }
42 |
43 | registerComponent(SplitChat, {
44 | settingId: SettingIds.SPLIT_CHAT,
45 | name: SETTING_NAME,
46 | category: CategoryTypes.CHAT,
47 | keywords: ['split', 'chat'],
48 | });
49 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/TabCompletionEmotePriority.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Tab Completion Emote Priority'});
11 |
12 | function TabCompletionEmotePriority() {
13 | const [value, setValue] = useStorageState(SettingIds.TAB_COMPLETION_EMOTE_PRIORITY);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Prioritize emotes over usernames when using tab completion'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(TabCompletionEmotePriority, {
28 | settingId: SettingIds.TAB_COMPLETION_EMOTE_PRIORITY,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHAT,
31 | keywords: ['tab', 'completion', 'emote', 'priority'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/Theme.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Theme'});
11 |
12 | function Theme() {
13 | const [darkThemeValue, setDarkThemeValue] = useStorageState(SettingIds.DARKENED_MODE);
14 | const [autoThemeValue, setAutoThemeValue] = useStorageState(SettingIds.AUTO_THEME_MODE);
15 |
16 | return (
17 |
18 |
19 |
{formatMessage({defaultMessage: 'Dark theme'})}
20 |
setDarkThemeValue(state)} disabled={autoThemeValue} />
21 |
22 |
23 |
24 | {formatMessage({defaultMessage: "Automatically set dark theme from your system's theme"})}
25 |
26 |
setAutoThemeValue(state)} />
27 |
28 |
29 | );
30 | }
31 |
32 | registerComponent(Theme, {
33 | settingId: SettingIds.DARKENED_MODE,
34 | name: SETTING_NAME,
35 | category: CategoryTypes.CHANNEL,
36 | keywords: ['dark', 'mode', 'light', 'theme', 'white', 'black'],
37 | });
38 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/twitch/Whispers.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Whispers'});
11 |
12 | function DisableWhispers() {
13 | const [value, setValue] = useStorageState(SettingIds.WHISPERS);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Enable Twitch whispers and show any whispers you receive'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | registerComponent(DisableWhispers, {
28 | settingId: SettingIds.WHISPERS,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHAT,
31 | keywords: ['whispers', 'direct', 'messages'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/youtube/AutoLiveChatView.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Auto Live Chat View'});
11 |
12 | function EmoteAutoLiveChatView() {
13 | const [value, setValue] = useStorageState(SettingIds.AUTO_LIVE_CHAT_VIEW);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Switch to the Live Chat view automatically when chat loads'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | export default registerComponent(EmoteAutoLiveChatView, {
28 | settingId: SettingIds.AUTO_LIVE_CHAT_VIEW,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHAT,
31 | keywords: ['auto', 'live', 'chat', 'view'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/youtube/EmoteAutocomplete.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {CategoryTypes, SettingIds} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Emote Autocomplete'});
11 |
12 | function EmoteAutocomplete() {
13 | const [value, setValue] = useStorageState(SettingIds.EMOTE_AUTOCOMPLETE);
14 |
15 | return (
16 |
17 |
18 |
19 | {formatMessage({defaultMessage: 'Typing : before text will attempt to autocomplete your emote'})}
20 |
21 |
setValue(state)} />
22 |
23 |
24 | );
25 | }
26 |
27 | export default registerComponent(EmoteAutocomplete, {
28 | settingId: SettingIds.EMOTE_AUTOCOMPLETE,
29 | name: SETTING_NAME,
30 | category: CategoryTypes.CHAT,
31 | keywords: ['auto', 'autocomplete', 'emote', ':'],
32 | });
33 |
--------------------------------------------------------------------------------
/src/modules/settings/components/settings/youtube/EmoteMenu.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Panel from 'rsuite/Panel';
3 | import Toggle from 'rsuite/Toggle';
4 | import useStorageState from '../../../../../common/hooks/StorageState.jsx';
5 | import {SettingIds, CategoryTypes, EmoteMenuTypes} from '../../../../../constants.js';
6 | import formatMessage from '../../../../../i18n/index.js';
7 | import styles from '../../../styles/header.module.css';
8 | import {registerComponent} from '../../Store.jsx';
9 |
10 | const SETTING_NAME = formatMessage({defaultMessage: 'Emote Menu'});
11 |
12 | function EmoteMenu() {
13 | const [value, setValue] = useStorageState(SettingIds.EMOTE_MENU);
14 | const isEnabled = value !== EmoteMenuTypes.NONE;
15 |
16 | return (
17 |
18 |
19 |
20 | {formatMessage({defaultMessage: 'Enables a more advanced emote menu for chat'})}
21 |
22 |
setValue(state ? EmoteMenuTypes.ENABLED : EmoteMenuTypes.NONE)}
25 | />
26 |
27 |
28 | );
29 | }
30 |
31 | registerComponent(EmoteMenu, {
32 | settingId: SettingIds.EMOTE_MENU,
33 | name: SETTING_NAME,
34 | category: CategoryTypes.CHAT,
35 | keywords: ['emotes', 'popup'],
36 | });
37 |
--------------------------------------------------------------------------------
/src/modules/settings/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../../constants.js';
2 | import {loadModuleForPlatforms} from '../../utils/modules.js';
3 | import TwitchSettingsModule from './twitch/Settings.jsx';
4 | import YoutubeSettingModule from './youtube/Settings.jsx';
5 |
6 | const settings = {
7 | openSettings: () => {},
8 | };
9 |
10 | loadModuleForPlatforms(
11 | [PlatformTypes.TWITCH, async () => new TwitchSettingsModule()],
12 | [PlatformTypes.YOUTUBE, async () => new YoutubeSettingModule()]
13 | ).then((resolvedSettings) => {
14 | settings.openSettings = resolvedSettings.openSettings;
15 | });
16 |
17 | export default settings;
18 |
--------------------------------------------------------------------------------
/src/modules/settings/pages/ChannelSettings.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React, {useState} from 'react';
3 | import PanelGroup from 'rsuite/PanelGroup';
4 | import {CategoryTypes} from '../../../constants.js';
5 | import formatMessage from '../../../i18n/index.js';
6 | import CloseButton from '../components/CloseButton.jsx';
7 | import {Settings, Search} from '../components/Settings.jsx';
8 | import styles from '../styles/header.module.css';
9 |
10 | function ChannelSettings({onClose, defaultSearchInput}) {
11 | const [search, setSearch] = useState(defaultSearchInput ?? '');
12 |
13 | return (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 | setSearch(newValue)}
24 | />
25 |
26 |
27 |
28 | >
29 | );
30 | }
31 |
32 | export default ChannelSettings;
33 |
--------------------------------------------------------------------------------
/src/modules/settings/pages/ChatSettings.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React, {useState} from 'react';
3 | import PanelGroup from 'rsuite/PanelGroup';
4 | import {CategoryTypes} from '../../../constants.js';
5 | import formatMessage from '../../../i18n/index.js';
6 | import CloseButton from '../components/CloseButton.jsx';
7 | import {Settings, Search} from '../components/Settings.jsx';
8 | import styles from '../styles/header.module.css';
9 |
10 | function ChatSettings({onClose, defaultSearchInput}) {
11 | const [search, setSearch] = useState(defaultSearchInput ?? '');
12 |
13 | return (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 | setSearch(newValue)}
24 | />
25 |
26 |
27 |
28 | >
29 | );
30 | }
31 |
32 | export default ChatSettings;
33 |
--------------------------------------------------------------------------------
/src/modules/settings/pages/DirectorySettings.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React, {useState} from 'react';
3 | import PanelGroup from 'rsuite/PanelGroup';
4 | import {CategoryTypes} from '../../../constants.js';
5 | import formatMessage from '../../../i18n/index.js';
6 | import CloseButton from '../components/CloseButton.jsx';
7 | import {Settings, Search} from '../components/Settings.jsx';
8 | import styles from '../styles/header.module.css';
9 |
10 | function DirectorySettings({onClose, defaultSearchInput}) {
11 | const [search, setSearch] = useState(defaultSearchInput ?? '');
12 |
13 | return (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 | setSearch(newValue)}
24 | />
25 |
26 |
27 |
28 | >
29 | );
30 | }
31 |
32 | export default DirectorySettings;
33 |
--------------------------------------------------------------------------------
/src/modules/settings/styles/about.module.css:
--------------------------------------------------------------------------------
1 | .socials {
2 | column-count: 3;
3 |
4 | ul {
5 | list-style-type: none;
6 | }
7 |
8 | a {
9 | cursor: pointer;
10 | }
11 | }
12 |
13 | .buttons {
14 | width: 250px;
15 | display: flex;
16 | flex-direction: column;
17 | gap: 10px;
18 |
19 | .button {
20 | width: 100%;
21 | }
22 | }
23 |
24 | .largeBlurb {
25 | font-size: 22px;
26 | margin-right: 56px;
27 | color: var(--bttv-rs-text-heading);
28 | }
29 |
--------------------------------------------------------------------------------
/src/modules/settings/styles/header.module.css:
--------------------------------------------------------------------------------
1 | .heading {
2 | color: var(--bttv-rs-text-heading);
3 | }
4 |
5 | .upper {
6 | text-transform: uppercase;
7 | }
8 |
9 | .description {
10 | color: var(--bttv-rs-text-secondary);
11 | }
12 |
13 | .header {
14 | position: absolute;
15 | right: 14px;
16 | left: 56px;
17 | top: 0;
18 | z-index: 10;
19 | overflow: hidden;
20 | pointer-events: none;
21 |
22 | .flexHeader {
23 | pointer-events: all;
24 | display: flex;
25 | column-gap: 10px;
26 | padding: 10px 0 10px 10px;
27 | background-color: var(--bttv-rs-bg-overlay);
28 | }
29 |
30 | .closeButton {
31 | pointer-events: all;
32 | padding: 10px 0 10px 10px;
33 | float: right;
34 | }
35 | }
36 |
37 | .setting {
38 | display: flex;
39 | flex-direction: column;
40 | row-gap: 16px;
41 | }
42 |
43 | .settingDescription {
44 | color: var(--bttv-rs-text-secondary);
45 | margin-right: 8px;
46 | }
47 |
48 | .content {
49 | composes: scroll from '../../emote_menu/styles/Scrollbar.module.css';
50 | overflow-y: scroll;
51 | height: 500px;
52 | margin-left: 56px;
53 | }
54 |
55 | .center {
56 | width: 100%;
57 | height: 100%;
58 | display: flex;
59 | justify-content: center;
60 | align-items: center;
61 | }
62 |
63 | .divider {
64 | padding-top: 50px;
65 | }
66 |
67 | .settingRow {
68 | display: flex;
69 | vertical-align: middle;
70 | align-items: center;
71 | justify-content: space-between;
72 |
73 | + .settingRow {
74 | margin-top: 8px;
75 | }
76 | }
77 |
78 | .settingInputNumber {
79 | width: 72px;
80 | }
81 |
82 | .settingTitle {
83 | color: var(--bttv-rs-text-heading);
84 | font-size: 20px;
85 | }
86 |
87 | .settingTagInput {
88 | width: 300px;
89 |
90 | input {
91 | color: var(--bttv-rs-text-primary);
92 | }
93 | }
94 |
95 | .panelWithOverflow {
96 | overflow: visible;
97 | }
98 |
99 | .codeBlock {
100 | background-color: var(--bttv-rs-sidenav-default-bg);
101 | padding: 2px 4px;
102 | border-radius: 4px;
103 | font-family: monospace;
104 | white-space: nowrap;
105 | }
106 |
--------------------------------------------------------------------------------
/src/modules/settings/styles/popout.module.css:
--------------------------------------------------------------------------------
1 | .standaloneChatWindow {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 | bottom: 0;
6 | right: 0;
7 | background-color: var(--bttv-rs-bg-overlay);
8 | z-index: 10;
9 |
10 | .header {
11 | display: flex;
12 | background-color: var(--bttv-rs-bg-well);
13 | align-items: center;
14 | padding: 10px;
15 | column-gap: 10px;
16 | filter: drop-shadow(0 0.2rem 0.25rem rgba(0, 0, 0, 0.2));
17 |
18 | .search {
19 | flex-grow: 4;
20 |
21 | input {
22 | box-sizing: border-box;
23 | }
24 | }
25 |
26 | .logo {
27 | box-sizing: unset;
28 | width: 36px;
29 | height: 36px;
30 | padding: 10px;
31 | margin-top: 5px;
32 | margin-bottom: 5px;
33 | transform: scaleX(-1);
34 | object-fit: contain;
35 | }
36 | }
37 | }
38 |
39 | .chatWindowContent {
40 | composes: scroll from '../../emote_menu/styles/Scrollbar.module.css';
41 | height: calc(100% - 76px);
42 | }
43 |
--------------------------------------------------------------------------------
/src/modules/settings/styles/sidenav.module.css:
--------------------------------------------------------------------------------
1 | .logoContainer {
2 | margin: 14px;
3 | color: var(--bttv-brand-color);
4 |
5 | .logo {
6 | width: 28px;
7 | height: 28px;
8 | overflow: hidden;
9 | }
10 | }
11 |
12 | .body {
13 | display: flex;
14 | flex-direction: column;
15 | justify-content: space-between;
16 | height: 100%;
17 | }
18 |
19 | .sidenav {
20 | position: fixed;
21 | height: 500px;
22 | margin: 0;
23 | padding: 0;
24 | border-radius: 5px 0px 0px 4px;
25 | transition: width 0.35s ease-in-out;
26 | overflow: hidden;
27 | z-index: 6;
28 | }
29 |
30 | .nav ul {
31 | display: flex;
32 | flex-direction: column;
33 | }
34 |
35 | .nav {
36 | border-radius: 5px;
37 | }
38 |
--------------------------------------------------------------------------------
/src/modules/settings/styles/table.module.css:
--------------------------------------------------------------------------------
1 | .input .bttv-rs-input {
2 | margin-left: 6px;
3 | position: absolute;
4 | left: 0;
5 | top: 5px;
6 | width: 100%;
7 | margin-right: 6px;
8 | }
9 |
10 | .action {
11 | float: right;
12 | margin-right: 4px;
13 | padding-right: 8px;
14 | padding-left: 4px;
15 |
16 | & + .action {
17 | padding-right: 4px;
18 | }
19 | }
20 |
21 | .actionCell {
22 | padding: 6px 0;
23 | }
24 |
25 | .dropdown {
26 | padding: 5px;
27 | }
28 |
29 | .text {
30 | margin-top: 12px;
31 | margin-left: 18px;
32 | font-size: 14px;
33 | }
34 |
35 | .tableContentEditing {
36 | margin-left: 6px;
37 | position: absolute;
38 | left: 0px;
39 | width: 95%;
40 | top: 7px;
41 | height: 30px;
42 | }
43 |
44 | .tableContentHovering {
45 | margin-left: 6px;
46 | position: absolute;
47 | left: 0px;
48 | width: 95%;
49 | top: 7px;
50 | height: 30px;
51 | opacity: 0.5;
52 | }
53 |
54 | .button {
55 | width: 25%;
56 | min-width: 150px;
57 | }
58 |
59 | .container {
60 | border-radius: 4px;
61 | position: relative;
62 | }
63 |
64 | .additionalSettingsDarken {
65 | opacity: 0.3;
66 | }
67 |
68 | .table {
69 | margin-bottom: 8px;
70 | }
71 |
72 | .additionalSettings {
73 | background-color: var(--bttv-rs-sidenav-default-bg);
74 | position: absolute;
75 | top: 20px;
76 | left: 20px;
77 | right: 20px;
78 | border-radius: 4px;
79 | z-index: 2;
80 | padding: 16px;
81 | max-height: 240px;
82 | }
83 |
84 | .additionalSettingsClose {
85 | position: absolute;
86 | top: 8px;
87 | right: 8px;
88 | }
89 |
90 | .header div {
91 | margin-left: 4px;
92 | }
93 |
--------------------------------------------------------------------------------
/src/modules/settings/styles/window.module.css:
--------------------------------------------------------------------------------
1 | .page {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: center;
5 | margin-left: 56px;
6 | padding-right: 27px;
7 | }
8 |
9 | .closeButton {
10 | position: absolute;
11 | padding: 10px;
12 | right: 0px;
13 | z-index: 5;
14 | }
15 |
--------------------------------------------------------------------------------
/src/modules/settings/youtube/DropdownButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import formatMessage from '../../../i18n/index.js';
3 | import styles from './Settings.module.css';
4 |
5 | export default function DropdownButton(props) {
6 | return (
7 |
8 |
11 |
{formatMessage({defaultMessage: 'BetterTTV Settings'})}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/modules/seventv/global-emotes.js:
--------------------------------------------------------------------------------
1 | import {EmoteCategories, EmoteProviders, EmoteTypeFlags, SettingIds} from '../../constants.js';
2 | import formatMessage from '../../i18n/index.js';
3 | import settings from '../../settings.js';
4 | import {hasFlag} from '../../utils/flags.js';
5 | import watcher from '../../watcher.js';
6 | import AbstractEmotes from '../emotes/abstract-emotes.js';
7 | import {createEmote, isOverlay} from './utils.js';
8 |
9 | const category = {
10 | id: EmoteCategories.SEVENTV_GLOBAL,
11 | provider: EmoteProviders.SEVENTV,
12 | displayName: formatMessage({defaultMessage: '7TV Global Emotes'}),
13 | };
14 |
15 | class SevenTVGlobalEmotes extends AbstractEmotes {
16 | constructor() {
17 | super();
18 |
19 | settings.on(`changed.${SettingIds.EMOTES}`, () => this.updateGlobalEmotes());
20 |
21 | this.updateGlobalEmotes();
22 | }
23 |
24 | get category() {
25 | return category;
26 | }
27 |
28 | updateGlobalEmotes() {
29 | this.emotes.clear();
30 |
31 | if (!hasFlag(settings.get(SettingIds.EMOTES), EmoteTypeFlags.SEVENTV_EMOTES)) return;
32 |
33 | fetch(`https://7tv.io/v3/emote-sets/global`)
34 | .then((response) => response.json())
35 | .then(({emotes: globalEmotes}) => {
36 | if (globalEmotes == null) {
37 | return;
38 | }
39 |
40 | for (const {
41 | id,
42 | name: code,
43 | data: {
44 | listed,
45 | animated,
46 | owner,
47 | flags,
48 | host: {url},
49 | },
50 | } of globalEmotes) {
51 | if (!listed && !hasFlag(settings.get(SettingIds.EMOTES), EmoteTypeFlags.SEVENTV_UNLISTED_EMOTES)) {
52 | continue;
53 | }
54 |
55 | this.emotes.set(code, createEmote(id, code, animated, owner, category, isOverlay(flags), url));
56 | }
57 | })
58 | .then(() => watcher.emit('emotes.updated'));
59 | }
60 | }
61 |
62 | export default new SevenTVGlobalEmotes();
63 |
--------------------------------------------------------------------------------
/src/modules/seventv/index.js:
--------------------------------------------------------------------------------
1 | import domObserver from '../../observers/dom.js';
2 |
3 | const BTTV_GLOBAL_MIXIN = '__BTTV_GLOBAL_MIXIN__';
4 | const SEVEN_TV_ROOT_ID = 'seventv-root';
5 |
6 | class SevenTV {
7 | constructor() {
8 | domObserver.on(`#${SEVEN_TV_ROOT_ID}`, (_, isConnected) => {
9 | if (!isConnected) {
10 | return;
11 | }
12 | this.applyGlobalMixin();
13 | });
14 | }
15 |
16 | getSeventvVueApp() {
17 | const root = document.getElementById(SEVEN_TV_ROOT_ID);
18 | if (root == null) {
19 | return null;
20 | }
21 | return root.__vue_app__;
22 | }
23 |
24 | applyGlobalMixin() {
25 | const vueApp = this.getSeventvVueApp();
26 | if (vueApp == null) {
27 | return;
28 | }
29 | const mixins = vueApp?._context?.mixins;
30 | if (mixins == null || !Array.isArray(mixins)) {
31 | return;
32 | }
33 | const globalMixin = mixins.find((mixin) => mixin?.__name === BTTV_GLOBAL_MIXIN);
34 | if (globalMixin != null) {
35 | return;
36 | }
37 | vueApp.mixin({
38 | __name: BTTV_GLOBAL_MIXIN,
39 | mounted() {
40 | this.$el.__bttv_seventv_instance = this;
41 | },
42 | beforeUnmount() {
43 | if (this.$el.__bttv_seventv_instance !== this) {
44 | return;
45 | }
46 | delete this.$el.__bttv_seventv_instance;
47 | },
48 | });
49 | }
50 |
51 | getElementInstance(element) {
52 | const instance = element?.__bttv_seventv_instance?.$;
53 | if (instance == null) {
54 | return null;
55 | }
56 | return instance;
57 | }
58 | }
59 |
60 | export default new SevenTV();
61 |
--------------------------------------------------------------------------------
/src/modules/seventv/utils.js:
--------------------------------------------------------------------------------
1 | import {hasFlag} from '../../utils/flags.js';
2 | import Emote from '../emotes/emote.js';
3 |
4 | function emoteUrl(url, version, static_ = false) {
5 | return `${url}/${version}${static_ ? '_static' : ''}.webp`;
6 | }
7 |
8 | export function createEmote(id, code, animated, owner, category, overlay, url) {
9 | return new Emote({
10 | id,
11 | category,
12 | channel: {
13 | id: owner?.id ?? '0',
14 | name: owner?.username ?? owner?.login ?? 'deleted_user',
15 | displayName: owner?.display_name ?? 'Deleted User',
16 | },
17 | code,
18 | animated,
19 | images: {
20 | '1x': emoteUrl(url, '1x'),
21 | '2x': emoteUrl(url, '2x'),
22 | '4x': emoteUrl(url, '4x'),
23 | '1x_static': animated ? emoteUrl(url, '1x', true) : undefined,
24 | '2x_static': animated ? emoteUrl(url, '2x', true) : undefined,
25 | '4x_static': animated ? emoteUrl(url, '4x', true) : undefined,
26 | },
27 | metadata: {
28 | isOverlay: overlay,
29 | },
30 | });
31 | }
32 |
33 | export function isOverlay(flags, isLegacy = false) {
34 | if (flags == null) {
35 | return false;
36 | }
37 |
38 | return hasFlag(flags, isLegacy ? 1 << 7 : 1 << 8);
39 | }
40 |
--------------------------------------------------------------------------------
/src/modules/split_chat/index.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {loadModuleForPlatforms} from '../../utils/modules.js';
4 |
5 | let alternateBackground = false;
6 | const DEFAULT_LIGHT_THEME_COLOR = '#dcdcdc';
7 | const DEFAULT_DARK_THEME_COLOR = '#1f1925';
8 |
9 | class SplitChatModule {
10 | render(el, msgObject = {}) {
11 | if (settings.get(SettingIds.SPLIT_CHAT) === false) {
12 | return;
13 | }
14 |
15 | const oldAlternateBackground = msgObject.__bttvAlternateBackground;
16 | if (oldAlternateBackground == null) {
17 | msgObject.__bttvAlternateBackground = alternateBackground;
18 | }
19 |
20 | if (msgObject.__bttvAlternateBackground) {
21 | el.classList.add('bttv-split-chat-alt-bg');
22 |
23 | const backgroundColor = settings.get(SettingIds.SPLIT_CHAT_COLOR);
24 | if (backgroundColor != null && !el.style.backgroundColor) {
25 | el.style.backgroundColor = backgroundColor;
26 | }
27 | }
28 |
29 | // only alternate for new messages
30 | if (oldAlternateBackground == null) {
31 | alternateBackground = !alternateBackground;
32 | }
33 | }
34 |
35 | getDefaultColor() {
36 | return document.querySelector('.tw-root--theme-dark') != null
37 | ? DEFAULT_DARK_THEME_COLOR
38 | : DEFAULT_LIGHT_THEME_COLOR;
39 | }
40 | }
41 |
42 | export default loadModuleForPlatforms([PlatformTypes.TWITCH, () => new SplitChatModule()]);
43 |
--------------------------------------------------------------------------------
/src/modules/split_chat/style.css:
--------------------------------------------------------------------------------
1 | .chat-line__message.bttv-split-chat-alt-bg,
2 | .vod-message.bttv-split-chat-alt-bg {
3 | background-color: #dcdcdc;
4 | }
5 |
6 | .tw-root--theme-dark {
7 | .chat-line__message.bttv-split-chat-alt-bg,
8 | .vod-message.bttv-split-chat-alt-bg {
9 | background-color: #1f1925;
10 | }
11 | }
12 |
13 | section[data-test-selector='chat-room-component-layout'] .simplebar-track.vertical {
14 | z-index: 2;
15 | }
16 |
--------------------------------------------------------------------------------
/src/modules/subscribers/index.js:
--------------------------------------------------------------------------------
1 | import socketClient, {EventNames} from '../../socket-client.js';
2 | import {getCurrentChannel} from '../../utils/channel.js';
3 | import twitch from '../../utils/twitch.js';
4 |
5 | const users = new Map();
6 |
7 | function updateSubscription({providerId, subscribed, glow, badge}) {
8 | users.set(providerId, {
9 | badge,
10 | subscribed,
11 | glow,
12 | });
13 | }
14 |
15 | function legacyNewSubscriber({user}) {
16 | if (getCurrentChannel().name !== 'night') {
17 | return;
18 | }
19 |
20 | twitch.sendChatAdminMessage(`${user} just subscribed!`);
21 | }
22 |
23 | class SubscribersModule {
24 | constructor() {
25 | socketClient.on(EventNames.LOOKUP_USER, (d) => updateSubscription(d));
26 | socketClient.on(EventNames.NEW_SUBSCRIBER, (d) => legacyNewSubscriber(d));
27 | }
28 |
29 | hasGlow(providerId) {
30 | return users.get(providerId)?.glow ?? false;
31 | }
32 |
33 | hasLegacySubscription(providerId) {
34 | return users.get(providerId)?.subscribed ?? false;
35 | }
36 |
37 | hasSubscription(providerId) {
38 | return users.get(providerId) != null;
39 | }
40 |
41 | getSubscriptionBadge(providerId) {
42 | return users.get(providerId)?.badge ?? null;
43 | }
44 | }
45 |
46 | export default new SubscribersModule();
47 |
--------------------------------------------------------------------------------
/src/modules/video_player/style.css:
--------------------------------------------------------------------------------
1 | .bttv-hide-player-extensions {
2 | .extensions-dock__layout,
3 | .extensions-video-overlay-size-container,
4 | .extensions-notifications,
5 | .extension-container,
6 | .extension-taskbar {
7 | display: none !important;
8 | }
9 | }
10 |
11 | .bttv-hide-player-cursor {
12 | div[data-test-selector='video-player__video-container'] {
13 | cursor: none;
14 | }
15 | }
16 |
17 | #bttv-picture-in-picture {
18 | display: inline-flex !important;
19 |
20 | button {
21 | position: relative;
22 | vertical-align: middle;
23 | overflow: hidden;
24 | text-decoration: none;
25 | white-space: nowrap;
26 | font-weight: var(--font-weight-semibold);
27 | border-radius: var(--border-radius-medium);
28 | font-size: var(--button-text-default);
29 | display: inline-flex;
30 | -webkit-box-align: center;
31 | align-items: center;
32 | -webkit-box-pack: center;
33 | justify-content: center;
34 | user-select: none;
35 | height: var(--button-size-default);
36 | width: var(--button-size-default);
37 | border: var(--border-width-default) solid transparent;
38 | background-color: var(--color-background-button-icon-overlay-default);
39 | color: var(--color-text-button-overlay);
40 | padding: 4px;
41 |
42 | &:hover {
43 | color: var(--color-text-button-overlay-hover);
44 | background-color: var(--color-background-button-icon-overlay-hover);
45 | }
46 |
47 | div {
48 | width: 2rem;
49 | height: 2rem;
50 | }
51 | }
52 |
53 | svg {
54 | fill: currentColor;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/modules/youtube/index.js:
--------------------------------------------------------------------------------
1 | import {EmoteTypeFlags, PlatformTypes, SettingIds} from '../../constants.js';
2 | import settings from '../../settings.js';
3 | import {hasFlag} from '../../utils/flags.js';
4 | import {loadModuleForPlatforms} from '../../utils/modules.js';
5 | import watcher from '../../watcher.js';
6 | import chat from '../chat/index.js';
7 |
8 | const CHAT_MESSAGE_SELECTOR = '#content #message,#content #content-text';
9 | const CHAT_BADGES_CONTAINER_SELECTOR = '#chat-badges';
10 |
11 | class YouTubeModule {
12 | constructor() {
13 | watcher.on('load.youtube', () => this.load());
14 | settings.on(`changed.${SettingIds.AUTO_LIVE_CHAT_VIEW}`, () => this.load());
15 | watcher.on('youtube.message', (el, messageObj) => this.parseMessage(el, messageObj));
16 | }
17 |
18 | load() {
19 | if (settings.get(SettingIds.AUTO_LIVE_CHAT_VIEW)) {
20 | document.querySelector('#live-chat-view-selector-sub-menu #trigger')?.click();
21 | document.querySelector('#live-chat-view-selector-sub-menu #dropdown a:nth-child(2)')?.click();
22 | }
23 | }
24 |
25 | parseMessage(element, message) {
26 | const mockUser = {
27 | id: message?.authorExternalChannelId,
28 | name: message?.authorExternalChannelId,
29 | displayName: message?.authorName?.simpleText,
30 | };
31 |
32 | const emotesSettingValue = settings.get(SettingIds.EMOTES);
33 | const handleAnimatedEmotes =
34 | !hasFlag(emotesSettingValue, EmoteTypeFlags.ANIMATED_PERSONAL_EMOTES) ||
35 | !hasFlag(emotesSettingValue, EmoteTypeFlags.ANIMATED_EMOTES);
36 | if (handleAnimatedEmotes) {
37 | element.addEventListener('mousemove', chat.handleEmoteMouseEvent);
38 | }
39 |
40 | const customBadges = chat.customBadges(mockUser);
41 | const badgesContainer = element.querySelector(CHAT_BADGES_CONTAINER_SELECTOR);
42 | if (
43 | customBadges.length > 0 &&
44 | badgesContainer != null &&
45 | element.getElementsByClassName(customBadges[0].className)[0] == null
46 | ) {
47 | for (const badge of customBadges) {
48 | badgesContainer.after(badge);
49 | }
50 | }
51 |
52 | chat.messageReplacer(element.querySelector(CHAT_MESSAGE_SELECTOR), mockUser);
53 | }
54 | }
55 |
56 | export default loadModuleForPlatforms([PlatformTypes.YOUTUBE, () => new YouTubeModule()]);
57 |
--------------------------------------------------------------------------------
/src/modules/youtube/style.css:
--------------------------------------------------------------------------------
1 | #message.yt-live-chat-text-message-renderer,
2 | yt-live-chat-text-message-renderer {
3 | contain: none;
4 | }
5 |
6 | yt-live-chat-text-message-renderer .bttv-tooltip {
7 | background-color: var(--yt-live-chat-vem-background-color);
8 | color: var(--yt-live-chat-primary-text-color);
9 |
10 | &:after {
11 | background-color: var(--yt-live-chat-vem-background-color);
12 | }
13 | }
14 |
15 | yt-live-chat-text-message-renderer .bttv-chat-badge-container {
16 | align-self: center;
17 | margin-left: 4px;
18 | }
19 |
--------------------------------------------------------------------------------
/src/observers/history.js:
--------------------------------------------------------------------------------
1 | import SafeEventEmitter from '../utils/safe-event-emitter.js';
2 |
3 | class HistoryObserver extends SafeEventEmitter {
4 | constructor() {
5 | super();
6 |
7 | const {history, location} = window;
8 | const {pushState, replaceState} = history;
9 |
10 | history.pushState = (...args) => {
11 | const state = args[0];
12 | pushState.apply(history, args);
13 | this.emit('pushState', location, state);
14 | };
15 | history.replaceState = (...args) => {
16 | const state = args[0];
17 | replaceState.apply(history, args);
18 | this.emit('replaceState', location, state);
19 | };
20 | window.addEventListener('popstate', ({state}) => this.emit('popState', location, state));
21 | }
22 | }
23 |
24 | export default new HistoryObserver();
25 |
--------------------------------------------------------------------------------
/src/storage.js:
--------------------------------------------------------------------------------
1 | import cookies from 'cookies-js';
2 |
3 | class Storage {
4 | constructor() {
5 | this._cache = {};
6 | this._prefix = 'bttv_';
7 | this._localStorageSupport = true;
8 |
9 | try {
10 | window.localStorage.setItem('bttv_test', 'it works!');
11 | window.localStorage.removeItem('bttv_test');
12 | } catch (e) {
13 | this._localStorageSupport = false;
14 | }
15 | }
16 |
17 | get localStorageSupport() {
18 | return this._localStorageSupport;
19 | }
20 |
21 | getStorage() {
22 | const storage = {};
23 |
24 | if (!this._localStorageSupport) {
25 | return storage;
26 | }
27 |
28 | Object.keys(window.localStorage)
29 | .filter((id) => id.startsWith('bttv_'))
30 | .forEach((id) => {
31 | storage[id] = this.get(id, null);
32 | });
33 |
34 | return storage;
35 | }
36 |
37 | get(id, prefix = this._prefix) {
38 | if (prefix) {
39 | id = prefix + id;
40 | }
41 |
42 | if (id in this._cache) {
43 | return this._cache[id];
44 | }
45 |
46 | try {
47 | const storageValue = this._localStorageSupport ? window.localStorage.getItem(id) : cookies.get(id);
48 | if (storageValue == null) {
49 | return null;
50 | }
51 |
52 | return JSON.parse(storageValue);
53 | } catch (e) {
54 | return null;
55 | }
56 | }
57 |
58 | set(id, value, prefix = this._prefix) {
59 | let storageId = id;
60 | if (prefix) {
61 | storageId = prefix + id;
62 | }
63 |
64 | this._cache[storageId] = value;
65 |
66 | value = JSON.stringify(value);
67 |
68 | if (this._localStorageSupport) {
69 | window.localStorage.setItem(storageId, value);
70 | } else {
71 | cookies.set(storageId, value, {expires: Infinity});
72 | }
73 | }
74 | }
75 |
76 | export default new Storage();
77 |
--------------------------------------------------------------------------------
/src/utils/api.js:
--------------------------------------------------------------------------------
1 | import HTTPError from './http-error.js';
2 |
3 | const API_ENDPOINT = 'https://api.betterttv.net/3/';
4 |
5 | function request(method, path, options = {}) {
6 | const {searchParams} = options;
7 | delete options.searchParams;
8 |
9 | return fetch(`${API_ENDPOINT}${path}${searchParams ? `?${new URLSearchParams(searchParams).toString()}` : ''}`, {
10 | method,
11 | ...options,
12 | }).then(async (response) => {
13 | if (response.ok) {
14 | return response.json();
15 | }
16 |
17 | let responseJSON;
18 | try {
19 | responseJSON = await response.json();
20 | } catch (err) {
21 | throw new HTTPError(response.status, null);
22 | }
23 |
24 | throw new HTTPError(response.status, responseJSON);
25 | });
26 | }
27 |
28 | export default {
29 | get(path, options) {
30 | return request('GET', path, options);
31 | },
32 |
33 | post(path, options) {
34 | return request('POST', path, options);
35 | },
36 |
37 | put(path, options) {
38 | return request('PUT', path, options);
39 | },
40 |
41 | patch(path, options) {
42 | return request('PATCH', path, options);
43 | },
44 |
45 | delete(path, options) {
46 | return request('DELETE', path, options);
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/src/utils/cdn.js:
--------------------------------------------------------------------------------
1 | import {EXT_VER, CDN_ENDPOINT} from '../constants.js';
2 |
3 | export default {
4 | url(path, breakCache = false) {
5 | return `${CDN_ENDPOINT}${path}${breakCache ? `?v=${EXT_VER}` : ''}`;
6 | },
7 |
8 | emoteUrl(emoteId, version = '3x', static_ = false) {
9 | return this.url(`emote/${emoteId}${static_ ? '/static' : ''}/${version}.webp`);
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/src/utils/channel.js:
--------------------------------------------------------------------------------
1 | let currentChannel;
2 |
3 | export function setCurrentChannel({provider, id, name, displayName, avatar}) {
4 | currentChannel = {
5 | provider,
6 | id: id.toString(),
7 | name,
8 | displayName,
9 | avatar,
10 | };
11 | }
12 |
13 | export function getCurrentChannel() {
14 | return currentChannel;
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/debug.js:
--------------------------------------------------------------------------------
1 | import storage from '../storage.js';
2 |
3 | const {console} = window;
4 |
5 | function log(type, ...args) {
6 | if (!console || !storage.get('consoleLog')) return;
7 | console[type].apply(console, ['BTTV:', ...args]);
8 | }
9 |
10 | export default {
11 | log: log.bind(this, 'log'),
12 | error: log.bind(this, 'error'),
13 | warn: log.bind(this, 'warn'),
14 | info: log.bind(this, 'info'),
15 | };
16 |
--------------------------------------------------------------------------------
/src/utils/emoji-blacklist.js:
--------------------------------------------------------------------------------
1 | export default [
2 | '🇦',
3 | '🇧',
4 | '🇨',
5 | '🇩',
6 | '🇪',
7 | '🇫',
8 | '🇬',
9 | '🇭',
10 | '🇮',
11 | '🇯',
12 | '🇰',
13 | '🇱',
14 | '🇲',
15 | '🇳',
16 | '🇴',
17 | '🇵',
18 | '🇶',
19 | '🇷',
20 | '🇸',
21 | '🇹',
22 | '🇺',
23 | '🇻',
24 | '🇼',
25 | '🇽',
26 | '🇾',
27 | '🇿',
28 | '🅰️',
29 | '🆎',
30 | 'Ⓜ️',
31 | '🅱️',
32 | '🆑',
33 | '🆖',
34 | '🅾️',
35 | '🅿️',
36 | '🚾',
37 | '🔞',
38 | '🖕',
39 | '🈁',
40 | '🈂️',
41 | '🈷️',
42 | '🈶',
43 | '🈯',
44 | '🉐',
45 | '🈹',
46 | '🈚',
47 | '🈲',
48 | '🉑',
49 | '🈸',
50 | '🈴',
51 | '🈳',
52 | '㊗️',
53 | '㊙️',
54 | '🈺',
55 | '🈵',
56 | '#⃣️',
57 | '0⃣️',
58 | '1⃣️',
59 | '2⃣️',
60 | '3⃣️',
61 | '4⃣️',
62 | '5⃣️',
63 | '6⃣️',
64 | '7⃣️',
65 | '8⃣️',
66 | '9⃣️',
67 | 'ℹ️',
68 | ];
69 |
--------------------------------------------------------------------------------
/src/utils/emote.js:
--------------------------------------------------------------------------------
1 | export function getCanonicalEmoteId(emoteId, emoteProvider) {
2 | return `${emoteProvider}-${emoteId}`;
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/flags.js:
--------------------------------------------------------------------------------
1 | export function hasFlag(flags, flag) {
2 | if (!Number.isSafeInteger(flags)) {
3 | throw new Error('invalid flags');
4 | }
5 | if (!Number.isSafeInteger(flag)) {
6 | throw new Error('invalid flag');
7 | }
8 | return (flags & flag) === flag;
9 | }
10 |
11 | export function setFlag(flags, flag, value) {
12 | if (!Number.isSafeInteger(flags)) {
13 | throw new Error('invalid flags');
14 | }
15 | if (!Number.isSafeInteger(flag)) {
16 | throw new Error('invalid flag');
17 | }
18 | if (value) {
19 | return flags | flag;
20 | }
21 | return flags & ~flag;
22 | }
23 |
24 | export function getChangedFlags(oldFlags, newFlags) {
25 | if (!Number.isSafeInteger(oldFlags)) {
26 | throw new Error('invalid oldFlags');
27 | }
28 | if (!Number.isSafeInteger(newFlags)) {
29 | throw new Error('invalid newFlags');
30 | }
31 | return (oldFlags & ~newFlags) | (newFlags & ~oldFlags);
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/http-error.js:
--------------------------------------------------------------------------------
1 | export default class HTTPError extends Error {
2 | constructor(statusCode, data) {
3 | super(`HTTPError: ${statusCode} received`);
4 | this.status = statusCode;
5 | this.data = data;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/image.js:
--------------------------------------------------------------------------------
1 | export const DEFAULT_SIZES = ['1x', '2x', '4x'];
2 | const STATIC_SIZES = {
3 | '1x': '1x_static',
4 | '2x': '2x_static',
5 | '4x': '4x_static',
6 | };
7 |
8 | export function createSrcSet(images, static_ = false, sizes = DEFAULT_SIZES) {
9 | const srcSet = [];
10 |
11 | for (const size of sizes) {
12 | let image;
13 | if (static_) {
14 | image = images[STATIC_SIZES[size]] ?? images[size];
15 | } else {
16 | image = images[size];
17 | }
18 |
19 | if (image == null) {
20 | continue;
21 | }
22 |
23 | srcSet.push(`${image} ${size}`);
24 | }
25 |
26 | if (srcSet.length === 0) {
27 | const image = images[STATIC_SIZES['1x']] ?? images['1x'];
28 | if (image != null) {
29 | srcSet.push(`${image} 1x`);
30 | }
31 | }
32 |
33 | return srcSet.length > 0 ? srcSet.join(', ') : null;
34 | }
35 |
36 | export function createSrc(images, static_ = false, size = '1x') {
37 | if (static_) {
38 | return images[STATIC_SIZES[size]] ?? images[STATIC_SIZES['1x']] ?? images[size] ?? images['1x'];
39 | }
40 | return images[size] ?? images['1x'];
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/keycodes.js:
--------------------------------------------------------------------------------
1 | export default {
2 | Tab: 'Tab',
3 | Enter: 'Enter',
4 | Shift: 'Shift',
5 | Control: 'Control',
6 | Alt: 'Alt',
7 | Meta: 'Meta',
8 | Pause: 'Pause',
9 | CapsLock: 'CapsLock',
10 | Escape: 'Escape',
11 | Space: 'Space',
12 | Backquote: 'Backquote',
13 | Backspace: 'Backspace',
14 | Backslash: 'Backslash',
15 | BracketLeft: 'BracketLeft',
16 | BracketRight: 'BracketRight',
17 | Equal: 'Equal',
18 | Minus: 'Minus',
19 | Comma: 'Comma',
20 | Period: 'Period',
21 | Quote: 'Quote',
22 | Semicolon: 'Semicolon',
23 | Slash: 'Slash',
24 | PageUp: 'PageUp',
25 | PageDown: 'PageDown',
26 | End: 'End',
27 | Home: 'Home',
28 | Insert: 'Insert',
29 | Delete: 'Delete',
30 | Multiply: 'Multiply',
31 | Add: 'Add',
32 | Subtract: 'Subtract',
33 | Decimal: 'Decimal',
34 | Divide: 'Divide',
35 | ArrowLeft: 'ArrowLeft',
36 | ArrowUp: 'ArrowUp',
37 | ArrowRight: 'ArrowRight',
38 | ArrowDown: 'ArrowDown',
39 | 0: '0',
40 | 1: '1',
41 | 2: '2',
42 | 3: '3',
43 | 4: '4',
44 | 5: '5',
45 | 6: '6',
46 | 7: '7',
47 | 8: '8',
48 | 9: '9',
49 | A: 'a',
50 | B: 'b',
51 | C: 'c',
52 | D: 'd',
53 | E: 'e',
54 | F: 'f',
55 | G: 'g',
56 | H: 'h',
57 | I: 'i',
58 | J: 'j',
59 | K: 'k',
60 | L: 'l',
61 | M: 'm',
62 | N: 'n',
63 | O: 'o',
64 | P: 'p',
65 | Q: 'q',
66 | R: 'r',
67 | S: 's',
68 | T: 't',
69 | U: 'u',
70 | V: 'v',
71 | W: 'w',
72 | X: 'x',
73 | Y: 'y',
74 | Z: 'z',
75 | F1: 'F1',
76 | F2: 'F2',
77 | F3: 'F3',
78 | F4: 'F4',
79 | F5: 'F5',
80 | F6: 'F6',
81 | F7: 'F7',
82 | F8: 'F8',
83 | F9: 'F9',
84 | F10: 'F10',
85 | F11: 'F11',
86 | F12: 'F12',
87 | };
88 |
--------------------------------------------------------------------------------
/src/utils/modules.js:
--------------------------------------------------------------------------------
1 | import {getPlatform} from './window.js';
2 |
3 | const currentPlatform = getPlatform();
4 |
5 | export function loadModuleForPlatforms(...platformConfigurations) {
6 | for (const [platformType, callback] of platformConfigurations) {
7 | if (platformType === currentPlatform) {
8 | return callback();
9 | }
10 | }
11 |
12 | return null;
13 | }
14 |
--------------------------------------------------------------------------------
/src/utils/mousebuttons.js:
--------------------------------------------------------------------------------
1 | export default {
2 | LeftClick: 0,
3 | MiddleClick: 1,
4 | RightClick: 2,
5 | };
6 |
--------------------------------------------------------------------------------
/src/utils/popover.js:
--------------------------------------------------------------------------------
1 | export default function repositionPopover(htmlElementRef, boundingQuerySelector, topPadding) {
2 | const popoverElement = htmlElementRef.current;
3 | if (popoverElement == null) {
4 | return;
5 | }
6 |
7 | const chatTextArea = document.querySelector(boundingQuerySelector);
8 | if (chatTextArea == null) {
9 | return;
10 | }
11 |
12 | const {x, y} = chatTextArea.getBoundingClientRect();
13 | const rightX = x + chatTextArea.offsetWidth;
14 |
15 | const popoverTop = `${y - popoverElement.offsetHeight - topPadding}px`;
16 | const wantedPopoverLeft = rightX - popoverElement.offsetWidth;
17 | const popoverLeft = `${wantedPopoverLeft < 0 ? x : wantedPopoverLeft}px`;
18 |
19 | if (popoverTop !== popoverElement.style.top) {
20 | popoverElement.style.top = popoverTop;
21 | }
22 | if (popoverLeft !== popoverElement.style.left) {
23 | popoverElement.style.left = popoverLeft;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/utils/regex.js:
--------------------------------------------------------------------------------
1 | export function escapeRegExp(text) {
2 | return text.replace(/[-[\]{}()+?.,\\^$|#\s]/g, '\\$&');
3 | }
4 |
5 | export function getEmoteFromRegEx(regex) {
6 | if (typeof regex === 'string') {
7 | regex = new RegExp(regex);
8 | }
9 |
10 | return decodeURI(regex.source)
11 | .replace('>\\;', '>')
12 | .replace('<\\;', '<')
13 | .replace(/\(\?![^)]*\)/g, '')
14 | .replace(/\(([^|])*\|?[^)]*\)/g, '$1')
15 | .replace(/\[([^|\][])*\|?[^\][]*\]/g, '$1')
16 | .replace(/[^\\]\?/g, '')
17 | .replace(/^\\b|\\b$/g, '')
18 | .replace(/\\(?!\\)/g, '');
19 | }
20 |
--------------------------------------------------------------------------------
/src/utils/safe-event-emitter.js:
--------------------------------------------------------------------------------
1 | import {EventEmitter} from 'events';
2 | import debug from './debug.js';
3 |
4 | function newListener(listener, ...args) {
5 | try {
6 | listener(...args);
7 | } catch (e) {
8 | debug.error('Failed executing listener callback', e.stack);
9 | }
10 | }
11 |
12 | class SafeEventEmitter extends EventEmitter {
13 | constructor() {
14 | super();
15 |
16 | this.setMaxListeners(100);
17 | }
18 |
19 | on(type, listener) {
20 | const callback = newListener.bind(this, listener);
21 | super.on(type, callback);
22 | return () => super.off(type, callback);
23 | }
24 |
25 | once(type, listener) {
26 | const callback = newListener.bind(this, listener);
27 | super.once(type, callback);
28 | return () => super.off(type, callback);
29 | }
30 |
31 | off() {
32 | throw new Error('.off cannot be called directly. you must use the returned cleanup function from .on/.once');
33 | }
34 | }
35 |
36 | export default SafeEventEmitter;
37 |
--------------------------------------------------------------------------------
/src/utils/send-chat-message.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../constants.js';
2 | import {loadModuleForPlatforms} from './modules.js';
3 |
4 | export default loadModuleForPlatforms(
5 | [
6 | PlatformTypes.TWITCH,
7 | () => {
8 | let twitch;
9 | return async (message) => {
10 | if (twitch == null) {
11 | const module = await import('./twitch.js');
12 | twitch = module.default;
13 | }
14 | twitch.sendChatAdminMessage(message, true);
15 | };
16 | },
17 | ],
18 | [
19 | PlatformTypes.YOUTUBE,
20 | () => {
21 | let sendEphemeralMessage;
22 | return async (message) => {
23 | if (sendEphemeralMessage == null) {
24 | const module = await import('./youtube-ephemeral-messages.js');
25 | sendEphemeralMessage = module.sendEphemeralMessage;
26 | }
27 | return sendEphemeralMessage(message);
28 | };
29 | },
30 | ]
31 | );
32 |
--------------------------------------------------------------------------------
/src/utils/user.js:
--------------------------------------------------------------------------------
1 | let currentUser;
2 |
3 | export function setCurrentUser({provider, id, name, displayName, avatar}) {
4 | currentUser = {
5 | provider,
6 | id: id.toString(),
7 | name,
8 | displayName,
9 | avatar,
10 | };
11 | }
12 |
13 | export function getCurrentUser() {
14 | return currentUser;
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/window.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from '../constants.js';
2 |
3 | let platform;
4 | let navigatorPlatform;
5 |
6 | export function getPlatform() {
7 | if (platform != null) {
8 | return platform;
9 | }
10 |
11 | const {hostname} = window.location;
12 |
13 | if (hostname.endsWith('.youtube.com')) {
14 | platform = PlatformTypes.YOUTUBE;
15 | } else if (hostname === 'clips.twitch.tv') {
16 | platform = PlatformTypes.TWITCH_CLIPS;
17 | } else if (hostname.endsWith('.twitch.tv')) {
18 | platform = PlatformTypes.TWITCH;
19 | } else {
20 | throw new Error('unsupported platform');
21 | }
22 |
23 | return platform;
24 | }
25 |
26 | export function isMac() {
27 | if (navigatorPlatform != null) {
28 | return navigatorPlatform;
29 | }
30 |
31 | navigatorPlatform = navigator.platform.toLowerCase().startsWith('mac');
32 | return navigatorPlatform;
33 | }
34 |
35 | export function isFrame() {
36 | try {
37 | return window.self !== window.top;
38 | } catch (_) {
39 | return true;
40 | }
41 | }
42 |
43 | export function isPopout() {
44 | try {
45 | return window.opener && window.opener !== window;
46 | } catch (_) {
47 | return true;
48 | }
49 | }
50 |
51 | export function isStandaloneWindow() {
52 | const currentPlatform = getPlatform();
53 |
54 | if (currentPlatform === PlatformTypes.TWITCH) {
55 | return window.location.pathname.endsWith('/chat');
56 | }
57 |
58 | if (currentPlatform === PlatformTypes.YOUTUBE) {
59 | return window.location.pathname.endsWith('/live_chat');
60 | }
61 |
62 | return false;
63 | }
64 |
--------------------------------------------------------------------------------
/src/utils/youtube.js:
--------------------------------------------------------------------------------
1 | import {createSrc, createSrcSet} from './image.js';
2 |
3 | export function createYoutubeEmojiNode(emote) {
4 | const newNode = document.createElement('img');
5 | newNode.className = 'emoji yt-formatted-string style-scope yt-live-chat-text-input-field-renderer';
6 | newNode.src = createSrc(emote.images);
7 | newNode.srcset = createSrcSet(emote.images);
8 | newNode.alt = emote.code;
9 | newNode.setAttribute('data-emoji-id', emote.id);
10 | return newNode;
11 | }
12 |
13 | export function getElementData(element) {
14 | if (element == null) {
15 | return null;
16 | }
17 |
18 | return element.__data?.data ?? element.data ?? element.__data;
19 | }
20 |
21 | export function getLiveChat() {
22 | return getElementData(document.getElementsByTagName('yt-live-chat-renderer')[0]);
23 | }
24 |
--------------------------------------------------------------------------------
/src/watcher.js:
--------------------------------------------------------------------------------
1 | import {PlatformTypes} from './constants.js';
2 | import debug from './utils/debug.js';
3 | import SafeEventEmitter from './utils/safe-event-emitter.js';
4 | import {getPlatform} from './utils/window.js';
5 |
6 | class Watcher extends SafeEventEmitter {
7 | async setup() {
8 | (await import('./watchers/channel.js')).default(this);
9 |
10 | const platform = getPlatform();
11 |
12 | if (platform === PlatformTypes.YOUTUBE) {
13 | (await import('./watchers/youtube.js')).default(this);
14 | } else if (platform === PlatformTypes.TWITCH_CLIPS) {
15 | (await import('./watchers/clips.js')).default(this);
16 | } else if (platform === PlatformTypes.TWITCH) {
17 | (await import('./watchers/chat.js')).default(this);
18 | (await import('./watchers/conversations.js')).default(this);
19 | (await import('./watchers/routes.js')).default(this);
20 | }
21 |
22 | debug.log('Watcher started');
23 | }
24 | }
25 |
26 | export default new Watcher();
27 |
--------------------------------------------------------------------------------
/src/watchers/channel.js:
--------------------------------------------------------------------------------
1 | import api from '../utils/api.js';
2 | import {getCurrentChannel} from '../utils/channel.js';
3 | import debug from '../utils/debug.js';
4 |
5 | let channel;
6 | let watcher;
7 | function updateChannel() {
8 | const currentChannel = getCurrentChannel();
9 | if (!currentChannel || (channel && currentChannel.id === channel.id)) return;
10 |
11 | debug.log(`Channel Observer: ${currentChannel.name} (${currentChannel.id}) loaded.`);
12 |
13 | channel = currentChannel;
14 |
15 | api
16 | .get(`cached/users/${channel.provider}/${channel.id}`)
17 | .catch((error) => ({
18 | bots: [],
19 | channelEmotes: [],
20 | sharedEmotes: [],
21 | status: error.status || 0,
22 | }))
23 | .then((data) => watcher.emit('channel.updated', data));
24 | }
25 |
26 | export default function channelWatcher(watcher_) {
27 | watcher = watcher_;
28 |
29 | watcher.on('load.channel', updateChannel);
30 | watcher.on('load.chat', updateChannel);
31 | watcher.on('load.vod', updateChannel);
32 | }
33 |
--------------------------------------------------------------------------------
/src/watchers/clips.js:
--------------------------------------------------------------------------------
1 | import domObserver from '../observers/dom.js';
2 | import twitch from '../utils/twitch.js';
3 |
4 | export default function clipsWatcher(watcher) {
5 | domObserver.on('.tw-animation', (node, isConnected) => {
6 | if (!isConnected || !node.parentNode?.parentNode?.classList.contains('clips-chat-replay')) return;
7 | watcher.emit('clips.message', node);
8 | });
9 |
10 | twitch.updateCurrentChannel();
11 |
12 | let interval;
13 |
14 | const timeoutInterval = setTimeout(() => interval && clearInterval(interval), 10000);
15 |
16 | interval = setInterval(() => {
17 | const currentChannel = twitch.updateCurrentChannel();
18 | if (!currentChannel) {
19 | return;
20 | }
21 | watcher.emit('load.clips');
22 | watcher.emit('load.channel');
23 | clearInterval(interval);
24 | interval = undefined;
25 | clearTimeout(timeoutInterval);
26 | }, 100);
27 | }
28 |
--------------------------------------------------------------------------------
/src/watchers/conversations.js:
--------------------------------------------------------------------------------
1 | import domObserver from '../observers/dom.js';
2 | import twitch from '../utils/twitch.js';
3 |
4 | export default function conversationsWatcher(watcher) {
5 | domObserver.on('.whispers-thread', (node, isConnected) => {
6 | if (!isConnected) return;
7 |
8 | const threadID = twitch.getConversationThreadId(node);
9 | if (!threadID) return;
10 |
11 | watcher.emit('conversation.new', threadID);
12 | });
13 |
14 | domObserver.on('.thread-message__message', (node, isConnected) => {
15 | if (!isConnected) return;
16 |
17 | const msgObject = twitch.getConversationMessageObject(node);
18 | if (!msgObject) return;
19 |
20 | const threadID = twitch.getConversationThreadId(node.closest('.whispers-thread,.whispers__messages'));
21 | if (!threadID) return;
22 |
23 | watcher.emit('conversation.message', threadID, node, msgObject);
24 | });
25 | }
26 |
--------------------------------------------------------------------------------