├── .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 | BetterTTV Logo 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 | 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 | 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 |
9 |
10 |
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 | --------------------------------------------------------------------------------