diff --git a/.CI/CreateDMG.sh b/.CI/CreateDMG.sh index 60a8f5341..8ad191e2d 100755 --- a/.CI/CreateDMG.sh +++ b/.CI/CreateDMG.sh @@ -1,13 +1,13 @@ #!/bin/sh echo "Running MACDEPLOYQT" -/usr/local/opt/qt/bin/macdeployqt chatterino.app -dmg -echo "Creating APP folder" -mkdir app -echo "Running hdiutil attach on the built DMG" -hdiutil attach chatterino.dmg -echo "Copying chatterino.app into the app folder" -cp -r /Volumes/chatterino/chatterino.app app/ -echo "Creating DMG with create-dmg" -create-dmg --volname Chatterino2 --volicon ../resources/chatterino.icns --icon-size 50 --app-drop-link 0 0 --format UDBZ chatterino-osx.dmg app/ -echo "DONE" +/usr/local/opt/qt/bin/macdeployqt chatterino.app +echo "Creating python3 virtual environment" +python3 -m venv venv +echo "Entering python3 virtual environment" +. venv/bin/activate +echo "Installing dmgbuild" +python3 -m pip install dmgbuild +echo "Running dmgbuild.." +dmgbuild --settings ./../.CI/dmg-settings.py -D app=./chatterino.app Chatterino2 chatterino-osx.dmg +echo "Done!" diff --git a/.CI/dmg-settings.py b/.CI/dmg-settings.py new file mode 100644 index 000000000..6b068fa1b --- /dev/null +++ b/.CI/dmg-settings.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import biplist +import os.path + +# +# Example settings file for dmgbuild +# + +# Use like this: dmgbuild -s settings.py "Test Volume" test.dmg + +# You can actually use this file for your own application (not just TextEdit) +# by doing e.g. +# +# dmgbuild -s settings.py -D app=/path/to/My.app "My Application" MyApp.dmg + +# .. Useful stuff .............................................................. + +application = defines.get('app', '/Applications/TextEdit.app') +appname = os.path.basename(application) + +def icon_from_app(app_path): + plist_path = os.path.join(app_path, 'Contents', 'Info.plist') + plist = biplist.readPlist(plist_path) + icon_name = plist['CFBundleIconFile'] + icon_root,icon_ext = os.path.splitext(icon_name) + if not icon_ext: + icon_ext = '.icns' + icon_name = icon_root + icon_ext + return os.path.join(app_path, 'Contents', 'Resources', icon_name) + +# .. Basics .................................................................... + +# Uncomment to override the output filename +# filename = 'test.dmg' + +# Uncomment to override the output volume name +# volume_name = 'Test' + +# Volume format (see hdiutil create -help) +format = defines.get('format', 'UDBZ') + +# Volume size +size = defines.get('size', None) + +# Files to include +files = [ application ] + +# Symlinks to create +symlinks = { 'Applications': '/Applications' } + +# Volume icon +# +# You can either define icon, in which case that icon file will be copied to the +# image, *or* you can define badge_icon, in which case the icon file you specify +# will be used to badge the system's Removable Disk icon +# +#icon = '/path/to/icon.icns' +badge_icon = icon_from_app(application) + +# Where to put the icons +icon_locations = { + appname: (140, 120), + 'Applications': (500, 120) + } + +# .. Window configuration ...................................................... + +# Background +# +# This is a STRING containing any of the following: +# +# #3344ff - web-style RGB color +# #34f - web-style RGB color, short form (#34f == #3344ff) +# rgb(1,0,0) - RGB color, each value is between 0 and 1 +# hsl(120,1,.5) - HSL (hue saturation lightness) color +# hwb(300,0,0) - HWB (hue whiteness blackness) color +# cmyk(0,1,0,0) - CMYK color +# goldenrod - X11/SVG named color +# builtin-arrow - A simple built-in background with a blue arrow +# /foo/bar/baz.png - The path to an image file +# +# The hue component in hsl() and hwb() may include a unit; it defaults to +# degrees ('deg'), but also supports radians ('rad') and gradians ('grad' +# or 'gon'). +# +# Other color components may be expressed either in the range 0 to 1, or +# as percentages (e.g. 60% is equivalent to 0.6). +background = 'builtin-arrow' + +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +sidebar_width = 180 + +# Window position in ((x, y), (w, h)) format +window_rect = ((100, 100), (640, 280)) + +# Select the default view; must be one of +# +# 'icon-view' +# 'list-view' +# 'column-view' +# 'coverflow' +# +default_view = 'icon-view' + +# General view configuration +show_icon_preview = False + +# Set these to True to force inclusion of icon/list view settings (otherwise +# we only include settings for the default view) +include_icon_view_settings = 'auto' +include_list_view_settings = 'auto' + +# .. Icon view configuration ................................................... + +arrange_by = None +grid_offset = (0, 0) +grid_spacing = 100 +scroll_position = (0, 0) +label_pos = 'bottom' # or 'right' +text_size = 16 +icon_size = 128 + +# .. List view configuration ................................................... + +# Column names are as follows: +# +# name +# date-modified +# date-created +# date-added +# date-last-opened +# size +# kind +# label +# version +# comments +# +list_icon_size = 16 +list_text_size = 12 +list_scroll_position = (0, 0) +list_sort_by = 'name' +list_use_relative_dates = True +list_calculate_all_sizes = False, +list_columns = ('name', 'date-modified', 'size', 'kind', 'date-added') +list_column_widths = { + 'name': 300, + 'date-modified': 181, + 'date-created': 181, + 'date-added': 181, + 'date-last-opened': 181, + 'size': 97, + 'kind': 115, + 'label': 100, + 'version': 75, + 'comments': 300, + } +list_column_sort_directions = { + 'name': 'ascending', + 'date-modified': 'descending', + 'date-created': 'descending', + 'date-added': 'descending', + 'date-last-opened': 'descending', + 'size': 'descending', + 'kind': 'ascending', + 'label': 'ascending', + 'version': 'ascending', + 'comments': 'ascending', + } + +# .. License configuration ..................................................... + +# Text in the license configuration is stored in the resources, which means +# it gets stored in a legacy Mac encoding according to the language. dmgbuild +# will *try* to convert Unicode strings to the appropriate encoding, *but* +# you should be aware that Python doesn't support all of the necessary encodings; +# in many cases you will need to encode the text yourself and use byte strings +# instead here. + +# Recognized language names are: +# +# af_ZA, ar, be_BY, bg_BG, bn, bo, br, ca_ES, cs_CZ, cy, da_DK, de_AT, de_CH, +# de_DE, dz_BT, el_CY, el_GR, en_AU, en_CA, en_GB, en_IE, en_SG, en_US, eo, +# es_419, es_ES, et_EE, fa_IR, fi_FI, fo_FO, fr_001, fr_BE, fr_CA, fr_CH, +# fr_FR, ga-Latg_IE, ga_IE, gd, grc, gu_IN, gv, he_IL, hi_IN, hr_HR, hu_HU, +# hy_AM, is_IS, it_CH, it_IT, iu_CA, ja_JP, ka_GE, kl, ko_KR, lt_LT, lv_LV, +# mk_MK, mr_IN, mt_MT, nb_NO, ne_NP, nl_BE, nl_NL, nn_NO, pa, pl_PL, pt_BR, +# pt_PT, ro_RO, ru_RU, se, sk_SK, sl_SI, sr_RS, sv_SE, th_TH, to_TO, tr_TR, +# uk_UA, ur_IN, ur_PK, uz_UZ, vi_VN, zh_CN, zh_TW + +# license = { +# 'default-language': 'en_US', +# 'licenses': { +# # For each language, the text of the license. This can be plain text, +# # RTF (in which case it must start "{\rtf1"), or a path to a file +# # containing the license text. If you're using RTF, +# # watch out for Python escaping (or read it from a file). +# 'English': b'''{\\rtf1\\ansi\\ansicpg1252\\cocoartf1504\\cocoasubrtf820 +# {\\fonttbl\\f0\\fnil\\fcharset0 Helvetica-Bold;\\f1\\fnil\\fcharset0 Helvetica;} +# {\\colortbl;\\red255\\green255\\blue255;\\red0\\green0\\blue0;} +# {\\*\\expandedcolortbl;;\\cssrgb\\c0\\c0\\c0;} +# \\paperw11905\\paperh16837\\margl1133\\margr1133\\margb1133\\margt1133 +# \\deftab720 +# \\pard\\pardeftab720\\sa160\\partightenfactor0 + +# \\f0\\b\\fs60 \\cf2 \\expnd0\\expndtw0\\kerning0 +# \\up0 \\nosupersub \\ulnone \\outl0\\strokewidth0 \\strokec2 Test License\\ +# \\pard\\pardeftab720\\sa160\\partightenfactor0 + +# \\fs36 \\cf2 \\strokec2 What is this?\\ +# \\pard\\pardeftab720\\sa160\\partightenfactor0 + +# \\f1\\b0\\fs22 \\cf2 \\strokec2 This is the English license. It says what you are allowed to do with this software.\\ +# \\ +# }''', +# }, +# 'buttons': { +# # For each language, text for the buttons on the licensing window. +# # +# # Default buttons and text are built-in for the following languages: +# # +# # English (en_US), German (de_DE), Spanish (es_ES), French (fr_FR), +# # Italian (it_IT), Japanese (ja_JP), Dutch (nl_NL), Swedish (sv_SE), +# # Brazilian Portuguese (pt_BR), Simplified Chinese (zh_CN), +# # Traditional Chinese (zh_TW), Danish (da_DK), Finnish (fi_FI), +# # Korean (ko_KR), Norwegian (nb_NO) +# # +# # You don't need to specify them for those languages; if you fail to +# # specify them for some other language, English will be used instead. + +# 'en_US': ( +# b'English', +# b'Agree', +# b'Disagree', +# b'Print', +# b'Save', +# b'If you agree with the terms of this license, press "Agree" to ' +# b'install the software. If you do not agree, press "Disagree".' +# ), +# }, +# } + diff --git a/.github/ISSUE_TEMPLATE/feature-suggestion.md b/.github/ISSUE_TEMPLATE/feature-suggestion.md index d44729381..38e6dd885 100644 --- a/.github/ISSUE_TEMPLATE/feature-suggestion.md +++ b/.github/ISSUE_TEMPLATE/feature-suggestion.md @@ -7,14 +7,8 @@ assignees: '' --- -**Is your feature request related to a problem? Please describe.** - +**What should be added?** + -**Describe the solution you'd like** - - -**Describe alternatives you've considered** - - -**Additional context** - +**Why should it be added?** + diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE/pull_request_template.md rename to .github/pull_request_template.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 75bdc29d5..e4ea6900a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,8 +24,18 @@ jobs: with: submodules: true + - name: Cache Qt + id: cache-qt + uses: actions/cache@v2 + with: + path: ../Qt + key: ${{ runner.os }}-QtCache-v2 + - name: Install Qt - uses: jurplel/install-qt-action@v1 + uses: jurplel/install-qt-action@v2 + with: + mirror: 'http://mirrors.ocf.berkeley.edu/qt/' + cached: ${{ steps.cache-qt.outputs.cache-hit }} # WINDOWS - name: Cache conan @@ -45,8 +55,7 @@ jobs: - name: Install dependencies (Windows) if: startsWith(matrix.os, 'windows') run: | - REM We use this source (temporarily?) until choco has updated their version of conan - choco install conan -y -s="https://api.bintray.com/nuget/anotherfoxguy/choco-pkg" + choco install conan -y refreshenv shell: cmd diff --git a/.github/workflows/check-formatting.yml b/.github/workflows/check-formatting.yml index 3c04502b4..aa7332984 100644 --- a/.github/workflows/check-formatting.yml +++ b/.github/workflows/check-formatting.yml @@ -1,6 +1,14 @@ name: Check formatting -on: [push, pull_request] +on: + push: + paths-ignore: + - 'docs/**' + - '*.md' + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' jobs: build: diff --git a/BUILDING_ON_FREEBSD.md b/BUILDING_ON_FREEBSD.md new file mode 100644 index 000000000..796317931 --- /dev/null +++ b/BUILDING_ON_FREEBSD.md @@ -0,0 +1,17 @@ +# FreeBSD + +Note on Qt version compatibility: If you are installing Qt from a package manager, please ensure the version you are installing is at least **Qt 5.10 or newer**. + +## FreeBSD 12.1-RELEASE + +Note: This is known to work on FreeBSD 12.1-RELEASE amd64. Chances are +high that this also works on older FreeBSD releases, architectures and +FreeBSD 13.0-CURRENT. + +1. Install build dependencies from package sources (or build from the + ports tree): `# pkg install qt5-core qt5-multimedia qt5-svg + qt5-qmake qt5-buildtools gstreamer-plugins-good boost-libs + rapidjson` +1. go into project directory +1. create build folder `$ mkdir build && cd build` +1. `$ qmake .. && make` diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index 40df39bb0..b456efb44 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -29,18 +29,20 @@ Note: This installation will take about 1.5 GB of disk space. ## OpenSSL ### For our websocket library, we need OpenSSL 1.1 -1. Download OpenSSL for windows, version `1.1.0j`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_1_0j.exe)** +1. Download OpenSSL for windows, version `1.1.1g`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_1_1g.exe)** 2. When prompted, install OpenSSL to `C:\local\openssl` 3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". ### For Qt SSL, we need OpenSSL 1.0 -1. Download OpenSSL for windows, version `1.0.2r`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_0_2r.exe)** +1. Download OpenSSL for windows, version `1.0.2u`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_0_2u.exe)** 2. When prompted, install it to any arbitrary empty directory. 3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". 4. Copy the OpenSSL 1.0 files from its `\bin` folder to `C:\local\bin` (You will need to create the folder) 5. Then copy the OpenSSL 1.1 files from its `\bin` folder to `C:\local\bin` (Overwrite any duplicate files) 6. Add `C:\local\bin` to your path folder ([Follow guide here if you don't know how to do it]( https://www.computerhope.com/issues/ch000549.htm#windows8)) +**If the download links above do not work, try downloading similar 1.1.x & 1.0.x versions [here](https://slproweb.com/products/Win32OpenSSL.html). Note: Don't download the "light" installers, they do not have the required files.** + Note: This installation will take about 200 MB of disk space. ## Qt diff --git a/CHANGELOG.md b/CHANGELOG.md index 201b0be80..0774df79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog ## Unversioned + +- Major: We now support image thumbnails coming from the link resolver. This feature is off by default and can be enabled in the settings with the "Show link thumbnail" setting. This feature also requires the "Show link info when hovering" setting to be enabled (#1664) +- Major: Added image upload functionality to i.nuuls.com with an ability to change upload destination. This works by dragging and dropping an image into a split, or pasting an image into the text edit field. (#1332, #1741) +- Minor: Clicking on `Open in browser` in a whisper split will now open your whispers on twitch. (#1828) +- Minor: Clicking on @mentions will open the User Popup. (#1674) +- Minor: You can now open the Twitch User Card by middle-mouse clicking a username. (#1669) +- Minor: User Popup now also includes recent user messages (#1729) +- Minor: BetterTTV / FrankerFaceZ emote tooltips now also have emote authors' name (#1721) - Minor: Emotes in the emote popup are now sorted in the same order as the tab completion (#1549) +- Minor: Removed "Online Logs" functionality as services are shut down (#1640) +- Minor: CTRL+F now selects the Find text input field in the Settings Dialog (#1806 #1811) +- Minor: CTRL+F now selects the search text input field in the Search Popup (#1812) - Bugfix: Fix preview on hover not working when Animated emotes options was disabled (#1546) +- Bugfix: FFZ custom mod badges no longer scale with the emote scale options (#1602) +- Bugfix: MacOS updater looked for non-existing fields, causing it to always fail the update check (#1642) +- Bugfix: Fixed message menu crashing if the message you right-clicked goes out of scope before you select an action (#1783) (#1787) +- Bugfix: Fixed alternate messages flickering in UserInfoPopup when clicking Refresh if there was an odd number of messages in there (#1789 #1810) +- Settings open faster +- Dev: Fully remove Twitch Chatroom support +- Dev: Handle conversion of historical CLEARCHAT messages to NOTICE messages in Chatterino instead of relying on the Recent Messages API to handle it for us. (#1804) diff --git a/README.md b/README.md index a0fc71be7..f39e14427 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ git submodule update --init --recursive [Building on Mac](../master/BUILDING_ON_MAC.md) +[Building on FreeBSD](../master/BUILDING_ON_FREEBSD.md) + ## Code style The code is formatted using clang format in Qt Creator. [.clang-format](src/.clang-format) contains the style file for clang format. diff --git a/.travis.yml b/_.travis.yml similarity index 100% rename from .travis.yml rename to _.travis.yml diff --git a/chatterino.pro b/chatterino.pro index 6cf3d8128..bd34241de 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -131,21 +131,16 @@ SOURCES += \ src/controllers/commands/CommandController.cpp \ src/controllers/commands/CommandModel.cpp \ src/controllers/highlights/HighlightBlacklistModel.cpp \ - src/controllers/highlights/HighlightController.cpp \ src/controllers/highlights/HighlightModel.cpp \ src/controllers/highlights/HighlightPhrase.cpp \ src/controllers/highlights/UserHighlightModel.cpp \ - src/controllers/ignores/IgnoreController.cpp \ src/controllers/ignores/IgnoreModel.cpp \ src/controllers/moderationactions/ModerationAction.cpp \ src/controllers/moderationactions/ModerationActionModel.cpp \ - src/controllers/moderationactions/ModerationActions.cpp \ src/controllers/notifications/NotificationController.cpp \ src/controllers/notifications/NotificationModel.cpp \ - src/controllers/pings/PingController.cpp \ - src/controllers/pings/PingModel.cpp \ + src/controllers/pings/MutedChannelModel.cpp \ src/controllers/taggedusers/TaggedUser.cpp \ - src/controllers/taggedusers/TaggedUsersController.cpp \ src/controllers/taggedusers/TaggedUsersModel.cpp \ src/debug/Benchmark.cpp \ src/main.cpp \ @@ -161,6 +156,7 @@ SOURCES += \ src/messages/MessageColor.cpp \ src/messages/MessageContainer.cpp \ src/messages/MessageElement.cpp \ + src/messages/SharedMessageBuilder.cpp \ src/messages/search/AuthorPredicate.cpp \ src/messages/search/LinkPredicate.cpp \ src/messages/search/SubstringPredicate.cpp \ @@ -176,17 +172,18 @@ SOURCES += \ src/providers/irc/IrcChannel2.cpp \ src/providers/irc/IrcCommands.cpp \ src/providers/irc/IrcConnection2.cpp \ + src/providers/irc/IrcMessageBuilder.cpp \ src/providers/irc/IrcServer.cpp \ src/providers/LinkResolver.cpp \ - src/providers/twitch/ChatroomChannel.cpp \ + src/providers/twitch/ChannelPointReward.cpp \ + src/providers/twitch/api/Helix.cpp \ + src/providers/twitch/api/Kraken.cpp \ src/providers/twitch/IrcMessageHandler.cpp \ - src/providers/twitch/PartialTwitchUser.cpp \ src/providers/twitch/PubsubActions.cpp \ src/providers/twitch/PubsubClient.cpp \ src/providers/twitch/PubsubHelpers.cpp \ src/providers/twitch/TwitchAccount.cpp \ src/providers/twitch/TwitchAccountManager.cpp \ - src/providers/twitch/TwitchApi.cpp \ src/providers/twitch/TwitchBadge.cpp \ src/providers/twitch/TwitchBadges.cpp \ src/providers/twitch/TwitchChannel.cpp \ @@ -223,6 +220,9 @@ SOURCES += \ src/util/JsonQuery.cpp \ src/util/RapidjsonHelpers.cpp \ src/util/StreamLink.cpp \ + src/util/StreamerMode.cpp \ + src/util/Twitch.cpp \ + src/util/NuulsUploader.cpp \ src/util/WindowsHelper.cpp \ src/widgets/AccountSwitchPopup.cpp \ src/widgets/AccountSwitchWidget.cpp \ @@ -235,7 +235,6 @@ SOURCES += \ src/widgets/dialogs/IrcConnectionEditor.cpp \ src/widgets/dialogs/LastRunCrashDialog.cpp \ src/widgets/dialogs/LoginDialog.cpp \ - src/widgets/dialogs/LogsPopup.cpp \ src/widgets/dialogs/NotificationPopup.cpp \ src/widgets/dialogs/QualityPopup.cpp \ src/widgets/dialogs/SelectChannelDialog.cpp \ @@ -303,6 +302,7 @@ HEADERS += \ src/common/DownloadManager.hpp \ src/common/Env.hpp \ src/common/FlagsEnum.hpp \ + src/common/IrcColors.hpp \ src/common/LinkParser.hpp \ src/common/Modes.hpp \ src/common/NetworkCommon.hpp \ @@ -327,7 +327,6 @@ HEADERS += \ src/controllers/commands/CommandModel.hpp \ src/controllers/highlights/HighlightBlacklistModel.hpp \ src/controllers/highlights/HighlightBlacklistUser.hpp \ - src/controllers/highlights/HighlightController.hpp \ src/controllers/highlights/HighlightModel.hpp \ src/controllers/highlights/HighlightPhrase.hpp \ src/controllers/highlights/UserHighlightModel.hpp \ @@ -336,13 +335,10 @@ HEADERS += \ src/controllers/ignores/IgnorePhrase.hpp \ src/controllers/moderationactions/ModerationAction.hpp \ src/controllers/moderationactions/ModerationActionModel.hpp \ - src/controllers/moderationactions/ModerationActions.hpp \ src/controllers/notifications/NotificationController.hpp \ src/controllers/notifications/NotificationModel.hpp \ - src/controllers/pings/PingController.hpp \ - src/controllers/pings/PingModel.hpp \ + src/controllers/pings/MutedChannelModel.hpp \ src/controllers/taggedusers/TaggedUser.hpp \ - src/controllers/taggedusers/TaggedUsersController.hpp \ src/controllers/taggedusers/TaggedUsersModel.hpp \ src/debug/AssertInGuiThread.hpp \ src/debug/Benchmark.hpp \ @@ -362,6 +358,7 @@ HEADERS += \ src/messages/MessageContainer.hpp \ src/messages/MessageElement.hpp \ src/messages/MessageParseArgs.hpp \ + src/messages/SharedMessageBuilder.hpp \ src/messages/search/AuthorPredicate.hpp \ src/messages/search/LinkPredicate.hpp \ src/messages/search/MessagePredicate.hpp \ @@ -380,18 +377,19 @@ HEADERS += \ src/providers/irc/IrcChannel2.hpp \ src/providers/irc/IrcCommands.hpp \ src/providers/irc/IrcConnection2.hpp \ + src/providers/irc/IrcMessageBuilder.hpp \ src/providers/irc/IrcServer.hpp \ src/providers/LinkResolver.hpp \ - src/providers/twitch/ChatroomChannel.hpp \ + src/providers/twitch/ChannelPointReward.hpp \ + src/providers/twitch/api/Helix.hpp \ + src/providers/twitch/api/Kraken.hpp \ src/providers/twitch/EmoteValue.hpp \ src/providers/twitch/IrcMessageHandler.hpp \ - src/providers/twitch/PartialTwitchUser.hpp \ src/providers/twitch/PubsubActions.hpp \ src/providers/twitch/PubsubClient.hpp \ src/providers/twitch/PubsubHelpers.hpp \ src/providers/twitch/TwitchAccount.hpp \ src/providers/twitch/TwitchAccountManager.hpp \ - src/providers/twitch/TwitchApi.hpp \ src/providers/twitch/TwitchBadge.hpp \ src/providers/twitch/TwitchBadges.hpp \ src/providers/twitch/TwitchChannel.hpp \ @@ -436,9 +434,12 @@ HEADERS += \ src/util/LayoutCreator.hpp \ src/util/LayoutHelper.hpp \ src/util/Overloaded.hpp \ + src/util/PersistSignalVector.hpp \ src/util/PostToThread.hpp \ src/util/QObjectRef.hpp \ src/util/QStringHash.hpp \ + src/util/StreamerMode.hpp \ + src/util/Twitch.hpp \ src/util/rangealgorithm.hpp \ src/util/RapidjsonHelpers.hpp \ src/util/RapidJsonSerializeQString.hpp \ @@ -449,6 +450,7 @@ HEADERS += \ src/util/Shortcut.hpp \ src/util/StandardItemHelper.hpp \ src/util/StreamLink.hpp \ + src/util/NuulsUploader.hpp \ src/util/WindowsHelper.hpp \ src/widgets/AccountSwitchPopup.hpp \ src/widgets/AccountSwitchWidget.hpp \ @@ -461,7 +463,6 @@ HEADERS += \ src/widgets/dialogs/IrcConnectionEditor.hpp \ src/widgets/dialogs/LastRunCrashDialog.hpp \ src/widgets/dialogs/LoginDialog.hpp \ - src/widgets/dialogs/LogsPopup.hpp \ src/widgets/dialogs/NotificationPopup.hpp \ src/widgets/dialogs/QualityPopup.hpp \ src/widgets/dialogs/SelectChannelDialog.hpp \ diff --git a/docs/Commands.md b/docs/Commands.md new file mode 100644 index 000000000..ceaebb831 --- /dev/null +++ b/docs/Commands.md @@ -0,0 +1,20 @@ +Commands are used as shortcuts for long messages. If a message starts with the "trigger" then the message will be replaced with the Command. + +#### Example +Add Command `Hello chat :)` with the trigger `/hello`. Now typing `/hello` in chat will send `Hello chat :)` instead of `/hello`. + +## Advanced + +- The trigger has to be matched at the **start** of the message but there is a setting to also match them at the **end**. +- Triggers don't need to start with `/` + +#### Using placeholders +- `{1}`, `{2}`, `{3}` and so on can be used to insert the 1st, 2nd, 3rd, ... word after the trigger. + + Example: Add Command `/timeout {1} 1` with trigger `/warn`. Now typing `/warn user123` will send `/timeout user123 1` + +- Similarly `{1+}` and so on can be used to insert all words starting with the 1st, ... word. + + Example: Add Command `Have a {1+} day!` with trigger `/day`. Now typing `/day very super nice` will send `Have a very super nice day!` + +- You can use `{{1}` if you want to send `{1}` literally. \ No newline at end of file diff --git a/docs/ENV.md b/docs/ENV.md index f95c74286..a6efcea52 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -3,7 +3,7 @@ Below I have tried to list all environment variables that can be used to modify ### CHATTERINO2_RECENT_MESSAGES_URL Used to change the URL that Chatterino2 uses when trying to load historic Twitch chat messages (if the setting is enabled). -Default value: `https://recent-messages.robotty.de/api/v2/recent-messages/%1?clearchatToNotice=true` (an [open-source service](https://github.com/robotty/recent-messages) written and currently run by [@RAnders00](https://github.com/RAnders00)) +Default value: `https://recent-messages.robotty.de/api/v2/recent-messages/%1` (an [open-source service](https://github.com/robotty/recent-messages2) written and currently run by [@RAnders00](https://github.com/RAnders00) - [visit the homepage for more details about the service](https://recent-messages.robotty.de/)) Arguments: - `%1` = Name of the Twitch channel diff --git a/docs/IMAGEUPLOADER.md b/docs/IMAGEUPLOADER.md new file mode 100644 index 000000000..fec670ff8 --- /dev/null +++ b/docs/IMAGEUPLOADER.md @@ -0,0 +1,47 @@ +## Image Uploader +You can drag and drop images to Chatterino or paste them from clipboard to upload them to an external service. + +By default, images are uploaded to [i.nuuls.com](https://i.nuuls.com). +You can change that in `Chatterino Settings -> External Tools -> Image Uploader`. + +Note to advanced users: This module sends multipart-form requests via POST method, so uploading via SFTP/FTP won't work. +However, popular hosts like [imgur.com](https://imgur.com) are [s-ul.eu](https://s-ul.eu) supported. Scroll down to see example cofiguration. + +### General settings explanation: + +|Row|Description| +|-|-| +|Request URL|Link to an API endpoint, which is requested by chatterino. Any needed URL parameters should be included here.| +|Form field|Name of a field, which contains image data.| +|Extra headers|Extra headers, that will be included in the request. Header name and value must be separated by colon (`:`). Multiple headers need to be separated with semicolons (`;`).
Example: `Authorization: supaKey ; NextHeader: value` .| +|Image link|Schema that tells where is the link in service's response. Leave empty if server's response is just the link itself. Refer to json properties by `{property}`. Supports dot-notation, example: `{property.anotherProperty}` .| +|Deletion link|Same as above.| + +
+ +## Examples + +### i.nuuls.com + +Simply clear all the fields. + +### imgur.com + +|Row|Description| +|-|-| +|Request URL|`https://api.imgur.com/3/image`| +|Form field|`image`| +|Extra headers|`Authorization: Client-ID c898c0bb848ca39`| +|Image link|`{data.link}`| +|Deletion link|`https://imgur.com/delete/{data.deletehash}`| + +### s-ul.eu + +Replace `XXXXXXXXXXXXXXX` with your API key from s-ul.eu. It can be found on [your account's configuration page](https://s-ul.eu/account/configurations). +|Row|Description| +|-|-| +|Request URL|`https://s-ul.eu/api/v1/upload?wizard=true&key=XXXXXXXXXXXXXXX`| +|Form field|`file`| +|Extra headers|| +|Image link|`{url}`| +|Deletion link|`https://s-ul.eu/delete.php?file={filename}&key=XXXXXXXXXXXXXXX`| diff --git a/docs/README.md b/docs/README.md index 57a48fe0f..5ae4760f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1 +1,21 @@ # Documents + +Welcome to the Chatterino2 documentation! + +Table of contents: + - [Environment variables](ENV.md) + - [Commands](Commands.md) + - [Regex](Regex.md) + - [Notes](notes/README.md) + - [TCaccountmanager](notes/TCaccountmanager.md) + - [TCappearanceSettings.md](notes/TCappearanceSettings.md) + - [TCchannelNavigation.md](notes/TCchannelNavigation.md) + - [TCchatterinoFeatures.md](notes/TCchatterinoFeatures.md) + - [TCchatting.md](notes/TCchatting.md) + - [TCchatWindowNavigation.md](notes/TCchatWindowNavigation.md) + - [TCemotes.md](notes/TCemotes.md) + - [TCgeneral.md](notes/TCgeneral.md) + - [TChighlighting.md](notes/TChighlighting.md) + - [TCtabsAndSplits.md](notes/TCtabsAndSplits.md) + - [TCusernameTabbing.md](notes/TCusernameTabbing.md) + diff --git a/docs/Regex.md b/docs/Regex.md new file mode 100644 index 000000000..9ef784a5c --- /dev/null +++ b/docs/Regex.md @@ -0,0 +1,45 @@ +_Regular expressions_ (or short _regexes_) are often used to check if a text matches a certain pattern. For example the regex `ab?c` would match `abc` or `ac`, but not `abbc` or `123`. In Chatterino, you can use them to highlight messages (and more) based on complex conditions. + +Basic patterns: + +|Pattern |Matches| +|-|-| +|`x?` |nothing or `x`| +|`x*` |`x`, repeated any number of times| +|`x+` |`x`, repeated any number of times but at least 1| +|`^` |The start of the text| +|`$` |The end of the text| +|`x\|y` |`x` or `y`| + +You can group multiple statements with `()`: + +|Pattern |Matches| +|-|-| +|`asd?` |`asd` or `as`| +|`(asd)?` |`asd` or nothing| +|`\(asd\)` |`(asd)`, literally| + +You can also group multiple characters with `[]`: + +|Pattern |Matches| +|-|-| +|`[xyz]` |`x`, `y` or `z`| +|`[1-5a-f]` |`1`,`2`,`3`,`4`,`5`,`a`,`b`,`c`,`d`,`e`,`f`| +|`[^abc]` |Anything, **except** `a`, `b` and `c`| +|`[\-]` |`-`, literally (escaped with `\`)| +|`\[xyz\]` |`[xyz]`, literally| + +Special patterns: + +|Pattern |Matches| +|-|-| +|`\d` |Digit characters (0-9)| +|`\D` |Non-digit characters| +|`\w` |Word characters (a-zA-Z0-9_)| +|`\W` |Non-word characters| +|`\s` |Spaces, tabs, etc.| +|`\S` |Not spaces, tabs, etc.| +|`\b` |Word boundaries (between \w and \W)| +|`\B` |Non-word boundaries| + +You can try out your regex pattern on websites like [https://regex101.com/](regex101.com). \ No newline at end of file diff --git a/lib/signals b/lib/signals index 1c38746b0..6665ccad9 160000 --- a/lib/signals +++ b/lib/signals @@ -1 +1 @@ -Subproject commit 1c38746b05d9311e73c8c8acdfdc4d36c9c551be +Subproject commit 6665ccad90461c01b7fe704a98a835953d644156 diff --git a/resources/contributors.txt b/resources/contributors.txt index ea0644a7c..cee2ae135 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -29,6 +29,8 @@ TranRed | https://github.com/TranRed | | Contributor RAnders00 | https://github.com/RAnders00 | | Contributor YungLPR | https://github.com/leon-richardt | | Contributor Mm2PL | https://github.com/mm2pl | | Contributor +gempir | https://github.com/gempir | | Contributor +mfmarlow | https://github.com/mfmarlow | | Contributor # If you are a contributor add yourself above this line Defman21 | https://github.com/Defman21 | | Documentation diff --git a/resources/qss/settings.qss b/resources/qss/settings.qss index 590dff5f5..a3172fd65 100644 --- a/resources/qss/settings.qss +++ b/resources/qss/settings.qss @@ -39,7 +39,6 @@ chatterino--TitleLabel { font-family: "Segoe UI light"; font-size: 24px; color: #4FC3F7; - margin-top: 16px; } chatterino--DescriptionLabel { diff --git a/resources/resources_autogenerated.qrc b/resources/resources_autogenerated.qrc index dbb639bbc..4fe4cf72e 100644 --- a/resources/resources_autogenerated.qrc +++ b/resources/resources_autogenerated.qrc @@ -28,8 +28,9 @@ buttons/unmod.png buttons/update.png buttons/updateError.png - com.chatterino.chatterino.desktop chatterino.icns + com.chatterino.chatterino.appdata.xml + com.chatterino.chatterino.desktop contributors.txt emoji.json emojidata.txt @@ -50,6 +51,12 @@ licenses/websocketpp.txt pajaDank.png qss/settings.qss + scrolling/downScroll.png + scrolling/downScroll.svg + scrolling/neutralScroll.png + scrolling/neutralScroll.svg + scrolling/upScroll.png + scrolling/upScroll.svg settings/about.svg settings/aboutlogo.png settings/accounts.svg diff --git a/resources/scrolling/downScroll.png b/resources/scrolling/downScroll.png new file mode 100644 index 000000000..cc46bb5c8 Binary files /dev/null and b/resources/scrolling/downScroll.png differ diff --git a/resources/scrolling/downScroll.svg b/resources/scrolling/downScroll.svg new file mode 100644 index 000000000..d2e8992ac --- /dev/null +++ b/resources/scrolling/downScroll.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/resources/scrolling/neutralScroll.png b/resources/scrolling/neutralScroll.png new file mode 100644 index 000000000..99ad1e7f2 Binary files /dev/null and b/resources/scrolling/neutralScroll.png differ diff --git a/resources/scrolling/neutralScroll.svg b/resources/scrolling/neutralScroll.svg new file mode 100644 index 000000000..6ad76c8ca --- /dev/null +++ b/resources/scrolling/neutralScroll.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/resources/scrolling/upScroll.png b/resources/scrolling/upScroll.png new file mode 100644 index 000000000..433642978 Binary files /dev/null and b/resources/scrolling/upScroll.png differ diff --git a/resources/scrolling/upScroll.svg b/resources/scrolling/upScroll.svg new file mode 100644 index 000000000..d25c3f10a --- /dev/null +++ b/resources/scrolling/upScroll.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/src/Application.cpp b/src/Application.cpp index fe0aab349..814ba92f5 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -5,12 +5,8 @@ #include "common/Args.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/CommandController.hpp" -#include "controllers/highlights/HighlightController.hpp" #include "controllers/ignores/IgnoreController.hpp" -#include "controllers/moderationactions/ModerationActions.hpp" #include "controllers/notifications/NotificationController.hpp" -#include "controllers/pings/PingController.hpp" -#include "controllers/taggedusers/TaggedUsersController.hpp" #include "messages/MessageBuilder.hpp" #include "providers/bttv/BttvEmotes.hpp" #include "providers/chatterino/ChatterinoBadges.hpp" @@ -31,6 +27,7 @@ #include "singletons/WindowManager.hpp" #include "util/IsBigEndian.hpp" #include "util/PostToThread.hpp" +#include "util/RapidjsonHelpers.hpp" #include "widgets/Notebook.hpp" #include "widgets/Window.hpp" #include "widgets/splits/Split.hpp" @@ -54,16 +51,10 @@ Application::Application(Settings &_settings, Paths &_paths) , accounts(&this->emplace()) , commands(&this->emplace()) - , highlights(&this->emplace()) , notifications(&this->emplace()) - , pings(&this->emplace()) - , ignores(&this->emplace()) - , taggedUsers(&this->emplace()) - , moderationActions(&this->emplace()) , twitch2(&this->emplace()) , chatterinoBadges(&this->emplace()) , logging(&this->emplace()) - { this->instance = this; @@ -115,9 +106,6 @@ void Application::initialize(Settings &settings, Paths &paths) this->initNm(paths); this->initPubsub(); - - this->moderationActions->items.delayedItemsChanged.connect( - [this] { this->windows->forceLayoutChannelViews(); }); } int Application::run(QApplication &qtApp) @@ -130,6 +118,13 @@ int Application::run(QApplication &qtApp) getSettings()->betaUpdates.connect( [] { Updates::instance().checkForUpdates(); }, false); + getSettings()->moderationActions.delayedItemsChanged.connect( + [this] { this->windows->forceLayoutChannelViews(); }); + + getSettings()->highlightedMessages.delayedItemsChanged.connect( + [this] { this->windows->forceLayoutChannelViews(); }); + getSettings()->highlightedUsers.delayedItemsChanged.connect( + [this] { this->windows->forceLayoutChannelViews(); }); return qtApp.exec(); } @@ -156,14 +151,6 @@ void Application::initNm(Paths &paths) void Application::initPubsub() { - this->twitch.pubsub->signals_.whisper.sent.connect([](const auto &msg) { - qDebug() << "WHISPER SENT LOL"; // - }); - - this->twitch.pubsub->signals_.whisper.received.connect([](const auto &msg) { - qDebug() << "WHISPER RECEIVED LOL"; // - }); - this->twitch.pubsub->signals_.moderation.chatCleared.connect( [this](const auto &action) { auto chan = @@ -298,6 +285,21 @@ void Application::initPubsub() chan->deleteMessage(msg->id); }); + this->twitch.pubsub->signals_.pointReward.redeemed.connect([&](auto &data) { + QString channelId; + if (rj::getSafe(data, "channel_id", channelId)) + { + const auto &chan = + this->twitch.server->getChannelOrEmptyByID(channelId); + auto channel = dynamic_cast(chan.get()); + channel->addChannelPointReward(ChannelPointReward(data)); + } + else + { + qDebug() << "Couldn't find channel id of point reward"; + } + }); + this->twitch.pubsub->start(); auto RequestModerationActions = [=]() { diff --git a/src/Application.hpp b/src/Application.hpp index fac8ec0f6..0c0e54bcb 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -3,6 +3,7 @@ #include #include +#include "common/SignalVector.hpp" #include "common/Singleton.hpp" #include "singletons/NativeMessaging.hpp" @@ -12,13 +13,8 @@ class TwitchIrcServer; class PubSub; class CommandController; -class HighlightController; -class IgnoreController; -class TaggedUsersController; class AccountController; -class ModerationActions; class NotificationController; -class PingController; class Theme; class WindowManager; @@ -58,12 +54,7 @@ public: AccountController *const accounts{}; CommandController *const commands{}; - HighlightController *const highlights{}; NotificationController *const notifications{}; - PingController *const pings{}; - IgnoreController *const ignores{}; - TaggedUsersController *const taggedUsers{}; - ModerationActions *const moderationActions{}; TwitchIrcServer *const twitch2{}; ChatterinoBadges *const chatterinoBadges{}; diff --git a/src/BaseTheme.cpp b/src/BaseTheme.cpp index 99ddddb24..306f13f5c 100644 --- a/src/BaseTheme.cpp +++ b/src/BaseTheme.cpp @@ -159,21 +159,6 @@ void AB_THEME_CLASS::actuallyUpdate(double hue, double multiplier) this->messages.backgrounds.regular = getColor(0, sat, 1); this->messages.backgrounds.alternate = getColor(0, sat, 0.96); - if (isLight_) - { - this->messages.backgrounds.highlighted = - blendColors(themeColor, this->messages.backgrounds.regular, 0.8); - } - else - { - // REMOVED - // this->messages.backgrounds.highlighted = - // QColor(getSettings()->highlightColor); - } - - this->messages.backgrounds.subscription = - blendColors(QColor("#C466FF"), this->messages.backgrounds.regular, 0.7); - // this->messages.backgrounds.resub // this->messages.backgrounds.whisper this->messages.disabled = getColor(0, sat, 1, 0.6); diff --git a/src/BaseTheme.hpp b/src/BaseTheme.hpp index d99808133..36e14c07f 100644 --- a/src/BaseTheme.hpp +++ b/src/BaseTheme.hpp @@ -64,8 +64,6 @@ public: struct { QColor regular; QColor alternate; - QColor highlighted; - QColor subscription; // QColor whisper; } backgrounds; diff --git a/src/RunGui.cpp b/src/RunGui.cpp index 3088b6da1..e025d7f21 100644 --- a/src/RunGui.cpp +++ b/src/RunGui.cpp @@ -154,11 +154,6 @@ namespace { toBeRemoved << info.absoluteFilePath(); } } - - for (auto &&path : toBeRemoved) - { - qDebug() << path << QFile(path).remove(); - } } } // namespace diff --git a/src/autogenerated/ResourcesAutogen.cpp b/src/autogenerated/ResourcesAutogen.cpp index e7aab5d55..b0e5e6ce8 100644 --- a/src/autogenerated/ResourcesAutogen.cpp +++ b/src/autogenerated/ResourcesAutogen.cpp @@ -30,6 +30,9 @@ Resources2::Resources2() this->error = QPixmap(":/error.png"); this->icon = QPixmap(":/icon.png"); this->pajaDank = QPixmap(":/pajaDank.png"); + this->scrolling.downScroll = QPixmap(":/scrolling/downScroll.png"); + this->scrolling.neutralScroll = QPixmap(":/scrolling/neutralScroll.png"); + this->scrolling.upScroll = QPixmap(":/scrolling/upScroll.png"); this->settings.aboutlogo = QPixmap(":/settings/aboutlogo.png"); this->split.down = QPixmap(":/split/down.png"); this->split.left = QPixmap(":/split/left.png"); @@ -50,4 +53,4 @@ Resources2::Resources2() this->twitch.vip = QPixmap(":/twitch/vip.png"); } -} // namespace chatterino +} // namespace chatterino \ No newline at end of file diff --git a/src/autogenerated/ResourcesAutogen.hpp b/src/autogenerated/ResourcesAutogen.hpp index cb3423631..cbd6c0bfb 100644 --- a/src/autogenerated/ResourcesAutogen.hpp +++ b/src/autogenerated/ResourcesAutogen.hpp @@ -1,5 +1,4 @@ #include - #include "common/Singleton.hpp" namespace chatterino { @@ -39,6 +38,11 @@ public: QPixmap error; QPixmap icon; QPixmap pajaDank; + struct { + QPixmap downScroll; + QPixmap neutralScroll; + QPixmap upScroll; + } scrolling; struct { QPixmap aboutlogo; } settings; @@ -65,4 +69,4 @@ public: } twitch; }; -} // namespace chatterino +} // namespace chatterino \ No newline at end of file diff --git a/src/common/Aliases.hpp b/src/common/Aliases.hpp index 2dea876b7..36fbc94ff 100644 --- a/src/common/Aliases.hpp +++ b/src/common/Aliases.hpp @@ -34,3 +34,4 @@ QStringAlias(Url); QStringAlias(Tooltip); QStringAlias(EmoteId); QStringAlias(EmoteName); +QStringAlias(EmoteAuthor); diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index 43e88ee38..3ff35385b 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -59,6 +59,11 @@ bool Channel::isEmpty() const return this->name_.isEmpty(); } +bool Channel::hasMessages() const +{ + return !this->messages_.empty(); +} + LimitedQueueSnapshot Channel::getMessageSnapshot() { return this->messages_.getSnapshot(); @@ -143,9 +148,8 @@ void Channel::addOrReplaceTimeout(MessagePtr message) int count = s->count + 1; - MessageBuilder replacement(systemMessage, - message->searchText + QString(" (") + - QString::number(count) + " times)"); + MessageBuilder replacement(timeoutMessage, message->searchText, + count); replacement->timeoutUser = message->timeoutUser; replacement->count = count; diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index f484235ac..71eac8cd4 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -75,6 +75,8 @@ public: void deleteMessage(QString messageID); void clearMessages(); + bool hasMessages() const; + QStringList modList; // CHANNEL INFO diff --git a/src/common/CompletionModel.cpp b/src/common/CompletionModel.cpp index 6cb3bc800..f3d368da8 100644 --- a/src/common/CompletionModel.cpp +++ b/src/common/CompletionModel.cpp @@ -127,7 +127,7 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) TaggedString::Type::Username); } } - else + else if (!getSettings()->userCompletionOnlyWithAt) { for (const auto &name : usernames->subrange(Prefix(usernamePrefix))) diff --git a/src/common/Env.cpp b/src/common/Env.cpp index 1fa819c3d..724c8d92a 100644 --- a/src/common/Env.cpp +++ b/src/common/Env.cpp @@ -50,7 +50,7 @@ Env::Env() : recentMessagesApiUrl( readStringEnv("CHATTERINO2_RECENT_MESSAGES_URL", "https://recent-messages.robotty.de/api/v2/" - "recent-messages/%1?clearchatToNotice=true")) + "recent-messages/%1")) , linkResolverUrl(readStringEnv( "CHATTERINO2_LINK_RESOLVER_URL", "https://braize.pajlada.com/chatterino/link_resolver/%1")) diff --git a/src/common/IrcColors.hpp b/src/common/IrcColors.hpp new file mode 100644 index 000000000..972110565 --- /dev/null +++ b/src/common/IrcColors.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +namespace chatterino { + +// Colors taken from https://modern.ircdocs.horse/formatting.html +static QMap IRC_COLORS = { + {0, QColor("white")}, {1, QColor("black")}, + {2, QColor("blue")}, {3, QColor("green")}, + {4, QColor("red")}, {5, QColor("brown")}, + {6, QColor("purple")}, {7, QColor("orange")}, + {8, QColor("yellow")}, {9, QColor("lightgreen")}, + {10, QColor("cyan")}, {11, QColor("lightcyan")}, + {12, QColor("lightblue")}, {13, QColor("pink")}, + {14, QColor("gray")}, {15, QColor("lightgray")}, + {16, QColor("#470000")}, {17, QColor("#472100")}, + {18, QColor("#474700")}, {19, QColor("#324700")}, + {20, QColor("#004700")}, {21, QColor("#00472c")}, + {22, QColor("#004747")}, {23, QColor("#002747")}, + {24, QColor("#000047")}, {25, QColor("#2e0047")}, + {26, QColor("#470047")}, {27, QColor("#47002a")}, + {28, QColor("#740000")}, {29, QColor("#743a00")}, + {30, QColor("#747400")}, {31, QColor("#517400")}, + {32, QColor("#007400")}, {33, QColor("#007449")}, + {34, QColor("#007474")}, {35, QColor("#004074")}, + {36, QColor("#000074")}, {37, QColor("#4b0074")}, + {38, QColor("#740074")}, {39, QColor("#740045")}, + {40, QColor("#b50000")}, {41, QColor("#b56300")}, + {42, QColor("#b5b500")}, {43, QColor("#7db500")}, + {44, QColor("#00b500")}, {45, QColor("#00b571")}, + {46, QColor("#00b5b5")}, {47, QColor("#0063b5")}, + {48, QColor("#0000b5")}, {49, QColor("#7500b5")}, + {50, QColor("#b500b5")}, {51, QColor("#b5006b")}, + {52, QColor("#ff0000")}, {53, QColor("#ff8c00")}, + {54, QColor("#ffff00")}, {55, QColor("#b2ff00")}, + {56, QColor("#00ff00")}, {57, QColor("#00ffa0")}, + {58, QColor("#00ffff")}, {59, QColor("#008cff")}, + {60, QColor("#0000ff")}, {61, QColor("#a500ff")}, + {62, QColor("#ff00ff")}, {63, QColor("#ff0098")}, + {64, QColor("#ff5959")}, {65, QColor("#ffb459")}, + {66, QColor("#ffff71")}, {67, QColor("#cfff60")}, + {68, QColor("#6fff6f")}, {69, QColor("#65ffc9")}, + {70, QColor("#6dffff")}, {71, QColor("#59b4ff")}, + {72, QColor("#5959ff")}, {73, QColor("#c459ff")}, + {74, QColor("#ff66ff")}, {75, QColor("#ff59bc")}, + {76, QColor("#ff9c9c")}, {77, QColor("#ffd39c")}, + {78, QColor("#ffff9c")}, {79, QColor("#e2ff9c")}, + {80, QColor("#9cff9c")}, {81, QColor("#9cffdb")}, + {82, QColor("#9cffff")}, {83, QColor("#9cd3ff")}, + {84, QColor("#9c9cff")}, {85, QColor("#dc9cff")}, + {86, QColor("#ff9cff")}, {87, QColor("#ff94d3")}, + {88, QColor("#000000")}, {89, QColor("#131313")}, + {90, QColor("#282828")}, {91, QColor("#363636")}, + {92, QColor("#4d4d4d")}, {93, QColor("#656565")}, + {94, QColor("#818181")}, {95, QColor("#9f9f9f")}, + {96, QColor("#bcbcbc")}, {97, QColor("#e2e2e2")}, + {98, QColor("#ffffff")}, +}; + +} // namespace chatterino diff --git a/src/common/NetworkRequest.cpp b/src/common/NetworkRequest.cpp index c4d3fb29a..ffb64f708 100644 --- a/src/common/NetworkRequest.cpp +++ b/src/common/NetworkRequest.cpp @@ -99,6 +99,20 @@ NetworkRequest NetworkRequest::header(const char *headerName, return std::move(*this); } +NetworkRequest NetworkRequest::headerList(const QStringList &headers) && +{ + for (const QString &header : headers) + { + const QStringList thisHeader = header.trimmed().split(":"); + if (thisHeader.size() == 2) + { + this->data->request_.setRawHeader(thisHeader[0].trimmed().toUtf8(), + thisHeader[1].trimmed().toUtf8()); + } + } + return std::move(*this); +} + NetworkRequest NetworkRequest::timeout(int ms) && { this->data->hasTimeout_ = true; diff --git a/src/common/NetworkRequest.hpp b/src/common/NetworkRequest.hpp index 3e806c3e0..509505079 100644 --- a/src/common/NetworkRequest.hpp +++ b/src/common/NetworkRequest.hpp @@ -3,6 +3,7 @@ #include "common/NetworkCommon.hpp" #include "common/NetworkResult.hpp" +#include #include namespace chatterino { @@ -52,6 +53,7 @@ public: NetworkRequest header(const char *headerName, const char *value) &&; NetworkRequest header(const char *headerName, const QByteArray &value) &&; NetworkRequest header(const char *headerName, const QString &value) &&; + NetworkRequest headerList(const QStringList &headers) &&; NetworkRequest timeout(int ms) &&; NetworkRequest concurrent() &&; NetworkRequest authorizeTwitchV5(const QString &clientID, diff --git a/src/common/SignalVector.hpp b/src/common/SignalVector.hpp index 2995cbba4..33bd8254c 100644 --- a/src/common/SignalVector.hpp +++ b/src/common/SignalVector.hpp @@ -4,244 +4,171 @@ #include #include #include -#include #include #include "debug/AssertInGuiThread.hpp" namespace chatterino { -template -struct SignalVectorItemArgs { - const TVectorItem &item; +template +struct SignalVectorItemEvent { + const T &item; int index; void *caller; }; -template -class ReadOnlySignalVector : boost::noncopyable +template +class SignalVector : boost::noncopyable { - using VecIt = typename std::vector::iterator; - public: - struct Iterator - : public std::iterator { - Iterator(VecIt &&it, std::shared_mutex &mutex) - : it_(std::move(it)) - , lock_(mutex) - , mutex_(mutex) - { - } + pajlada::Signals::Signal> itemInserted; + pajlada::Signals::Signal> itemRemoved; + pajlada::Signals::NoArgSignal delayedItemsChanged; - Iterator(const Iterator &other) - : it_(other.it_) - , lock_(other.mutex_) - , mutex_(other.mutex_) - { - } - - Iterator &operator=(const Iterator &other) - { - this->lock_ = std::shared_lock(other.mutex_.get()); - this->mutex_ = other.mutex_; - - return *this; - } - - TVectorItem &operator*() - { - return it_.operator*(); - } - - Iterator &operator++() - { - ++this->it_; - return *this; - } - - bool operator==(const Iterator &other) - { - return this->it_ == other.it_; - } - - bool operator!=(const Iterator &other) - { - return this->it_ != other.it_; - } - - auto operator-(const Iterator &other) - { - return this->it_ - other.it_; - } - - private: - VecIt it_; - std::shared_lock lock_; - std::reference_wrapper mutex_; - }; - - ReadOnlySignalVector() + SignalVector() + : readOnly_(new std::vector()) { QObject::connect(&this->itemsChangedTimer_, &QTimer::timeout, [this] { this->delayedItemsChanged.invoke(); }); this->itemsChangedTimer_.setInterval(100); this->itemsChangedTimer_.setSingleShot(true); } - virtual ~ReadOnlySignalVector() = default; - pajlada::Signals::Signal> itemInserted; - pajlada::Signals::Signal> itemRemoved; - pajlada::Signals::NoArgSignal delayedItemsChanged; - - Iterator begin() const + SignalVector(std::function &&compare) + : SignalVector() { - return Iterator( - const_cast &>(this->vector_).begin(), - this->mutex_); + itemCompare_ = std::move(compare); } - Iterator end() const + virtual bool isSorted() const { - return Iterator( - const_cast &>(this->vector_).end(), - this->mutex_); + return bool(this->itemCompare_); } - bool empty() const + /// A read-only version of the vector which can be used concurrently. + std::shared_ptr> readOnly() { - std::shared_lock lock(this->mutex_); - - return this->vector_.empty(); + return this->readOnly_; } - const std::vector &getVector() const + /// This may only be called from the GUI thread. + /// + /// @param item + /// Item to be inserted. + /// @param proposedIndex + /// Index to insert at. `-1` will append at the end. + /// Will be ignored if the vector is sorted. + /// @param caller + /// Caller id which will be passed in the itemInserted and itemRemoved + /// signals. + int insert(const T &item, int index = -1, void *caller = nullptr) { assertInGuiThread(); - return this->vector_; + if (this->isSorted()) + { + auto it = std::lower_bound(this->items_.begin(), this->items_.end(), + item, this->itemCompare_); + index = it - this->items_.begin(); + this->items_.insert(it, item); + } + else + { + if (index == -1) + index = this->items_.size(); + else + assert(index >= 0 && index <= this->items_.size()); + + this->items_.insert(this->items_.begin() + index, item); + } + + SignalVectorItemEvent args{item, index, caller}; + this->itemInserted.invoke(args); + this->itemsChanged_(); + + return index; } - std::vector cloneVector() const + /// This may only be called from the GUI thread. + /// + /// @param item + /// Item to be appended. + /// @param caller + /// Caller id which will be passed in the itemInserted and itemRemoved + /// signals. + int append(const T &item, void *caller = nullptr) { - std::shared_lock lock(this->mutex_); - - return this->vector_; + return this->insert(item, -1, caller); } - void invokeDelayedItemsChanged() + void removeAt(int index, void *caller = nullptr) + { + assertInGuiThread(); + assert(index >= 0 && index < int(this->items_.size())); + + T item = this->items_[index]; + this->items_.erase(this->items_.begin() + index); + + SignalVectorItemEvent args{item, index, caller}; + this->itemRemoved.invoke(args); + + this->itemsChanged_(); + } + + const std::vector &raw() const { assertInGuiThread(); + return this->items_; + } + + [[deprecated]] std::vector cloneVector() + { + return *this->readOnly(); + } + + // mirror vector functions + auto begin() const + { + assertInGuiThread(); + return this->items_.begin(); + } + + auto end() const + { + assertInGuiThread(); + return this->items_.end(); + } + + decltype(auto) operator[](size_t index) + { + assertInGuiThread(); + return this->items[index]; + } + + auto empty() + { + assertInGuiThread(); + return this->items_.empty(); + } + +private: + void itemsChanged_() + { + // emit delayed event if (!this->itemsChangedTimer_.isActive()) { this->itemsChangedTimer_.start(); } + + // update concurrent version + this->readOnly_ = std::make_shared>(this->items_); } - virtual bool isSorted() const = 0; - -protected: - std::vector vector_; + std::vector items_; + std::shared_ptr> readOnly_; QTimer itemsChangedTimer_; - mutable std::shared_mutex mutex_; -}; - -template -class BaseSignalVector : public ReadOnlySignalVector -{ -public: - // returns the actual index of the inserted item - virtual int insertItem(const TVectorItem &item, int proposedIndex = -1, - void *caller = nullptr) = 0; - - void removeItem(int index, void *caller = nullptr) - { - assertInGuiThread(); - std::unique_lock lock(this->mutex_); - - assert(index >= 0 && index < int(this->vector_.size())); - - TVectorItem item = this->vector_[index]; - - this->vector_.erase(this->vector_.begin() + index); - lock.unlock(); // manual unlock - - SignalVectorItemArgs args{item, index, caller}; - this->itemRemoved.invoke(args); - - this->invokeDelayedItemsChanged(); - } - - int appendItem(const TVectorItem &item, void *caller = nullptr) - { - return this->insertItem(item, -1, caller); - } -}; - -template -class UnsortedSignalVector : public BaseSignalVector -{ -public: - virtual int insertItem(const TVectorItem &item, int index = -1, - void *caller = nullptr) override - { - assertInGuiThread(); - - { - std::unique_lock lock(this->mutex_); - if (index == -1) - { - index = this->vector_.size(); - } - else - { - assert(index >= 0 && index <= this->vector_.size()); - } - - this->vector_.insert(this->vector_.begin() + index, item); - } - - SignalVectorItemArgs args{item, index, caller}; - this->itemInserted.invoke(args); - this->invokeDelayedItemsChanged(); - return index; - } - - virtual bool isSorted() const override - { - return false; - } -}; - -template -class SortedSignalVector : public BaseSignalVector -{ -public: - virtual int insertItem(const TVectorItem &item, int = -1, - void *caller = nullptr) override - { - assertInGuiThread(); - int index = -1; - - { - std::unique_lock lock(this->mutex_); - - auto it = std::lower_bound(this->vector_.begin(), - this->vector_.end(), item, Compare{}); - index = it - this->vector_.begin(); - this->vector_.insert(it, item); - } - - SignalVectorItemArgs args{item, index, caller}; - this->itemInserted.invoke(args); - this->invokeDelayedItemsChanged(); - return index; - } - - virtual bool isSorted() const override - { - return true; - } + std::function itemCompare_; }; } // namespace chatterino diff --git a/src/common/SignalVectorModel.hpp b/src/common/SignalVectorModel.hpp index adea78843..76bc14b22 100644 --- a/src/common/SignalVectorModel.hpp +++ b/src/common/SignalVectorModel.hpp @@ -25,11 +25,11 @@ public: } } - void init(BaseSignalVector *vec) + void initialize(SignalVector *vec) { this->vector_ = vec; - auto insert = [this](const SignalVectorItemArgs &args) { + auto insert = [this](const SignalVectorItemEvent &args) { if (args.caller == this) { return; @@ -52,9 +52,9 @@ public: }; int i = 0; - for (const TVectorItem &item : vec->getVector()) + for (const TVectorItem &item : vec->raw()) { - SignalVectorItemArgs args{item, i++, 0}; + SignalVectorItemEvent args{item, i++, 0}; insert(args); } @@ -89,6 +89,12 @@ public: this->afterInit(); } + SignalVectorModel *initialized(SignalVector *vec) + { + this->initialize(vec); + return this; + } + virtual ~SignalVectorModel() { for (Row &row : this->rows_) @@ -147,12 +153,12 @@ public: else { int vecRow = this->getVectorIndexFromModelIndex(row); - this->vector_->removeItem(vecRow, this); + this->vector_->removeAt(vecRow, this); assert(this->rows_[row].original); TVectorItem item = this->getItemFromRow( this->rows_[row].items, this->rows_[row].original.get()); - this->vector_->insertItem(item, vecRow, this); + this->vector_->insert(item, vecRow, this); } return true; @@ -219,7 +225,7 @@ public: void deleteRow(int row) { int signalVectorRow = this->getVectorIndexFromModelIndex(row); - this->vector_->removeItem(signalVectorRow); + this->vector_->removeAt(signalVectorRow); } bool removeRows(int row, int count, const QModelIndex &parent) override @@ -234,11 +240,71 @@ public: assert(row >= 0 && row < this->rows_.size()); int signalVectorRow = this->getVectorIndexFromModelIndex(row); - this->vector_->removeItem(signalVectorRow); + this->vector_->removeAt(signalVectorRow); return true; } + QStringList mimeTypes() const override + { + return {"chatterino_row_id"}; + } + + QMimeData *mimeData(const QModelIndexList &list) const + { + if (list.length() == 1) + { + return nullptr; + } + + // Check if all indices are in the same row -> single row selected + for (auto &&x : list) + { + if (x.row() != list.first().row()) + return nullptr; + } + + auto data = new QMimeData; + data->setData("chatterino_row_id", QByteArray::number(list[0].row())); + return data; + } + + bool dropMimeData(const QMimeData *data, Qt::DropAction action, int /*row*/, + int /*column*/, const QModelIndex &parent) override + { + if (data->hasFormat("chatterino_row_id") && + action & (Qt::DropAction::MoveAction | Qt::DropAction::CopyAction)) + { + int from = data->data("chatterino_row_id").toInt(); + int to = parent.row(); + + if (from < 0 || from > this->vector_->raw().size() || to < 0 || + to > this->vector_->raw().size()) + { + return false; + } + + if (from != to) + { + auto item = this->vector_->raw()[from]; + this->vector_->removeAt(from); + this->vector_->insert(item, to); + } + + // We return false since we remove items ourselves. + return false; + } + + return false; + } + + Qt::DropActions supportedDropActions() const override + { + return this->vector_->isSorted() + ? Qt::DropActions() + : Qt::DropAction::CopyAction | Qt::DropAction::MoveAction; + } + protected: virtual void afterInit() { @@ -326,7 +392,7 @@ protected: private: std::vector> headerData_; - BaseSignalVector *vector_; + SignalVector *vector_; std::vector rows_; int columnCount_; diff --git a/src/common/UsernameSet.cpp b/src/common/UsernameSet.cpp index c9f9a031c..958a6fc3a 100644 --- a/src/common/UsernameSet.cpp +++ b/src/common/UsernameSet.cpp @@ -63,6 +63,11 @@ void UsernameSet::insertPrefix(const QString &value) string = value; } +bool UsernameSet::contains(const QString &value) const +{ + return this->items.count(value) == 1; +} + // // Range // diff --git a/src/common/UsernameSet.hpp b/src/common/UsernameSet.hpp index 9a24d239b..f43916cc4 100644 --- a/src/common/UsernameSet.hpp +++ b/src/common/UsernameSet.hpp @@ -76,6 +76,8 @@ public: std::pair insert(const QString &value); std::pair insert(QString &&value); + bool contains(const QString &value) const; + private: void insertPrefix(const QString &string); diff --git a/src/controllers/accounts/AccountController.cpp b/src/controllers/accounts/AccountController.cpp index cdaa53285..2692b05a9 100644 --- a/src/controllers/accounts/AccountController.cpp +++ b/src/controllers/accounts/AccountController.cpp @@ -7,20 +7,20 @@ namespace chatterino { AccountController::AccountController() + : accounts_(SharedPtrElementLess{}) { this->twitch.accounts.itemInserted.connect([this](const auto &args) { - this->accounts_.insertItem( - std::dynamic_pointer_cast(args.item)); + this->accounts_.insert(std::dynamic_pointer_cast(args.item)); }); this->twitch.accounts.itemRemoved.connect([this](const auto &args) { if (args.caller != this) { - auto &accs = this->twitch.accounts.getVector(); + auto &accs = this->twitch.accounts.raw(); auto it = std::find(accs.begin(), accs.end(), args.item); assert(it != accs.end()); - this->accounts_.removeItem(it - accs.begin(), this); + this->accounts_.removeAt(it - accs.begin(), this); } }); @@ -30,10 +30,10 @@ AccountController::AccountController() case ProviderId::Twitch: { if (args.caller != this) { - auto accs = this->twitch.accounts.cloneVector(); + auto &&accs = this->twitch.accounts; auto it = std::find(accs.begin(), accs.end(), args.item); assert(it != accs.end()); - this->twitch.accounts.removeItem(it - accs.begin(), this); + this->twitch.accounts.removeAt(it - accs.begin(), this); } } break; @@ -50,7 +50,7 @@ AccountModel *AccountController::createModel(QObject *parent) { AccountModel *model = new AccountModel(parent); - model->init(&this->accounts_); + model->initialize(&this->accounts_); return model; } diff --git a/src/controllers/accounts/AccountController.hpp b/src/controllers/accounts/AccountController.hpp index e297b9e4d..e1ef3c70a 100644 --- a/src/controllers/accounts/AccountController.hpp +++ b/src/controllers/accounts/AccountController.hpp @@ -27,8 +27,7 @@ public: TwitchAccountManager twitch; private: - SortedSignalVector, SharedPtrElementLess> - accounts_; + SignalVector> accounts_; }; } // namespace chatterino diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a6150cfa9..a6f4746f5 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -8,28 +8,29 @@ #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" -#include "providers/twitch/TwitchApi.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "providers/twitch/api/Helix.hpp" #include "singletons/Emotes.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "util/CombinePath.hpp" -#include "widgets/dialogs/LogsPopup.hpp" +#include "util/Twitch.hpp" #include "widgets/dialogs/UserInfoPopup.hpp" #include #include #include -#define TWITCH_DEFAULT_COMMANDS \ - { \ - "/help", "/w", "/me", "/disconnect", "/mods", "/color", "/ban", \ - "/unban", "/timeout", "/untimeout", "/slow", "/slowoff", \ - "/r9kbeta", "/r9kbetaoff", "/emoteonly", "/emoteonlyoff", \ - "/clear", "/subscribers", "/subscribersoff", "/followers", \ - "/followersoff", "/user" \ +#define TWITCH_DEFAULT_COMMANDS \ + { \ + "/help", "/w", "/me", "/disconnect", "/mods", "/color", "/ban", \ + "/unban", "/timeout", "/untimeout", "/slow", "/slowoff", \ + "/r9kbeta", "/r9kbetaoff", "/emoteonly", "/emoteonlyoff", \ + "/clear", "/subscribers", "/subscribersoff", "/followers", \ + "/followersoff", "/user", "/usercard", "/follow", "/unfollow", \ + "/ignore", "/unignore" \ } namespace { @@ -212,7 +213,7 @@ void CommandController::initialize(Settings &, Paths &paths) // Update the setting when the vector of commands has been updated (most // likely from the settings dialog) this->items_.delayedItemsChanged.connect([this] { // - this->commandsSetting_->setValue(this->items_.getVector()); + this->commandsSetting_->setValue(this->items_.raw()); }); // Load commands from commands.json @@ -222,7 +223,7 @@ void CommandController::initialize(Settings &, Paths &paths) // of commands) for (const auto &command : this->commandsSetting_->getValue()) { - this->items_.appendItem(command); + this->items_.append(command); } } @@ -234,7 +235,7 @@ void CommandController::save() CommandModel *CommandController::createModel(QObject *parent) { CommandModel *model = new CommandModel(parent); - model->init(&this->items_); + model->initialize(&this->items_); return model; } @@ -245,8 +246,6 @@ QString CommandController::execCommand(const QString &textNoEmoji, QString text = getApp()->emotes->emojis.replaceShortCodes(textNoEmoji); QStringList words = text.split(' ', QString::SkipEmptyParts); - std::lock_guard lock(this->mutex_); - if (words.length() == 0) { return text; @@ -366,18 +365,17 @@ QString CommandController::execCommand(const QString &textNoEmoji, return ""; } - TwitchApi::findUserId( - target, [user, channel, target](QString userId) { - if (userId.isEmpty()) - { - channel->addMessage(makeSystemMessage( - "User " + target + " could not be followed!")); - return; - } - user->followUser(userId, [channel, target]() { + getHelix()->getUserByName( + target, + [user, channel, target](const auto &targetUser) { + user->followUser(targetUser.id, [channel, target]() { channel->addMessage(makeSystemMessage( "You successfully followed " + target)); }); + }, + [channel, target] { + channel->addMessage(makeSystemMessage( + "User " + target + " could not be followed!")); }); return ""; @@ -402,63 +400,26 @@ QString CommandController::execCommand(const QString &textNoEmoji, return ""; } - TwitchApi::findUserId( - target, [user, channel, target](QString userId) { - if (userId.isEmpty()) - { - channel->addMessage(makeSystemMessage( - "User " + target + " could not be followed!")); - return; - } - user->unfollowUser(userId, [channel, target]() { + getHelix()->getUserByName( + target, + [user, channel, target](const auto &targetUser) { + user->unfollowUser(targetUser.id, [channel, target]() { channel->addMessage(makeSystemMessage( "You successfully unfollowed " + target)); }); + }, + [channel, target] { + channel->addMessage(makeSystemMessage( + "User " + target + " could not be followed!")); }); return ""; } else if (commandName == "/logs") { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /logs [user] (channel)")); - return ""; - } - auto app = getApp(); - - auto logs = new LogsPopup(); - QString target = words.at(1); - - if (target.at(0) == '@') - { - target = target.mid(1); - } - - logs->setTargetUserName(target); - - std::shared_ptr logsChannel = channel; - - if (words.size() == 3) - { - QString channelName = words.at(2); - if (words.at(2).at(0) == '#') - { - channelName = channelName.mid(1); - } - - logs->setChannelName(channelName); - - logsChannel = - app->twitch.server->getChannelOrEmpty(channelName); - } - - logs->setChannel(logsChannel); - - logs->getLogs(); - logs->setAttribute(Qt::WA_DeleteOnClose); - logs->show(); + channel->addMessage(makeSystemMessage( + "Online logs functionality has been removed. If you're a " + "moderator, you can use the /user command")); return ""; } else if (commandName == "/user") @@ -478,8 +439,8 @@ QString CommandController::execCommand(const QString &textNoEmoji, channelName.remove(0, 1); } } - QDesktopServices::openUrl("https://www.twitch.tv/popout/" + - channelName + "/viewercard/" + words[1]); + openTwitchUsercard(channelName, words[1]); + return ""; } else if (commandName == "/usercard") @@ -492,7 +453,6 @@ QString CommandController::execCommand(const QString &textNoEmoji, } auto *userPopup = new UserInfoPopup; userPopup->setData(words[1], channel); - userPopup->setActionOnFocusLoss(BaseWindow::Delete); userPopup->move(QCursor::pos()); userPopup->show(); return ""; diff --git a/src/controllers/commands/CommandController.hpp b/src/controllers/commands/CommandController.hpp index 938fe0f39..ebc57d8cb 100644 --- a/src/controllers/commands/CommandController.hpp +++ b/src/controllers/commands/CommandController.hpp @@ -22,7 +22,7 @@ class CommandModel; class CommandController final : public Singleton { public: - UnsortedSignalVector items_; + SignalVector items_; QString execCommand(const QString &text, std::shared_ptr channel, bool dryRun); @@ -39,8 +39,6 @@ private: QMap commandsMap_; int maxSpaces_ = 0; - std::mutex mutex_; - std::shared_ptr sm_; // Because the setting manager is not initialized until the initialize // function is called (and not in the constructor), we have to diff --git a/src/controllers/commands/CommandModel.cpp b/src/controllers/commands/CommandModel.cpp index 4a55907d0..d12a81c69 100644 --- a/src/controllers/commands/CommandModel.cpp +++ b/src/controllers/commands/CommandModel.cpp @@ -1,5 +1,7 @@ #include "CommandModel.hpp" +#include "util/StandardItemHelper.hpp" + namespace chatterino { // commandmodel @@ -20,12 +22,8 @@ Command CommandModel::getItemFromRow(std::vector &row, void CommandModel::getRowFromItem(const Command &item, std::vector &row) { - row[0]->setData(item.name, Qt::DisplayRole); - row[0]->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable | - Qt::ItemIsEditable); - row[1]->setData(item.func, Qt::DisplayRole); - row[1]->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable | - Qt::ItemIsEditable); + setStringItem(row[0], item.name); + setStringItem(row[1], item.func); } } // namespace chatterino diff --git a/src/controllers/highlights/HighlightBlacklistModel.hpp b/src/controllers/highlights/HighlightBlacklistModel.hpp index c3420b066..d4acc474d 100644 --- a/src/controllers/highlights/HighlightBlacklistModel.hpp +++ b/src/controllers/highlights/HighlightBlacklistModel.hpp @@ -11,9 +11,9 @@ class HighlightController; class HighlightBlacklistModel : public SignalVectorModel { +public: explicit HighlightBlacklistModel(QObject *parent); -public: enum Column { Pattern = 0, UseRegex = 1, @@ -28,8 +28,6 @@ protected: // turns a row in the model into a vector item virtual void getRowFromItem(const HighlightBlacklistUser &item, std::vector &row) override; - - friend class HighlightController; }; } // namespace chatterino diff --git a/src/controllers/highlights/HighlightController.cpp b/src/controllers/highlights/HighlightController.cpp deleted file mode 100644 index d030f77b8..000000000 --- a/src/controllers/highlights/HighlightController.cpp +++ /dev/null @@ -1,110 +0,0 @@ -#include "HighlightController.hpp" - -#include "Application.hpp" -#include "controllers/highlights/HighlightBlacklistModel.hpp" -#include "controllers/highlights/HighlightModel.hpp" -#include "controllers/highlights/UserHighlightModel.hpp" -#include "widgets/dialogs/NotificationPopup.hpp" - -namespace chatterino { - -HighlightController::HighlightController() -{ -} - -void HighlightController::initialize(Settings &settings, Paths &paths) -{ - assert(!this->initialized_); - this->initialized_ = true; - - for (const HighlightPhrase &phrase : this->highlightsSetting_.getValue()) - { - this->phrases.appendItem(phrase); - } - - this->phrases.delayedItemsChanged.connect([this] { // - this->highlightsSetting_.setValue(this->phrases.getVector()); - }); - - for (const HighlightBlacklistUser &blacklistedUser : - this->blacklistSetting_.getValue()) - { - this->blacklistedUsers.appendItem(blacklistedUser); - } - - this->blacklistedUsers.delayedItemsChanged.connect([this] { - this->blacklistSetting_.setValue(this->blacklistedUsers.getVector()); - }); - - for (const HighlightPhrase &user : this->userSetting_.getValue()) - { - this->highlightedUsers.appendItem(user); - } - - this->highlightedUsers.delayedItemsChanged.connect([this] { // - this->userSetting_.setValue(this->highlightedUsers.getVector()); - }); -} - -HighlightModel *HighlightController::createModel(QObject *parent) -{ - HighlightModel *model = new HighlightModel(parent); - model->init(&this->phrases); - - return model; -} - -UserHighlightModel *HighlightController::createUserModel(QObject *parent) -{ - auto *model = new UserHighlightModel(parent); - model->init(&this->highlightedUsers); - - return model; -} - -bool HighlightController::isHighlightedUser(const QString &username) -{ - const auto &userItems = this->highlightedUsers; - for (const auto &highlightedUser : userItems) - { - if (highlightedUser.isMatch(username)) - { - return true; - } - } - - return false; -} - -HighlightBlacklistModel *HighlightController::createBlacklistModel( - QObject *parent) -{ - auto *model = new HighlightBlacklistModel(parent); - model->init(&this->blacklistedUsers); - - return model; -} - -bool HighlightController::blacklistContains(const QString &username) -{ - for (const auto &blacklistedUser : this->blacklistedUsers) - { - if (blacklistedUser.isMatch(username)) - { - return true; - } - } - - return false; -} - -void HighlightController::addHighlight(const MessagePtr &msg) -{ - // static NotificationPopup popup; - - // popup.updatePosition(); - // popup.addMessage(msg); - // popup.show(); -} - -} // namespace chatterino diff --git a/src/controllers/highlights/HighlightController.hpp b/src/controllers/highlights/HighlightController.hpp deleted file mode 100644 index faa4683c1..000000000 --- a/src/controllers/highlights/HighlightController.hpp +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include "common/ChatterinoSetting.hpp" -#include "common/SignalVector.hpp" -#include "common/Singleton.hpp" -#include "controllers/highlights/HighlightBlacklistUser.hpp" -#include "controllers/highlights/HighlightPhrase.hpp" - -namespace chatterino { - -struct Message; -using MessagePtr = std::shared_ptr; - -class Settings; -class Paths; - -class UserHighlightModel; -class HighlightModel; -class HighlightBlacklistModel; - -class HighlightController final : public Singleton -{ -public: - HighlightController(); - - virtual void initialize(Settings &settings, Paths &paths) override; - - UnsortedSignalVector phrases; - UnsortedSignalVector blacklistedUsers; - UnsortedSignalVector highlightedUsers; - - HighlightModel *createModel(QObject *parent); - HighlightBlacklistModel *createBlacklistModel(QObject *parent); - UserHighlightModel *createUserModel(QObject *parent); - - bool isHighlightedUser(const QString &username); - bool blacklistContains(const QString &username); - - void addHighlight(const MessagePtr &msg); - -private: - bool initialized_ = false; - - ChatterinoSetting> highlightsSetting_ = { - "/highlighting/highlights"}; - ChatterinoSetting> blacklistSetting_ = { - "/highlighting/blacklist"}; - ChatterinoSetting> userSetting_ = { - "/highlighting/users"}; -}; - -} // namespace chatterino diff --git a/src/controllers/highlights/HighlightModel.cpp b/src/controllers/highlights/HighlightModel.cpp index 05f37bf78..12135e3be 100644 --- a/src/controllers/highlights/HighlightModel.cpp +++ b/src/controllers/highlights/HighlightModel.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "singletons/Settings.hpp" +#include "singletons/WindowManager.hpp" #include "util/StandardItemHelper.hpp" namespace chatterino { @@ -63,10 +64,10 @@ void HighlightModel::afterInit() usernameRow[Column::CaseSensitive]->setFlags(0); QUrl selfSound = QUrl(getSettings()->selfHighlightSoundUrl.getValue()); - setFilePathItem(usernameRow[Column::SoundPath], selfSound); + setFilePathItem(usernameRow[Column::SoundPath], selfSound, false); auto selfColor = ColorProvider::instance().color(ColorType::SelfHighlight); - setColorItem(usernameRow[Column::Color], *selfColor); + setColorItem(usernameRow[Column::Color], *selfColor, false); this->insertCustomRow(usernameRow, 0); @@ -86,12 +87,13 @@ void HighlightModel::afterInit() QUrl whisperSound = QUrl(getSettings()->whisperHighlightSoundUrl.getValue()); - setFilePathItem(whisperRow[Column::SoundPath], whisperSound); + setFilePathItem(whisperRow[Column::SoundPath], whisperSound, false); - auto whisperColor = ColorProvider::instance().color(ColorType::Whisper); - setColorItem(whisperRow[Column::Color], *whisperColor); + // auto whisperColor = ColorProvider::instance().color(ColorType::Whisper); + // setColorItem(whisperRow[Column::Color], *whisperColor, false); + whisperRow[Column::Color]->setFlags(Qt::ItemFlag::NoItemFlags); - this->insertCustomRow(whisperRow, 1); + this->insertCustomRow(whisperRow, WHISPER_ROW); // Highlight settings for subscription messages std::vector subRow = this->createRow(); @@ -107,12 +109,39 @@ void HighlightModel::afterInit() subRow[Column::CaseSensitive]->setFlags(0); QUrl subSound = QUrl(getSettings()->subHighlightSoundUrl.getValue()); - setFilePathItem(subRow[Column::SoundPath], subSound); + setFilePathItem(subRow[Column::SoundPath], subSound, false); auto subColor = ColorProvider::instance().color(ColorType::Subscription); - setColorItem(subRow[Column::Color], *subColor); + setColorItem(subRow[Column::Color], *subColor, false); this->insertCustomRow(subRow, 2); + + // Highlight settings for redeemed highlight messages + std::vector redeemedRow = this->createRow(); + setBoolItem(redeemedRow[Column::Pattern], + getSettings()->enableRedeemedHighlight.getValue(), true, false); + redeemedRow[Column::Pattern]->setData( + "Highlights redeemed with Channel Points", Qt::DisplayRole); + // setBoolItem(redeemedRow[Column::FlashTaskbar], + // getSettings()->enableRedeemedHighlightTaskbar.getValue(), true, + // false); + // setBoolItem(redeemedRow[Column::PlaySound], + // getSettings()->enableRedeemedHighlightSound.getValue(), true, + // false); + redeemedRow[Column::FlashTaskbar]->setFlags(0); + redeemedRow[Column::PlaySound]->setFlags(0); + redeemedRow[Column::UseRegex]->setFlags(0); + redeemedRow[Column::CaseSensitive]->setFlags(0); + + QUrl RedeemedSound = + QUrl(getSettings()->redeemedHighlightSoundUrl.getValue()); + setFilePathItem(redeemedRow[Column::SoundPath], RedeemedSound, false); + + auto RedeemedColor = + ColorProvider::instance().color(ColorType::RedeemedHighlight); + setColorItem(redeemedRow[Column::Color], *RedeemedColor, false); + + this->insertCustomRow(redeemedRow, 3); } void HighlightModel::customRowSetData(const std::vector &row, @@ -128,7 +157,7 @@ void HighlightModel::customRowSetData(const std::vector &row, { getSettings()->enableSelfHighlight.setValue(value.toBool()); } - else if (rowIndex == 1) + else if (rowIndex == WHISPER_ROW) { getSettings()->enableWhisperHighlight.setValue( value.toBool()); @@ -137,6 +166,11 @@ void HighlightModel::customRowSetData(const std::vector &row, { getSettings()->enableSubHighlight.setValue(value.toBool()); } + else if (rowIndex == 3) + { + getSettings()->enableRedeemedHighlight.setValue( + value.toBool()); + } } } break; @@ -148,7 +182,7 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableSelfHighlightTaskbar.setValue( value.toBool()); } - else if (rowIndex == 1) + else if (rowIndex == WHISPER_ROW) { getSettings()->enableWhisperHighlightTaskbar.setValue( value.toBool()); @@ -158,6 +192,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableSubHighlightTaskbar.setValue( value.toBool()); } + else if (rowIndex == 3) + { + // getSettings()->enableRedeemedHighlightTaskbar.setValue( + // value.toBool()); + } } } break; @@ -169,7 +208,7 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableSelfHighlightSound.setValue( value.toBool()); } - else if (rowIndex == 1) + else if (rowIndex == WHISPER_ROW) { getSettings()->enableWhisperHighlightSound.setValue( value.toBool()); @@ -179,6 +218,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableSubHighlightSound.setValue( value.toBool()); } + else if (rowIndex == 3) + { + // getSettings()->enableRedeemedHighlightSound.setValue( + // value.toBool()); + } } } break; @@ -199,7 +243,7 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->selfHighlightSoundUrl.setValue( value.toString()); } - else if (rowIndex == 1) + else if (rowIndex == WHISPER_ROW) { getSettings()->whisperHighlightSoundUrl.setValue( value.toString()); @@ -209,6 +253,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->subHighlightSoundUrl.setValue( value.toString()); } + else if (rowIndex == 3) + { + getSettings()->redeemedHighlightSoundUrl.setValue( + value.toString()); + } } } break; @@ -221,18 +270,27 @@ void HighlightModel::customRowSetData(const std::vector &row, { getSettings()->selfHighlightColor.setValue(colorName); } - else if (rowIndex == 1) - { - getSettings()->whisperHighlightColor.setValue(colorName); - } + // else if (rowIndex == WHISPER_ROW) + // { + // getSettings()->whisperHighlightColor.setValue(colorName); + // } else if (rowIndex == 2) { getSettings()->subHighlightColor.setValue(colorName); } + else if (rowIndex == 3) + { + getSettings()->redeemedHighlightColor.setValue(colorName); + const_cast(ColorProvider::instance()) + .updateColor(ColorType::RedeemedHighlight, + QColor(colorName)); + } } } break; } + + getApp()->windows->forceLayoutChannelViews(); } } // namespace chatterino diff --git a/src/controllers/highlights/HighlightModel.hpp b/src/controllers/highlights/HighlightModel.hpp index 13b85bca1..92acd2e0a 100644 --- a/src/controllers/highlights/HighlightModel.hpp +++ b/src/controllers/highlights/HighlightModel.hpp @@ -7,13 +7,11 @@ namespace chatterino { -class HighlightController; - class HighlightModel : public SignalVectorModel { +public: explicit HighlightModel(QObject *parent); -public: // Used here, in HighlightingPage and in UserHighlightModel enum Column { Pattern = 0, @@ -25,6 +23,8 @@ public: Color = 6 }; + constexpr static int WHISPER_ROW = 1; + protected: // turn a vector item into a model row virtual HighlightPhrase getItemFromRow( @@ -40,8 +40,6 @@ protected: virtual void customRowSetData(const std::vector &row, int column, const QVariant &value, int role, int rowIndex) override; - - friend class HighlightController; }; } // namespace chatterino diff --git a/src/controllers/highlights/HighlightPhrase.cpp b/src/controllers/highlights/HighlightPhrase.cpp index 40942df3d..5ffd5b360 100644 --- a/src/controllers/highlights/HighlightPhrase.cpp +++ b/src/controllers/highlights/HighlightPhrase.cpp @@ -2,6 +2,11 @@ namespace chatterino { +QColor HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR = QColor(127, 63, 73, 127); +QColor HighlightPhrase::FALLBACK_REDEEMED_HIGHLIGHT_COLOR = + QColor(28, 126, 141, 90); +QColor HighlightPhrase::FALLBACK_SUB_COLOR = QColor(196, 102, 255, 100); + bool HighlightPhrase::operator==(const HighlightPhrase &other) const { return std::tie(this->pattern_, this->hasSound_, this->hasAlert_, diff --git a/src/controllers/highlights/HighlightPhrase.hpp b/src/controllers/highlights/HighlightPhrase.hpp index 72849bb66..268ae354c 100644 --- a/src/controllers/highlights/HighlightPhrase.hpp +++ b/src/controllers/highlights/HighlightPhrase.hpp @@ -70,6 +70,14 @@ public: const QUrl &getSoundUrl() const; const std::shared_ptr getColor() const; + /* + * XXX: Use the constexpr constructor here once we are building with + * Qt>=5.13. + */ + static QColor FALLBACK_HIGHLIGHT_COLOR; + static QColor FALLBACK_REDEEMED_HIGHLIGHT_COLOR; + static QColor FALLBACK_SUB_COLOR; + private: QString pattern_; bool hasAlert_; @@ -132,6 +140,8 @@ struct Deserialize { chatterino::rj::getSafe(value, "color", encodedColor); auto _color = QColor(encodedColor); + if (!_color.isValid()) + _color = chatterino::HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR; return chatterino::HighlightPhrase(_pattern, _hasAlert, _hasSound, _isRegex, _isCaseSensitive, diff --git a/src/controllers/highlights/UserHighlightModel.hpp b/src/controllers/highlights/UserHighlightModel.hpp index dcc42a950..de4332433 100644 --- a/src/controllers/highlights/UserHighlightModel.hpp +++ b/src/controllers/highlights/UserHighlightModel.hpp @@ -11,6 +11,7 @@ class HighlightController; class UserHighlightModel : public SignalVectorModel { +public: explicit UserHighlightModel(QObject *parent); protected: @@ -21,8 +22,6 @@ protected: virtual void getRowFromItem(const HighlightPhrase &item, std::vector &row) override; - - friend class HighlightController; }; } // namespace chatterino diff --git a/src/controllers/ignores/IgnoreController.cpp b/src/controllers/ignores/IgnoreController.cpp deleted file mode 100644 index 5548ee5d4..000000000 --- a/src/controllers/ignores/IgnoreController.cpp +++ /dev/null @@ -1,33 +0,0 @@ -#include "controllers/ignores/IgnoreController.hpp" - -#include "Application.hpp" -#include "controllers/ignores/IgnoreModel.hpp" - -#include - -namespace chatterino { - -void IgnoreController::initialize(Settings &, Paths &) -{ - assert(!this->initialized_); - this->initialized_ = true; - - for (const IgnorePhrase &phrase : this->ignoresSetting_.getValue()) - { - this->phrases.appendItem(phrase); - } - - this->phrases.delayedItemsChanged.connect([this] { // - this->ignoresSetting_.setValue(this->phrases.getVector()); - }); -} - -IgnoreModel *IgnoreController::createModel(QObject *parent) -{ - IgnoreModel *model = new IgnoreModel(parent); - model->init(&this->phrases); - - return model; -} - -} // namespace chatterino diff --git a/src/controllers/ignores/IgnoreController.hpp b/src/controllers/ignores/IgnoreController.hpp index 1279f4a88..fed12f12c 100644 --- a/src/controllers/ignores/IgnoreController.hpp +++ b/src/controllers/ignores/IgnoreController.hpp @@ -1,33 +1,7 @@ #pragma once -#include "common/ChatterinoSetting.hpp" -#include "common/SignalVector.hpp" -#include "common/Singleton.hpp" -#include "controllers/ignores/IgnorePhrase.hpp" - namespace chatterino { -class Settings; -class Paths; - -class IgnoreModel; - enum class ShowIgnoredUsersMessages { Never, IfModerator, IfBroadcaster }; -class IgnoreController final : public Singleton -{ -public: - virtual void initialize(Settings &settings, Paths &paths) override; - - UnsortedSignalVector phrases; - - IgnoreModel *createModel(QObject *parent); - -private: - bool initialized_ = false; - - ChatterinoSetting> ignoresSetting_ = { - "/ignore/phrases"}; -}; - } // namespace chatterino diff --git a/src/controllers/ignores/IgnoreModel.hpp b/src/controllers/ignores/IgnoreModel.hpp index 1b9a5099e..473b61b93 100644 --- a/src/controllers/ignores/IgnoreModel.hpp +++ b/src/controllers/ignores/IgnoreModel.hpp @@ -7,10 +7,9 @@ namespace chatterino { -class IgnoreController; - class IgnoreModel : public SignalVectorModel { +public: explicit IgnoreModel(QObject *parent); protected: @@ -21,8 +20,6 @@ protected: // turns a row in the model into a vector item virtual void getRowFromItem(const IgnorePhrase &item, std::vector &row) override; - - friend class IgnoreController; }; } // namespace chatterino diff --git a/src/controllers/moderationactions/ModerationAction.cpp b/src/controllers/moderationactions/ModerationAction.cpp index dce01904c..d94cb234d 100644 --- a/src/controllers/moderationactions/ModerationAction.cpp +++ b/src/controllers/moderationactions/ModerationAction.cpp @@ -71,11 +71,11 @@ ModerationAction::ModerationAction(const QString &action) } else if (action.startsWith("/ban ")) { - this->image_ = Image::fromPixmap(getResources().buttons.ban); + this->imageToLoad_ = 1; } else if (action.startsWith("/delete ")) { - this->image_ = Image::fromPixmap(getResources().buttons.trashCan); + this->imageToLoad_ = 2; } else { @@ -100,6 +100,16 @@ bool ModerationAction::isImage() const const boost::optional &ModerationAction::getImage() const { + assertInGuiThread(); + + if (this->imageToLoad_ != 0) + { + if (this->imageToLoad_ == 1) + this->image_ = Image::fromPixmap(getResources().buttons.ban); + else if (this->imageToLoad_ == 2) + this->image_ = Image::fromPixmap(getResources().buttons.trashCan); + } + return this->image_; } diff --git a/src/controllers/moderationactions/ModerationAction.hpp b/src/controllers/moderationactions/ModerationAction.hpp index eed7579a5..3b4532d91 100644 --- a/src/controllers/moderationactions/ModerationAction.hpp +++ b/src/controllers/moderationactions/ModerationAction.hpp @@ -25,10 +25,11 @@ public: const QString &getAction() const; private: - boost::optional image_; + mutable boost::optional image_; QString line1_; QString line2_; QString action_; + int imageToLoad_{}; }; } // namespace chatterino diff --git a/src/controllers/moderationactions/ModerationActionModel.hpp b/src/controllers/moderationactions/ModerationActionModel.hpp index e9f372361..e13b3b27b 100644 --- a/src/controllers/moderationactions/ModerationActionModel.hpp +++ b/src/controllers/moderationactions/ModerationActionModel.hpp @@ -7,8 +7,6 @@ namespace chatterino { -class ModerationActions; - class ModerationActionModel : public SignalVectorModel { public: @@ -25,8 +23,6 @@ protected: std::vector &row) override; friend class HighlightController; - - friend class ModerationActions; }; } // namespace chatterino diff --git a/src/controllers/moderationactions/ModerationActions.cpp b/src/controllers/moderationactions/ModerationActions.cpp deleted file mode 100644 index 0104c7de1..000000000 --- a/src/controllers/moderationactions/ModerationActions.cpp +++ /dev/null @@ -1,42 +0,0 @@ -#include "ModerationActions.hpp" - -#include "Application.hpp" -#include "controllers/moderationactions/ModerationActionModel.hpp" -#include "singletons/Settings.hpp" - -#include - -namespace chatterino { - -ModerationActions::ModerationActions() -{ -} - -void ModerationActions::initialize(Settings &settings, Paths &paths) -{ - assert(!this->initialized_); - this->initialized_ = true; - - this->setting_ = - std::make_unique>>( - "/moderation/actions"); - - for (auto &val : this->setting_->getValue()) - { - this->items.insertItem(val); - } - - this->items.delayedItemsChanged.connect([this] { // - this->setting_->setValue(this->items.getVector()); - }); -} - -ModerationActionModel *ModerationActions::createModel(QObject *parent) -{ - ModerationActionModel *model = new ModerationActionModel(parent); - model->init(&this->items); - - return model; -} - -} // namespace chatterino diff --git a/src/controllers/moderationactions/ModerationActions.hpp b/src/controllers/moderationactions/ModerationActions.hpp deleted file mode 100644 index e1bf1b802..000000000 --- a/src/controllers/moderationactions/ModerationActions.hpp +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include "common/Singleton.hpp" - -#include "common/ChatterinoSetting.hpp" -#include "common/SignalVector.hpp" -#include "controllers/moderationactions/ModerationAction.hpp" - -namespace chatterino { - -class Settings; -class Paths; - -class ModerationActionModel; - -class ModerationActions final : public Singleton -{ -public: - ModerationActions(); - - virtual void initialize(Settings &settings, Paths &paths) override; - - UnsortedSignalVector items; - - ModerationActionModel *createModel(QObject *parent); - -private: - std::unique_ptr>> setting_; - bool initialized_ = false; -}; - -} // namespace chatterino diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index b9777c802..2578558df 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -4,8 +4,8 @@ #include "common/NetworkRequest.hpp" #include "common/Outcome.hpp" #include "controllers/notifications/NotificationModel.hpp" -#include "providers/twitch/TwitchApi.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "providers/twitch/api/Helix.hpp" #include "singletons/Toasts.hpp" #include "singletons/WindowManager.hpp" #include "widgets/Window.hpp" @@ -26,12 +26,11 @@ void NotificationController::initialize(Settings &settings, Paths &paths) this->initialized_ = true; for (const QString &channelName : this->twitchSetting_.getValue()) { - this->channelMap[Platform::Twitch].appendItem(channelName); + this->channelMap[Platform::Twitch].append(channelName); } this->channelMap[Platform::Twitch].delayedItemsChanged.connect([this] { // - this->twitchSetting_.setValue( - this->channelMap[Platform::Twitch].getVector()); + this->twitchSetting_.setValue(this->channelMap[Platform::Twitch].raw()); }); /* for (const QString &channelName : this->mixerSetting_.getValue()) { @@ -81,18 +80,18 @@ bool NotificationController::isChannelNotified(const QString &channelName, void NotificationController::addChannelNotification(const QString &channelName, Platform p) { - channelMap[p].appendItem(channelName); + channelMap[p].append(channelName); } void NotificationController::removeChannelNotification( const QString &channelName, Platform p) { - for (std::vector::size_type i = 0; - i != channelMap[p].getVector().size(); i++) + for (std::vector::size_type i = 0; i != channelMap[p].raw().size(); + i++) { - if (channelMap[p].getVector()[i].toLower() == channelName.toLower()) + if (channelMap[p].raw()[i].toLower() == channelName.toLower()) { - channelMap[p].removeItem(i); + channelMap[p].removeAt(i); i--; } } @@ -121,21 +120,21 @@ NotificationModel *NotificationController::createModel(QObject *parent, Platform p) { NotificationModel *model = new NotificationModel(parent); - model->init(&this->channelMap[p]); + model->initialize(&this->channelMap[p]); return model; } void NotificationController::fetchFakeChannels() { for (std::vector::size_type i = 0; - i != channelMap[Platform::Twitch].getVector().size(); i++) + i != channelMap[Platform::Twitch].raw().size(); i++) { auto chan = getApp()->twitch.server->getChannelOrEmpty( - channelMap[Platform::Twitch].getVector()[i]); + channelMap[Platform::Twitch].raw()[i]); if (chan->isEmpty()) { getFakeTwitchChannelLiveStatus( - channelMap[Platform::Twitch].getVector()[i]); + channelMap[Platform::Twitch].raw()[i]); } } } @@ -143,68 +142,52 @@ void NotificationController::fetchFakeChannels() void NotificationController::getFakeTwitchChannelLiveStatus( const QString &channelName) { - TwitchApi::findUserId(channelName, [channelName, this](QString roomID) { - if (roomID.isEmpty()) - { + getHelix()->getStreamByName( + channelName, + [channelName, this](bool live, const auto &stream) { + qDebug() << "[TwitchChannel" << channelName + << "] Refreshing live status"; + + if (!live) + { + // Stream is offline + this->removeFakeChannel(channelName); + return; + } + + // Stream is online + auto i = std::find(fakeTwitchChannels.begin(), + fakeTwitchChannels.end(), channelName); + + if (i != fakeTwitchChannels.end()) + { + // We have already pushed the live state of this stream + // Could not find stream in fake twitch channels! + return; + } + + if (Toasts::isEnabled()) + { + getApp()->toasts->sendChannelNotification(channelName, + Platform::Twitch); + } + if (getSettings()->notificationPlaySound) + { + getApp()->notifications->playSound(); + } + if (getSettings()->notificationFlashTaskbar) + { + getApp()->windows->sendAlert(); + } + + // Indicate that we have pushed notifications for this stream + fakeTwitchChannels.push_back(channelName); + }, + [channelName, this] { qDebug() << "[TwitchChannel" << channelName << "] Refreshing live status (Missing ID)"; - removeFakeChannel(channelName); - return; - } - qDebug() << "[TwitchChannel" << channelName - << "] Refreshing live status"; - - QString url("https://api.twitch.tv/kraken/streams/" + roomID); - NetworkRequest::twitchRequest(url) - .onSuccess([this, channelName](auto result) -> Outcome { - rapidjson::Document document = result.parseRapidJson(); - if (!document.IsObject()) - { - qDebug() << "[TwitchChannel:refreshLiveStatus] root is not " - "an object"; - return Failure; - } - - if (!document.HasMember("stream")) - { - qDebug() << "[TwitchChannel:refreshLiveStatus] Missing " - "stream in root"; - return Failure; - } - - const auto &stream = document["stream"]; - - if (!stream.IsObject()) - { - // Stream is offline (stream is most likely null) - // removeFakeChannel(channelName); - return Failure; - } - // Stream is live - auto i = std::find(fakeTwitchChannels.begin(), - fakeTwitchChannels.end(), channelName); - - if (!(i != fakeTwitchChannels.end())) - { - fakeTwitchChannels.push_back(channelName); - if (Toasts::isEnabled()) - { - getApp()->toasts->sendChannelNotification( - channelName, Platform::Twitch); - } - if (getSettings()->notificationPlaySound) - { - getApp()->notifications->playSound(); - } - if (getSettings()->notificationFlashTaskbar) - { - getApp()->windows->sendAlert(); - } - } - return Success; - }) - .execute(); - }); + this->removeFakeChannel(channelName); + }); } void NotificationController::removeFakeChannel(const QString channelName) diff --git a/src/controllers/notifications/NotificationController.hpp b/src/controllers/notifications/NotificationController.hpp index 228348add..e8d0c4ef7 100644 --- a/src/controllers/notifications/NotificationController.hpp +++ b/src/controllers/notifications/NotificationController.hpp @@ -30,9 +30,9 @@ public: void playSound(); - UnsortedSignalVector getVector(Platform p); + SignalVector getVector(Platform p); - std::map> channelMap; + std::map> channelMap; NotificationModel *createModel(QObject *parent, Platform p); @@ -43,6 +43,7 @@ private: void removeFakeChannel(const QString channelName); void getFakeTwitchChannelLiveStatus(const QString &channelName); + // fakeTwitchChannels is a list of streams who are live that we have already sent out a notification for std::vector fakeTwitchChannels; QTimer *liveStatusTimer_; diff --git a/src/controllers/pings/MutedChannelModel.cpp b/src/controllers/pings/MutedChannelModel.cpp new file mode 100644 index 000000000..fc1473506 --- /dev/null +++ b/src/controllers/pings/MutedChannelModel.cpp @@ -0,0 +1,28 @@ +#include "MutedChannelModel.hpp" + +#include "Application.hpp" +#include "singletons/Settings.hpp" +#include "util/StandardItemHelper.hpp" + +namespace chatterino { + +MutedChannelModel::MutedChannelModel(QObject *parent) + : SignalVectorModel(1, parent) +{ +} + +// turn a vector item into a model row +QString MutedChannelModel::getItemFromRow(std::vector &row, + const QString &original) +{ + return QString(row[0]->data(Qt::DisplayRole).toString()); +} + +// turn a model +void MutedChannelModel::getRowFromItem(const QString &item, + std::vector &row) +{ + setStringItem(row[0], item); +} + +} // namespace chatterino diff --git a/src/controllers/pings/PingModel.hpp b/src/controllers/pings/MutedChannelModel.hpp similarity index 79% rename from src/controllers/pings/PingModel.hpp rename to src/controllers/pings/MutedChannelModel.hpp index 137be5e0c..c53b3177c 100644 --- a/src/controllers/pings/PingModel.hpp +++ b/src/controllers/pings/MutedChannelModel.hpp @@ -7,11 +7,11 @@ namespace chatterino { -class PingController; +class MutedChannelController; -class PingModel : public SignalVectorModel +class MutedChannelModel : public SignalVectorModel { - explicit PingModel(QObject *parent); + explicit MutedChannelModel(QObject *parent); protected: // turn a vector item into a model row @@ -21,8 +21,6 @@ protected: // turns a row in the model into a vector item virtual void getRowFromItem(const QString &item, std::vector &row) override; - - friend class PingController; }; } // namespace chatterino diff --git a/src/controllers/pings/PingController.cpp b/src/controllers/pings/PingController.cpp deleted file mode 100644 index b61d6e795..000000000 --- a/src/controllers/pings/PingController.cpp +++ /dev/null @@ -1,70 +0,0 @@ -#include "controllers/pings/PingController.hpp" -#include "controllers/pings/PingModel.hpp" - -namespace chatterino { - -void PingController::initialize(Settings &settings, Paths &paths) -{ - this->initialized_ = true; - for (const QString &channelName : this->pingSetting_.getValue()) - { - this->channelVector.appendItem(channelName); - } - - this->channelVector.delayedItemsChanged.connect([this] { // - this->pingSetting_.setValue(this->channelVector.getVector()); - }); -} - -PingModel *PingController::createModel(QObject *parent) -{ - PingModel *model = new PingModel(parent); - model->init(&this->channelVector); - return model; -} - -bool PingController::isMuted(const QString &channelName) -{ - for (const auto &channel : this->channelVector) - { - if (channelName.toLower() == channel.toLower()) - { - return true; - } - } - return false; -} - -void PingController::muteChannel(const QString &channelName) -{ - channelVector.appendItem(channelName); -} - -void PingController::unmuteChannel(const QString &channelName) -{ - for (std::vector::size_type i = 0; - i != channelVector.getVector().size(); i++) - { - if (channelVector.getVector()[i].toLower() == channelName.toLower()) - { - channelVector.removeItem(i); - i--; - } - } -} - -bool PingController::toggleMuteChannel(const QString &channelName) -{ - if (this->isMuted(channelName)) - { - unmuteChannel(channelName); - return false; - } - else - { - muteChannel(channelName); - return true; - } -} - -} // namespace chatterino diff --git a/src/controllers/pings/PingController.hpp b/src/controllers/pings/PingController.hpp deleted file mode 100644 index 80805e856..000000000 --- a/src/controllers/pings/PingController.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -#include "common/SignalVector.hpp" -#include "common/Singleton.hpp" -#include "singletons/Settings.hpp" - -namespace chatterino { - -class Settings; -class Paths; - -class PingModel; - -class PingController final : public Singleton, private QObject -{ -public: - virtual void initialize(Settings &settings, Paths &paths) override; - - bool isMuted(const QString &channelName); - void muteChannel(const QString &channelName); - void unmuteChannel(const QString &channelName); - bool toggleMuteChannel(const QString &channelName); - - PingModel *createModel(QObject *parent); - -private: - bool initialized_ = false; - - UnsortedSignalVector channelVector; - - ChatterinoSetting> pingSetting_ = {"/pings/muted"}; -}; - -} // namespace chatterino diff --git a/src/controllers/pings/PingModel.cpp b/src/controllers/pings/PingModel.cpp deleted file mode 100644 index 28098a209..000000000 --- a/src/controllers/pings/PingModel.cpp +++ /dev/null @@ -1,28 +0,0 @@ -#include "PingModel.hpp" - -#include "Application.hpp" -#include "singletons/Settings.hpp" -#include "util/StandardItemHelper.hpp" - -namespace chatterino { - -PingModel::PingModel(QObject *parent) - : SignalVectorModel(1, parent) -{ -} - -// turn a vector item into a model row -QString PingModel::getItemFromRow(std::vector &row, - const QString &original) -{ - return QString(row[0]->data(Qt::DisplayRole).toString()); -} - -// turn a model -void PingModel::getRowFromItem(const QString &item, - std::vector &row) -{ - setStringItem(row[0], item); -} - -} // namespace chatterino diff --git a/src/controllers/taggedusers/TaggedUsersController.cpp b/src/controllers/taggedusers/TaggedUsersController.cpp deleted file mode 100644 index 2aa82a6c3..000000000 --- a/src/controllers/taggedusers/TaggedUsersController.cpp +++ /dev/null @@ -1,19 +0,0 @@ -#include "TaggedUsersController.hpp" - -#include "controllers/taggedusers/TaggedUsersModel.hpp" - -namespace chatterino { - -TaggedUsersController::TaggedUsersController() -{ -} - -TaggedUsersModel *TaggedUsersController::createModel(QObject *parent) -{ - TaggedUsersModel *model = new TaggedUsersModel(parent); - model->init(&this->users); - - return model; -} - -} // namespace chatterino diff --git a/src/controllers/taggedusers/TaggedUsersController.hpp b/src/controllers/taggedusers/TaggedUsersController.hpp deleted file mode 100644 index db9ff2d14..000000000 --- a/src/controllers/taggedusers/TaggedUsersController.hpp +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "common/Singleton.hpp" - -#include "common/SignalVector.hpp" -#include "controllers/taggedusers/TaggedUser.hpp" - -namespace chatterino { - -class TaggedUsersModel; - -class TaggedUsersController final : public Singleton -{ -public: - TaggedUsersController(); - - SortedSignalVector> users; - - TaggedUsersModel *createModel(QObject *parent = nullptr); -}; - -} // namespace chatterino diff --git a/src/main.cpp b/src/main.cpp index 463465894..67a05b7b9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,8 @@ #include "common/Args.hpp" #include "common/Modes.hpp" #include "common/Version.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/api/Kraken.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" #include "util/IncognitoBrowser.hpp" @@ -19,6 +21,10 @@ int main(int argc, char **argv) { QApplication a(argc, argv); + QCoreApplication::setApplicationName("chatterino"); + QCoreApplication::setApplicationVersion(CHATTERINO_VERSION); + QCoreApplication::setOrganizationDomain("https://www.chatterino.com"); + // convert char** to QStringList auto args = QStringList(); std::transform(argv + 1, argv + argc, std::back_inserter(args), @@ -32,10 +38,19 @@ int main(int argc, char **argv) } else if (getArgs().printVersion) { - qInfo().noquote() << Version::instance().fullVersion(); + auto version = Version::instance(); + qInfo().noquote() << QString("%1 (commit %2%3)") + .arg(version.fullVersion()) + .arg(version.commitHash()) + .arg(Modes::instance().isNightly + ? ", " + version.dateOfBuild() + : ""); } else { + Helix::initialize(); + Kraken::initialize(); + Paths *paths{}; try diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index b42223012..37a8a644b 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -41,6 +41,22 @@ namespace detail { getApp()->emotes->gifTimer.signal.connect( [this] { this->advance(); }); } + + auto totalLength = std::accumulate( + this->items_.begin(), this->items_.end(), 0UL, + [](auto init, auto &&frame) { return init + frame.duration; }); + + if (totalLength == 0) + { + this->durationOffset_ = 0; + } + else + { + this->durationOffset_ = std::min( + int(getApp()->emotes->gifTimer.position() % totalLength), + 60000); + } + this->processOffset(); } Frames::~Frames() @@ -58,17 +74,21 @@ namespace detail { void Frames::advance() { - this->durationOffset_ += GIF_FRAME_LENGTH; + this->durationOffset_ += gifFrameLength; + this->processOffset(); + } + + void Frames::processOffset() + { + if (this->items_.isEmpty()) + { + return; + } while (true) { this->index_ %= this->items_.size(); - // TODO: Figure out what this was supposed to achieve - // if (this->index_ >= this->items_.size()) { - // this->index_ = this->index_; - // } - if (this->durationOffset_ > this->items_[this->index_].duration) { this->durationOffset_ -= this->items_[this->index_].duration; @@ -219,10 +239,6 @@ ImagePtr Image::fromUrl(const Url &url, qreal scale) { cache[url] = shared = ImagePtr(new Image(url, scale)); } - else - { - // qDebug() << "same image created multiple times:" << url.string; - } return shared; } diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index f114d1f0f..193ba5787 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -35,6 +35,7 @@ namespace detail { boost::optional first() const; private: + void processOffset(); QVector> items_; int index_{0}; int durationOffset_{0}; diff --git a/src/messages/LimitedQueue.hpp b/src/messages/LimitedQueue.hpp index 61e8db56f..f206704f5 100644 --- a/src/messages/LimitedQueue.hpp +++ b/src/messages/LimitedQueue.hpp @@ -125,8 +125,6 @@ public: newChunks->at(0) = newFirstChunk; this->chunks_ = newChunks; - // qDebug() << acceptedItems.size(); - // qDebug() << this->chunks->at(0)->size(); if (this->chunks_->size() == 1) { @@ -225,8 +223,13 @@ public: this->firstChunkOffset_, this->lastChunkEnd_); } + bool empty() const + { + return this->limit_ - this->space() == 0; + } + private: - qsizetype space() + qsizetype space() const { size_t totalSize = 0; for (auto &chunk : *this->chunks_) diff --git a/src/messages/Link.cpp b/src/messages/Link.cpp index 7b5bcdb7d..e87c214a3 100644 --- a/src/messages/Link.cpp +++ b/src/messages/Link.cpp @@ -19,4 +19,9 @@ bool Link::isValid() const return this->type != None; } +bool Link::isUrl() const +{ + return this->type == Url; +} + } // namespace chatterino diff --git a/src/messages/Link.hpp b/src/messages/Link.hpp index 52a52444b..4545ec8cf 100644 --- a/src/messages/Link.hpp +++ b/src/messages/Link.hpp @@ -28,6 +28,7 @@ public: QString value; bool isValid() const; + bool isUrl() const; }; } // namespace chatterino diff --git a/src/messages/Message.cpp b/src/messages/Message.cpp index 218fa3a73..912eeefec 100644 --- a/src/messages/Message.cpp +++ b/src/messages/Message.cpp @@ -35,6 +35,12 @@ SBHighlight Message::getScrollBarHighlight() const return SBHighlight( ColorProvider::instance().color(ColorType::Subscription)); } + else if (this->flags.has(MessageFlag::RedeemedHighlight)) + { + return SBHighlight( + ColorProvider::instance().color(ColorType::RedeemedHighlight), + SBHighlight::Default, true); + } return SBHighlight(); } diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index 3b5bb97a8..b5c97b553 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -34,6 +34,8 @@ enum class MessageFlag : uint32_t { HighlightedWhisper = (1 << 17), Debug = (1 << 18), Similar = (1 << 19), + RedeemedHighlight = (1 << 20), + RedeemedChannelPointReward = (1 << 21), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 6e4b8115e..545d8735f 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "common/LinkParser.hpp" +#include "controllers/accounts/AccountController.hpp" #include "messages/Image.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" @@ -23,6 +24,11 @@ MessagePtr makeSystemMessage(const QString &text) return MessageBuilder(systemMessage, text).release(); } +MessagePtr makeSystemMessage(const QString &text, const QTime &time) +{ + return MessageBuilder(systemMessage, text, time).release(); +} + std::pair makeAutomodMessage( const AutomodAction &action) { @@ -92,10 +98,11 @@ MessageBuilder::MessageBuilder() { } -MessageBuilder::MessageBuilder(SystemMessageTag, const QString &text) +MessageBuilder::MessageBuilder(SystemMessageTag, const QString &text, + const QTime &time) : MessageBuilder() { - this->emplace(); + this->emplace(time); // check system message for links // (e.g. needed for sub ticket message in sub only mode) @@ -119,17 +126,40 @@ MessageBuilder::MessageBuilder(SystemMessageTag, const QString &text) this->message().searchText = text; } +MessageBuilder::MessageBuilder(TimeoutMessageTag, + const QString &systemMessageText, int times) + : MessageBuilder() +{ + QString username = systemMessageText.split(" ").at(0); + QString remainder = systemMessageText.mid(username.length() + 1); + + QString text; + + this->emplace(); + this->emplaceSystemTextAndUpdate(username, text) + ->setLink({Link::UserInfo, username}); + this->emplaceSystemTextAndUpdate( + QString("%1 (%2 times)").arg(remainder.trimmed()).arg(times), text); + + this->message().messageText = text; + this->message().searchText = text; +} + MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &username, const QString &durationInSeconds, const QString &reason, bool multipleTimes) : MessageBuilder() { + QString fullText; QString text; - text.append(username); + this->emplace(); + this->emplaceSystemTextAndUpdate(username, fullText) + ->setLink({Link::UserInfo, username}); + if (!durationInSeconds.isEmpty()) { - text.append(" has been timed out"); + text.append("has been timed out"); // TODO: Implement who timed the user out @@ -143,7 +173,7 @@ MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &username, } else { - text.append(" has been permanently banned"); + text.append("has been permanently banned"); } if (reason.length() > 0) @@ -163,16 +193,18 @@ MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &username, this->message().flags.set(MessageFlag::Timeout); this->message().flags.set(MessageFlag::DoNotTriggerNotification); this->message().timeoutUser = username; - this->emplace(); - this->emplace(text, MessageElementFlag::Text, - MessageColor::System); - this->message().messageText = text; - this->message().searchText = text; + + this->emplaceSystemTextAndUpdate(text, fullText); + this->message().messageText = fullText; + this->message().searchText = fullText; } +// XXX: This does not belong in the MessageBuilder, this should be part of the TwitchMessageBuilder MessageBuilder::MessageBuilder(const BanAction &action, uint32_t count) : MessageBuilder() { + auto current = getApp()->accounts->twitch.getCurrent(); + this->emplace(); this->message().flags.set(MessageFlag::System); this->message().flags.set(MessageFlag::Timeout); @@ -181,48 +213,84 @@ MessageBuilder::MessageBuilder(const BanAction &action, uint32_t count) QString text; - if (action.isBan()) + if (action.target.id == current->getUserId()) { - if (action.reason.isEmpty()) + this->emplaceSystemTextAndUpdate("You were", text); + if (action.isBan()) { - text = QString("%1 banned %2.") // - .arg(action.source.name) - .arg(action.target.name); + this->emplaceSystemTextAndUpdate("banned", text); } else { - text = QString("%1 banned %2: \"%3\".") // - .arg(action.source.name) - .arg(action.target.name) - .arg(action.reason); + this->emplaceSystemTextAndUpdate( + QString("timed out for %1").arg(formatTime(action.duration)), + text); + } + + if (!action.source.name.isEmpty()) + { + this->emplaceSystemTextAndUpdate("by", text); + this->emplaceSystemTextAndUpdate( + action.source.name + (action.reason.isEmpty() ? "." : ":"), + text) + ->setLink({Link::UserInfo, action.source.name}); + } + + if (!action.reason.isEmpty()) + { + this->emplaceSystemTextAndUpdate( + QString("\"%1\".").arg(action.reason), text); } } else { - if (action.reason.isEmpty()) + if (action.isBan()) { - text = QString("%1 timed out %2 for %3.") // - .arg(action.source.name) - .arg(action.target.name) - .arg(formatTime(action.duration)); + this->emplaceSystemTextAndUpdate(action.source.name, text) + ->setLink({Link::UserInfo, action.source.name}); + this->emplaceSystemTextAndUpdate("banned", text); + if (action.reason.isEmpty()) + { + this->emplaceSystemTextAndUpdate(action.target.name, text) + ->setLink({Link::UserInfo, action.target.name}); + } + else + { + this->emplaceSystemTextAndUpdate(action.target.name + ":", text) + ->setLink({Link::UserInfo, action.target.name}); + this->emplaceSystemTextAndUpdate( + QString("\"%1\".").arg(action.reason), text); + } } else { - text = QString("%1 timed out %2 for %3: \"%4\".") // - .arg(action.source.name) - .arg(action.target.name) - .arg(formatTime(action.duration)) - .arg(action.reason); - } + this->emplaceSystemTextAndUpdate(action.source.name, text) + ->setLink({Link::UserInfo, action.source.name}); + this->emplaceSystemTextAndUpdate("timed out", text); + this->emplaceSystemTextAndUpdate(action.target.name, text) + ->setLink({Link::UserInfo, action.target.name}); + if (action.reason.isEmpty()) + { + this->emplaceSystemTextAndUpdate( + QString("for %1.").arg(formatTime(action.duration)), text); + } + else + { + this->emplaceSystemTextAndUpdate( + QString("for %1: \"%2\".") + .arg(formatTime(action.duration)) + .arg(action.reason), + text); + } - if (count > 1) - { - text.append(QString(" (%1 times)").arg(count)); + if (count > 1) + { + this->emplaceSystemTextAndUpdate( + QString("(%1 times)").arg(count), text); + } } } - this->emplace(text, MessageElementFlag::Text, - MessageColor::System); this->message().messageText = text; this->message().searchText = text; } @@ -236,14 +304,15 @@ MessageBuilder::MessageBuilder(const UnbanAction &action) this->message().timeoutUser = action.target.name; - QString text = - QString("%1 %2 %3.") - .arg(action.source.name) - .arg(QString(action.wasBan() ? "unbanned" : "untimedout")) - .arg(action.target.name); + QString text; + + this->emplaceSystemTextAndUpdate(action.source.name, text) + ->setLink({Link::UserInfo, action.source.name}); + this->emplaceSystemTextAndUpdate( + action.wasBan() ? "unbanned" : "untimedout", text); + this->emplaceSystemTextAndUpdate(action.target.name, text) + ->setLink({Link::UserInfo, action.target.name}); - this->emplace(text, MessageElementFlag::Text, - MessageColor::System); this->message().messageText = text; this->message().searchText = text; } @@ -384,7 +453,8 @@ void MessageBuilder::addLink(const QString &origLink, LinkResolver::getLinkInfo( matchedLink, nullptr, [weakMessage = this->weakOf(), linkMELowercase, linkMEOriginal, - matchedLink](QString tooltipText, Link originalLink) { + matchedLink](QString tooltipText, Link originalLink, + ImagePtr thumbnail) { auto shared = weakMessage.lock(); if (!shared) { @@ -401,7 +471,21 @@ void MessageBuilder::addLink(const QString &origLink, linkMELowercase->setLink(originalLink)->updateLink(); linkMEOriginal->setLink(originalLink)->updateLink(); } + linkMELowercase->setThumbnail(thumbnail); + linkMELowercase->setThumbnailType( + MessageElement::ThumbnailType::Link_Thumbnail); + linkMEOriginal->setThumbnail(thumbnail); + linkMEOriginal->setThumbnailType( + MessageElement::ThumbnailType::Link_Thumbnail); }); } +TextElement *MessageBuilder::emplaceSystemTextAndUpdate(const QString &text, + QString &toUpdate) +{ + toUpdate.append(text + " "); + return this->emplace(text, MessageElementFlag::Text, + MessageColor::System); +} + } // namespace chatterino diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 4793ce9d9..a8e04b628 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -22,6 +22,7 @@ const SystemMessageTag systemMessage{}; const TimeoutMessageTag timeoutMessage{}; MessagePtr makeSystemMessage(const QString &text); +MessagePtr makeSystemMessage(const QString &text, const QTime &time); std::pair makeAutomodMessage( const AutomodAction &action); @@ -31,14 +32,17 @@ struct MessageParseArgs { bool isSentWhisper = false; bool trimSubscriberUsername = false; bool isStaffOrBroadcaster = false; + QString channelPointRewardId = ""; }; class MessageBuilder - { public: MessageBuilder(); - MessageBuilder(SystemMessageTag, const QString &text); + MessageBuilder(SystemMessageTag, const QString &text, + const QTime &time = QTime::currentTime()); + MessageBuilder(TimeoutMessageTag, const QString &systemMessageText, + int times); MessageBuilder(TimeoutMessageTag, const QString &username, const QString &durationInSeconds, const QString &reason, bool multipleTimes); @@ -68,6 +72,13 @@ public: } private: + // Helper method that emplaces some text stylized as system text + // and then appends that text to the QString parameter "toUpdate". + // Returns the TextElement that was emplaced. + TextElement *emplaceSystemTextAndUpdate(const QString &text, + QString &toUpdate); + std::shared_ptr message_; }; + } // namespace chatterino diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 0defed597..e3318511a 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -1,7 +1,7 @@ #include "messages/MessageElement.hpp" #include "Application.hpp" -#include "controllers/moderationactions/ModerationActions.hpp" +#include "common/IrcColors.hpp" #include "debug/Benchmark.hpp" #include "messages/Emote.hpp" #include "messages/layouts/MessageLayoutContainer.hpp" @@ -12,6 +12,14 @@ namespace chatterino { +namespace { + + QRegularExpression IRC_COLOR_PARSE_REGEX( + "(\u0003(\\d{1,2})?(,(\\d{1,2}))?|\u000f)", + QRegularExpression::UseUnicodePropertiesOption); + +} // namespace + MessageElement::MessageElement(MessageElementFlags flags) : flags_(flags) { @@ -41,6 +49,18 @@ MessageElement *MessageElement::setTooltip(const QString &tooltip) return this; } +MessageElement *MessageElement::setThumbnail(const ImagePtr &thumbnail) +{ + this->thumbnail_ = thumbnail; + return this; +} + +MessageElement *MessageElement::setThumbnailType(const ThumbnailType type) +{ + this->thumbnailType_ = type; + return this; +} + MessageElement *MessageElement::setTrailingSpace(bool value) { this->trailingSpace = value; @@ -52,6 +72,16 @@ const QString &MessageElement::getTooltip() const return this->tooltip_; } +const ImagePtr &MessageElement::getThumbnail() const +{ + return this->thumbnail_; +} + +const MessageElement::ThumbnailType &MessageElement::getThumbnailType() const +{ + return this->thumbnailType_; +} + const Link &MessageElement::getLink() const { return this->link_; @@ -165,21 +195,6 @@ MessageLayoutElement *EmoteElement::makeImageLayoutElement( return new ImageLayoutElement(*this, image, size); } -// MOD BADGE -ModBadgeElement::ModBadgeElement(const EmotePtr &data, - MessageElementFlags flags_) - : EmoteElement(data, flags_) -{ -} - -MessageLayoutElement *ModBadgeElement::makeImageLayoutElement( - const ImagePtr &image, const QSize &size) -{ - static const QColor modBadgeBackgroundColor("#34AE0A"); - return new ImageWithBackgroundLayoutElement(*this, image, size, - modBadgeBackgroundColor); -} - // BADGE BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags) : MessageElement(flags) @@ -201,8 +216,7 @@ void BadgeElement::addToContainer(MessageLayoutContainer &container, auto size = QSize(int(container.getScale() * image->width()), int(container.getScale() * image->height())); - container.addElement((new ImageLayoutElement(*this, image, size)) - ->setLink(this->getLink())); + container.addElement(this->makeImageLayoutElement(image, size)); } } @@ -211,6 +225,34 @@ EmotePtr BadgeElement::getEmote() const return this->emote_; } +MessageLayoutElement *BadgeElement::makeImageLayoutElement( + const ImagePtr &image, const QSize &size) +{ + auto element = + (new ImageLayoutElement(*this, image, size))->setLink(this->getLink()); + + return element; +} + +// MOD BADGE +ModBadgeElement::ModBadgeElement(const EmotePtr &data, + MessageElementFlags flags_) + : BadgeElement(data, flags_) +{ +} + +MessageLayoutElement *ModBadgeElement::makeImageLayoutElement( + const ImagePtr &image, const QSize &size) +{ + static const QColor modBadgeBackgroundColor("#34AE0A"); + + auto element = (new ImageWithBackgroundLayoutElement( + *this, image, size, modBadgeBackgroundColor)) + ->setLink(this->getLink()); + + return element; +} + // TEXT TextElement::TextElement(const QString &text, MessageElementFlags flags, const MessageColor &color, FontStyle style) @@ -319,10 +361,9 @@ void TextElement::addToContainer(MessageLayoutContainer &container, if (isSurrogate) i++; } - - container.addElement(getTextLayoutElement( + //add the final piece of wrapped text + container.addElementNoLineBreak(getTextLayoutElement( text.mid(wordStart), width, this->hasTrailingSpace())); - container.breakLine(); } } } @@ -374,8 +415,8 @@ void TwitchModerationElement::addToContainer(MessageLayoutContainer &container, { QSize size(int(container.getScale() * 16), int(container.getScale() * 16)); - - for (const auto &action : getApp()->moderationActions->items) + auto actions = getCSettings().moderationActions.readOnly(); + for (const auto &action : *actions) { if (auto image = action.getImage()) { @@ -395,4 +436,283 @@ void TwitchModerationElement::addToContainer(MessageLayoutContainer &container, } } +// TEXT +// IrcTextElement gets its color from the color code in the message, and can change from character to character. +// This differs from the TextElement +IrcTextElement::IrcTextElement(const QString &fullText, + MessageElementFlags flags, FontStyle style) + : MessageElement(flags) + , style_(style) +{ + assert(IRC_COLOR_PARSE_REGEX.isValid()); + + // Default pen colors. -1 = default theme colors + int fg = -1, bg = -1; + + // Split up the message in words (space separated) + // Each word contains one or more colored segments. + // The color of that segment is "global", as in it can be decided by the word before it. + for (const auto &text : fullText.split(' ')) + { + std::vector segments; + + int pos = 0; + int lastPos = 0; + + auto i = IRC_COLOR_PARSE_REGEX.globalMatch(text); + + while (i.hasNext()) + { + auto match = i.next(); + + if (lastPos != match.capturedStart() && match.capturedStart() != 0) + { + auto seg = Segment{}; + seg.text = text.mid(lastPos, match.capturedStart() - lastPos); + seg.fg = fg; + seg.bg = bg; + segments.emplace_back(seg); + lastPos = match.capturedStart() + match.capturedLength(); + } + if (!match.captured(1).isEmpty()) + { + fg = -1; + bg = -1; + } + + if (!match.captured(2).isEmpty()) + { + fg = match.captured(2).toInt(nullptr); + } + else + { + fg = -1; + } + if (!match.captured(4).isEmpty()) + { + bg = match.captured(4).toInt(nullptr); + } + else if (fg == -1) + { + bg = -1; + } + + lastPos = match.capturedStart() + match.capturedLength(); + } + + auto seg = Segment{}; + seg.text = text.mid(lastPos); + seg.fg = fg; + seg.bg = bg; + segments.emplace_back(seg); + + QString n(text); + + n.replace(IRC_COLOR_PARSE_REGEX, ""); + + Word w{ + n, + -1, + segments, + }; + this->words_.emplace_back(w); + } +} + +void IrcTextElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + auto app = getApp(); + + MessageColor defaultColorType = MessageColor::Text; + auto defaultColor = defaultColorType.getColor(*app->themes); + if (flags.hasAny(this->getFlags())) + { + QFontMetrics metrics = + app->fonts->getFontMetrics(this->style_, container.getScale()); + + for (auto &word : this->words_) + { + auto getTextLayoutElement = [&](QString text, + std::vector segments, + int width, bool hasTrailingSpace) { + std::vector xd{}; + + for (const auto &segment : segments) + { + QColor color = defaultColor; + if (segment.fg >= 0 && segment.fg <= 98) + { + color = IRC_COLORS[segment.fg]; + } + app->themes->normalizeColor(color); + xd.emplace_back(PajSegment{segment.text, color}); + } + + auto e = (new MultiColorTextLayoutElement( + *this, text, QSize(width, metrics.height()), xd, + this->style_, container.getScale())) + ->setLink(this->getLink()); + e->setTrailingSpace(true); + e->setText(text); + + // If URL link was changed, + // Should update it in MessageLayoutElement too! + if (this->getLink().type == Link::Url) + { + static_cast(e)->listenToLinkChanges(); + } + return e; + }; + + // fourtf: add again + // if (word.width == -1) { + word.width = metrics.width(word.text); + // } + + // see if the text fits in the current line + if (container.fitsInLine(word.width)) + { + container.addElementNoLineBreak( + getTextLayoutElement(word.text, word.segments, word.width, + this->hasTrailingSpace())); + continue; + } + + // see if the text fits in the next line + if (!container.atStartOfLine()) + { + container.breakLine(); + + if (container.fitsInLine(word.width)) + { + container.addElementNoLineBreak(getTextLayoutElement( + word.text, word.segments, word.width, + this->hasTrailingSpace())); + continue; + } + } + + // we done goofed, we need to wrap the text + QString text = word.text; + std::vector segments = word.segments; + int textLength = text.length(); + int wordStart = 0; + int width = 0; + + // QChar::isHighSurrogate(text[0].unicode()) ? 2 : 1 + + // XXX(pajlada): NOT TESTED + for (int i = 0; i < textLength; i++) // + { + auto isSurrogate = text.size() > i + 1 && + QChar::isHighSurrogate(text[i].unicode()); + + auto charWidth = isSurrogate ? metrics.width(text.mid(i, 2)) + : metrics.width(text[i]); + + if (!container.fitsInLine(width + charWidth)) + { + std::vector pieceSegments; + int charactersLeft = i - wordStart; + assert(charactersLeft > 0); + for (auto segmentIt = segments.begin(); + segmentIt != segments.end();) + { + assert(charactersLeft > 0); + auto &segment = *segmentIt; + if (charactersLeft >= segment.text.length()) + { + // Entire segment fits in this piece + pieceSegments.push_back(segment); + charactersLeft -= segment.text.length(); + segmentIt = segments.erase(segmentIt); + + assert(charactersLeft >= 0); + + if (charactersLeft == 0) + { + break; + } + } + else + { + // Only part of the segment fits in this piece + // We create a new segment with the characters that fit, and modify the segment we checked to only contain the characters we didn't consume + Segment segmentThatFitsInPiece{ + segment.text.left(charactersLeft), segment.fg, + segment.bg}; + pieceSegments.emplace_back(segmentThatFitsInPiece); + segment.text = segment.text.mid(charactersLeft); + + break; + } + } + + container.addElementNoLineBreak( + getTextLayoutElement(text.mid(wordStart, i - wordStart), + pieceSegments, width, false)); + container.breakLine(); + + wordStart = i; + width = charWidth; + + if (isSurrogate) + i++; + continue; + } + + width += charWidth; + + if (isSurrogate) + i++; + } + + // Add last remaining text & segments + container.addElementNoLineBreak( + getTextLayoutElement(text.mid(wordStart), segments, width, + this->hasTrailingSpace())); + } + } +} + +LinebreakElement::LinebreakElement(MessageElementFlags flags) + : MessageElement(flags) +{ +} + +void LinebreakElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + if (flags.hasAny(this->getFlags())) + { + container.breakLine(); + } +} + +ScalingImageElement::ScalingImageElement(ImageSet images, + MessageElementFlags flags) + : MessageElement(flags) + , images_(images) +{ +} + +void ScalingImageElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + if (flags.hasAny(this->getFlags())) + { + const auto &image = + this->images_.getImageOrLoaded(container.getScale()); + if (image->isEmpty()) + return; + + auto size = QSize(image->width() * container.getScale(), + image->height() * container.getScale()); + + container.addElement((new ImageLayoutElement(*this, image, size)) + ->setLink(this->getLink())); + } +} + } // namespace chatterino diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 7d1a00c6e..8da2bdf9c 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -4,6 +4,7 @@ #include "messages/Link.hpp" #include "messages/MessageColor.hpp" #include "singletons/Fonts.hpp" +#include "src/messages/ImageSet.hpp" #include #include @@ -39,6 +40,10 @@ enum class MessageElementFlag { BttvEmoteImage = (1 << 6), BttvEmoteText = (1 << 7), BttvEmote = BttvEmoteImage | BttvEmoteText, + + ChannelPointReward = (1 << 8), + ChannelPointRewardImage = ChannelPointReward | TwitchEmoteImage, + FfzEmoteImage = (1 << 10), FfzEmoteText = (1 << 11), FfzEmote = FfzEmoteImage | FfzEmoteText, @@ -122,14 +127,23 @@ public: Update_Images = 4, Update_All = Update_Text | Update_Emotes | Update_Images }; + enum ThumbnailType : char { + Link_Thumbnail = 1, + }; virtual ~MessageElement(); MessageElement *setLink(const Link &link); MessageElement *setText(const QString &text); MessageElement *setTooltip(const QString &tooltip); + MessageElement *setThumbnailType(const ThumbnailType type); + MessageElement *setThumbnail(const ImagePtr &thumbnail); + MessageElement *setTrailingSpace(bool value); const QString &getTooltip() const; + const ImagePtr &getThumbnail() const; + const ThumbnailType &getThumbnailType() const; + const Link &getLink() const; bool hasTrailingSpace() const; MessageElementFlags getFlags() const; @@ -148,6 +162,8 @@ private: QString text_; Link link_; QString tooltip_; + ImagePtr thumbnail_; + ThumbnailType thumbnailType_; MessageElementFlags flags_; }; @@ -223,17 +239,6 @@ private: EmotePtr emote_; }; -// Behaves like an emote element, except it creates a different image layout element that draws the mod badge background -class ModBadgeElement : public EmoteElement -{ -public: - ModBadgeElement(const EmotePtr &data, MessageElementFlags flags_); - -protected: - MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, - const QSize &size) override; -}; - class BadgeElement : public MessageElement { public: @@ -244,10 +249,24 @@ public: EmotePtr getEmote() const; +protected: + virtual MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, + const QSize &size); + private: EmotePtr emote_; }; +class ModBadgeElement : public BadgeElement +{ +public: + ModBadgeElement(const EmotePtr &data, MessageElementFlags flags_); + +protected: + MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, + const QSize &size) override; +}; + // contains a text, formated depending on the preferences class TimestampElement : public MessageElement { @@ -277,4 +296,56 @@ public: MessageElementFlags flags) override; }; +// contains a full message string that's split into words on space and parses irc colors that are then put into segments +// these segments are later passed to "MultiColorTextLayoutElement" elements to be rendered :) +class IrcTextElement : public MessageElement +{ +public: + IrcTextElement(const QString &text, MessageElementFlags flags, + FontStyle style = FontStyle::ChatMedium); + ~IrcTextElement() override = default; + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + +private: + FontStyle style_; + + struct Segment { + QString text; + int fg = -1; + int bg = -1; + }; + + struct Word { + QString text; + int width = -1; + std::vector segments; + }; + + std::vector words_; +}; + +// Forces a linebreak +class LinebreakElement : public MessageElement +{ +public: + LinebreakElement(MessageElementFlags flags); + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; +}; + +// Image element which will pick the quality of the image based on ui scale +class ScalingImageElement : public MessageElement +{ +public: + ScalingImageElement(ImageSet images, MessageElementFlags flags); + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + +private: + ImageSet images_; +}; } // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp new file mode 100644 index 000000000..26a2d85a5 --- /dev/null +++ b/src/messages/SharedMessageBuilder.cpp @@ -0,0 +1,399 @@ +#include "messages/SharedMessageBuilder.hpp" + +#include "Application.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" +#include "messages/Message.hpp" +#include "messages/MessageElement.hpp" +#include "providers/twitch/TwitchCommon.hpp" +#include "singletons/Settings.hpp" +#include "singletons/WindowManager.hpp" + +namespace chatterino { + +namespace { + + QUrl getFallbackHighlightSound() + { + QString path = getSettings()->pathHighlightSound; + bool fileExists = QFileInfo::exists(path) && QFileInfo(path).isFile(); + + // Use fallback sound when checkbox is not checked + // or custom file doesn't exist + if (getSettings()->customHighlightSound && fileExists) + { + return QUrl::fromLocalFile(path); + } + else + { + return QUrl("qrc:/sounds/ping2.wav"); + } + } + +} // namespace + +SharedMessageBuilder::SharedMessageBuilder( + Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, + const MessageParseArgs &_args) + : channel(_channel) + , ircMessage(_ircMessage) + , args(_args) + , tags(this->ircMessage->tags()) + , originalMessage_(_ircMessage->content()) + , action_(_ircMessage->isAction()) +{ +} + +SharedMessageBuilder::SharedMessageBuilder( + Channel *_channel, const Communi::IrcMessage *_ircMessage, + const MessageParseArgs &_args, QString content, bool isAction) + : channel(_channel) + , ircMessage(_ircMessage) + , args(_args) + , tags(this->ircMessage->tags()) + , originalMessage_(content) + , action_(isAction) +{ +} + +namespace { + + QColor getRandomColor(const QString &v) + { + int colorSeed = 0; + for (const auto &c : v) + { + colorSeed += c.digitValue(); + } + const auto colorIndex = colorSeed % TWITCH_USERNAME_COLORS.size(); + return TWITCH_USERNAME_COLORS[colorIndex]; + } + +} // namespace + +void SharedMessageBuilder::parse() +{ + this->parseUsernameColor(); + + this->parseUsername(); + + this->message().flags.set(MessageFlag::Collapsed); +} + +bool SharedMessageBuilder::isIgnored() const +{ + // TODO(pajlada): Do we need to check if the phrase is valid first? + auto phrases = getCSettings().ignoredMessages.readOnly(); + for (const auto &phrase : *phrases) + { + if (phrase.isBlock() && phrase.isMatch(this->originalMessage_)) + { + qDebug() << "Blocking message because it contains ignored phrase" + << phrase.getPattern(); + return true; + } + } + + return false; +} + +void SharedMessageBuilder::parseUsernameColor() +{ + if (getSettings()->colorizeNicknames) + { + this->usernameColor_ = getRandomColor(this->ircMessage->nick()); + } +} + +void SharedMessageBuilder::parseUsername() +{ + // username + this->userName = this->ircMessage->nick(); + + this->message().loginName = this->userName; +} + +void SharedMessageBuilder::parseHighlights() +{ + auto app = getApp(); + + if (this->message().flags.has(MessageFlag::Subscription) && + getSettings()->enableSubHighlight) + { + if (getSettings()->enableSubHighlightTaskbar) + { + this->highlightAlert_ = true; + } + + if (getSettings()->enableSubHighlightSound) + { + this->highlightSound_ = true; + + // Use custom sound if set, otherwise use fallback + if (!getSettings()->subHighlightSoundUrl.getValue().isEmpty()) + { + this->highlightSoundUrl_ = + QUrl(getSettings()->subHighlightSoundUrl.getValue()); + } + else + { + this->highlightSoundUrl_ = getFallbackHighlightSound(); + } + } + + this->message().flags.set(MessageFlag::Highlighted); + this->message().highlightColor = + ColorProvider::instance().color(ColorType::Subscription); + + // This message was a subscription. + // Don't check for any other highlight phrases. + return; + } + + // XXX: Non-common term in SharedMessageBuilder + auto currentUser = app->accounts->twitch.getCurrent(); + + QString currentUsername = currentUser->getUserName(); + + if (getCSettings().isBlacklistedUser(this->ircMessage->nick())) + { + // Do nothing. We ignore highlights from this user. + return; + } + + // Highlight because it's a whisper + if (this->args.isReceivedWhisper && getSettings()->enableWhisperHighlight) + { + if (getSettings()->enableWhisperHighlightTaskbar) + { + this->highlightAlert_ = true; + } + + if (getSettings()->enableWhisperHighlightSound) + { + this->highlightSound_ = true; + + // Use custom sound if set, otherwise use fallback + if (!getSettings()->whisperHighlightSoundUrl.getValue().isEmpty()) + { + this->highlightSoundUrl_ = + QUrl(getSettings()->whisperHighlightSoundUrl.getValue()); + } + else + { + this->highlightSoundUrl_ = getFallbackHighlightSound(); + } + } + + this->message().highlightColor = + ColorProvider::instance().color(ColorType::Whisper); + + /* + * Do _NOT_ return yet, we might want to apply phrase/user name + * highlights (which override whisper color/sound). + */ + } + + // Highlight because of sender + auto userHighlights = getCSettings().highlightedUsers.readOnly(); + for (const HighlightPhrase &userHighlight : *userHighlights) + { + if (!userHighlight.isMatch(this->ircMessage->nick())) + { + continue; + } + qDebug() << "Highlight because user" << this->ircMessage->nick() + << "sent a message"; + + this->message().flags.set(MessageFlag::Highlighted); + this->message().highlightColor = userHighlight.getColor(); + + if (userHighlight.hasAlert()) + { + this->highlightAlert_ = true; + } + + if (userHighlight.hasSound()) + { + this->highlightSound_ = true; + // Use custom sound if set, otherwise use the fallback sound + if (userHighlight.hasCustomSound()) + { + this->highlightSoundUrl_ = userHighlight.getSoundUrl(); + } + else + { + this->highlightSoundUrl_ = getFallbackHighlightSound(); + } + } + + if (this->highlightAlert_ && this->highlightSound_) + { + /* + * User name highlights "beat" highlight phrases: If a message has + * all attributes (color, taskbar flashing, sound) set, highlight + * phrases will not be checked. + */ + return; + } + } + + if (this->ircMessage->nick() == currentUsername) + { + // Do nothing. Highlights cannot be triggered by yourself + return; + } + + // TODO: This vector should only be rebuilt upon highlights being changed + // fourtf: should be implemented in the HighlightsController + std::vector activeHighlights = + getSettings()->highlightedMessages.cloneVector(); + + if (getSettings()->enableSelfHighlight && currentUsername.size() > 0) + { + HighlightPhrase selfHighlight( + currentUsername, getSettings()->enableSelfHighlightTaskbar, + getSettings()->enableSelfHighlightSound, false, false, + getSettings()->selfHighlightSoundUrl.getValue(), + ColorProvider::instance().color(ColorType::SelfHighlight)); + activeHighlights.emplace_back(std::move(selfHighlight)); + } + + // Highlight because of message + for (const HighlightPhrase &highlight : activeHighlights) + { + if (!highlight.isMatch(this->originalMessage_)) + { + continue; + } + + this->message().flags.set(MessageFlag::Highlighted); + this->message().highlightColor = highlight.getColor(); + + if (highlight.hasAlert()) + { + this->highlightAlert_ = true; + } + + // Only set highlightSound_ if it hasn't been set by username + // highlights already. + if (highlight.hasSound() && !this->highlightSound_) + { + this->highlightSound_ = true; + + // Use custom sound if set, otherwise use fallback sound + if (highlight.hasCustomSound()) + { + this->highlightSoundUrl_ = highlight.getSoundUrl(); + } + else + { + this->highlightSoundUrl_ = getFallbackHighlightSound(); + } + } + + if (this->highlightAlert_ && this->highlightSound_) + { + /* + * Break once no further attributes (taskbar, sound) can be + * applied. + */ + break; + } + } +} + +void SharedMessageBuilder::addTextOrEmoji(EmotePtr emote) +{ + this->emplace(emote, MessageElementFlag::EmojiAll); +} + +void SharedMessageBuilder::addTextOrEmoji(const QString &string_) +{ + auto string = QString(string_); + + // Actually just text + auto linkString = this->matchLink(string); + auto link = Link(); + auto textColor = this->action_ ? MessageColor(this->usernameColor_) + : MessageColor(MessageColor::Text); + + if (linkString.isEmpty()) + { + if (string.startsWith('@')) + { + this->emplace(string, MessageElementFlag::BoldUsername, + textColor, FontStyle::ChatMediumBold); + this->emplace( + string, MessageElementFlag::NonBoldUsername, textColor); + } + else + { + this->emplace(string, MessageElementFlag::Text, + textColor); + } + } + else + { + this->addLink(string, linkString); + } +} + +void SharedMessageBuilder::appendChannelName() +{ + QString channelName("#" + this->channel->getName()); + Link link(Link::Url, this->channel->getName() + "\n" + this->message().id); + + this->emplace(channelName, MessageElementFlag::ChannelName, + MessageColor::System) // + ->setLink(link); +} + +inline QMediaPlayer *getPlayer() +{ + if (isGuiThread()) + { + static auto player = new QMediaPlayer; + return player; + } + else + { + return nullptr; + } +} + +void SharedMessageBuilder::triggerHighlights() +{ + static QUrl currentPlayerUrl; + + if (getCSettings().isMutedChannel(this->channel->getName())) + { + // Do nothing. Pings are muted in this channel. + return; + } + + bool hasFocus = (QApplication::focusWidget() != nullptr); + bool resolveFocus = !hasFocus || getSettings()->highlightAlwaysPlaySound; + + if (this->highlightSound_ && resolveFocus) + { + if (auto player = getPlayer()) + { + // update the media player url if necessary + if (currentPlayerUrl != this->highlightSoundUrl_) + { + player->setMedia(this->highlightSoundUrl_); + + currentPlayerUrl = this->highlightSoundUrl_; + } + + player->play(); + } + } + + if (this->highlightAlert_) + { + getApp()->windows->sendAlert(); + } +} + +} // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.hpp b/src/messages/SharedMessageBuilder.hpp new file mode 100644 index 000000000..355c2908d --- /dev/null +++ b/src/messages/SharedMessageBuilder.hpp @@ -0,0 +1,69 @@ +#include "messages/MessageBuilder.hpp" + +#include "common/Aliases.hpp" +#include "common/Outcome.hpp" + +#include +#include + +namespace chatterino { + +class SharedMessageBuilder : public MessageBuilder +{ +public: + SharedMessageBuilder() = delete; + + explicit SharedMessageBuilder(Channel *_channel, + const Communi::IrcPrivateMessage *_ircMessage, + const MessageParseArgs &_args); + + explicit SharedMessageBuilder(Channel *_channel, + const Communi::IrcMessage *_ircMessage, + const MessageParseArgs &_args, + QString content, bool isAction); + + QString userName; + + [[nodiscard]] virtual bool isIgnored() const; + + // triggerHighlights triggers any alerts or sounds parsed by parseHighlights + virtual void triggerHighlights(); + virtual MessagePtr build() = 0; + +protected: + virtual void parse(); + + virtual void parseUsernameColor(); + + virtual void parseUsername(); + + virtual Outcome tryAppendEmote(const EmoteName &name) + { + return Failure; + } + + // parseHighlights only updates the visual state of the message, but leaves the playing of alerts and sounds to the triggerHighlights function + virtual void parseHighlights(); + + virtual void addTextOrEmoji(EmotePtr emote); + virtual void addTextOrEmoji(const QString &value); + + void appendChannelName(); + + Channel *channel; + const Communi::IrcMessage *ircMessage; + MessageParseArgs args; + const QVariantMap tags; + QString originalMessage_; + + const bool action_{}; + + QColor usernameColor_; + + bool highlightAlert_ = false; + bool highlightSound_ = false; + + QUrl highlightSoundUrl_; +}; + +} // namespace chatterino diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index c048bd626..0e7547ffa 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -169,7 +169,7 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) // Painting void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, Selection &selection, bool isLastReadMessage, - bool isWindowFocused) + bool isWindowFocused, bool isMentions) { auto app = getApp(); QPixmap *pixmap = this->buffer_.get(); @@ -220,6 +220,14 @@ void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, app->themes->messages.disabled); } + if (!isMentions && + this->message_->flags.has(MessageFlag::RedeemedChannelPointReward)) + { + painter.fillRect( + 0, y, this->scale_ * 4, pixmap->height(), + *ColorProvider::instance().color(ColorType::Subscription)); + } + // draw selection if (!selection.isEmpty()) { @@ -263,6 +271,7 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, Selection & /*selection*/) { auto app = getApp(); + auto settings = getSettings(); QPainter painter(buffer); painter.setRenderHint(QPainter::SmoothPixmapTransform); @@ -296,6 +305,14 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, backgroundColor, *ColorProvider::instance().color(ColorType::Subscription)); } + else if (this->message_->flags.has(MessageFlag::RedeemedHighlight) && + settings->enableRedeemedHighlight.getValue()) + { + // Blend highlight color with usual background color + backgroundColor = blendColors( + backgroundColor, + *ColorProvider::instance().color(ColorType::RedeemedHighlight)); + } else if (this->message_->flags.has(MessageFlag::AutoMod)) { backgroundColor = QColor("#404040"); diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index cd9bdeb5d..6048aca6b 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -47,7 +47,7 @@ public: // Painting void paint(QPainter &painter, int width, int y, int messageIndex, Selection &selection, bool isLastReadMessage, - bool isWindowFocused); + bool isWindowFocused, bool isMentions); void invalidateBuffer(); void deleteBuffer(); void deleteCache(); diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index 9c46e0c27..543b3f4e8 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -123,9 +123,20 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, xOffset -= element->getRect().width() + this->spaceWidth_; } + auto yOffset = 0; + + if (element->getCreator().getFlags().has( + MessageElementFlag::ChannelPointReward) && + element->getCreator().getFlags().hasNone( + {MessageElementFlag::TwitchEmoteImage})) + { + yOffset -= (this->margin.top * this->scale_); + } + // set move element - element->setPosition(QPoint(this->currentX_ + xOffset, - this->currentY_ - element->getRect().height())); + element->setPosition( + QPoint(this->currentX_ + xOffset, + this->currentY_ - element->getRect().height() + yOffset)); // add element this->elements_.push_back(std::unique_ptr(element)); diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index 7990d6a6c..49edde0ef 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -395,4 +395,41 @@ int TextIconLayoutElement::getXFromIndex(int index) } } +// +// TEXT +// + +MultiColorTextLayoutElement::MultiColorTextLayoutElement( + MessageElement &_creator, QString &_text, const QSize &_size, + std::vector segments, FontStyle _style, float _scale) + : TextLayoutElement(_creator, _text, _size, QColor{}, _style, _scale) + , segments_(segments) +{ + this->setText(_text); +} + +void MultiColorTextLayoutElement::paint(QPainter &painter) +{ + auto app = getApp(); + + painter.setPen(this->color_); + + painter.setFont(app->fonts->getFont(this->style_, this->scale_)); + + int xOffset = 0; + + auto metrics = app->fonts->getFontMetrics(this->style_, this->scale_); + + for (const auto &segment : this->segments_) + { + // qDebug() << "Draw segment:" << segment.text; + painter.setPen(segment.color); + painter.drawText(QRectF(this->getRect().x() + xOffset, + this->getRect().y(), 10000, 10000), + segment.text, + QTextOption(Qt::AlignLeft | Qt::AlignTop)); + xOffset += metrics.width(segment.text); + } +} + } // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 60d166a4c..fd711af88 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -107,7 +107,6 @@ protected: int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(int index) override; -private: QColor color_; FontStyle style_; float scale_; @@ -138,4 +137,25 @@ private: QString line2; }; +struct PajSegment { + QString text; + QColor color; +}; + +// TEXT +class MultiColorTextLayoutElement : public TextLayoutElement +{ +public: + MultiColorTextLayoutElement(MessageElement &creator_, QString &text, + const QSize &size, + std::vector segments, + FontStyle style_, float scale_); + +protected: + void paint(QPainter &painter) override; + +private: + std::vector segments_; +}; + } // namespace chatterino diff --git a/src/providers/LinkResolver.cpp b/src/providers/LinkResolver.cpp index 50f6c9716..41f511eb5 100644 --- a/src/providers/LinkResolver.cpp +++ b/src/providers/LinkResolver.cpp @@ -3,6 +3,7 @@ #include "common/Common.hpp" #include "common/Env.hpp" #include "common/NetworkRequest.hpp" +#include "messages/Image.hpp" #include "messages/Link.hpp" #include "singletons/Settings.hpp" @@ -12,11 +13,11 @@ namespace chatterino { void LinkResolver::getLinkInfo( const QString url, QObject *caller, - std::function successCallback) + std::function successCallback) { if (!getSettings()->linkInfoTooltip) { - successCallback("No link info loaded", Link(Link::Url, url)); + successCallback("No link info loaded", Link(Link::Url, url), nullptr); return; } // Uncomment to test crashes @@ -25,30 +26,35 @@ void LinkResolver::getLinkInfo( QUrl::toPercentEncoding(url, "", "/:")))) .caller(caller) .timeout(30000) - .onSuccess([successCallback, url](auto result) mutable -> Outcome { - auto root = result.parseJson(); - auto statusCode = root.value("status").toInt(); - QString response = QString(); - QString linkString = url; - if (statusCode == 200) - { - response = root.value("tooltip").toString(); - if (getSettings()->unshortLinks) + .onSuccess( + [successCallback, url](NetworkResult result) mutable -> Outcome { + auto root = result.parseJson(); + auto statusCode = root.value("status").toInt(); + QString response = QString(); + QString linkString = url; + ImagePtr thumbnail = nullptr; + if (statusCode == 200) { - linkString = root.value("link").toString(); + response = root.value("tooltip").toString(); + thumbnail = + Image::fromUrl({root.value("thumbnail").toString()}); + if (getSettings()->unshortLinks) + { + linkString = root.value("link").toString(); + } } - } - else - { - response = root.value("message").toString(); - } - successCallback(QUrl::fromPercentEncoding(response.toUtf8()), - Link(Link::Url, linkString)); + else + { + response = root.value("message").toString(); + } + successCallback(QUrl::fromPercentEncoding(response.toUtf8()), + Link(Link::Url, linkString), thumbnail); - return Success; - }) + return Success; + }) .onError([successCallback, url](auto /*result*/) { - successCallback("No link info found", Link(Link::Url, url)); + successCallback("No link info found", Link(Link::Url, url), + nullptr); }) .execute(); // }); diff --git a/src/providers/LinkResolver.hpp b/src/providers/LinkResolver.hpp index d4e9804f9..d1b24d690 100644 --- a/src/providers/LinkResolver.hpp +++ b/src/providers/LinkResolver.hpp @@ -3,6 +3,7 @@ #include #include +#include "messages/Image.hpp" #include "messages/Link.hpp" namespace chatterino { @@ -10,8 +11,9 @@ namespace chatterino { class LinkResolver { public: - static void getLinkInfo(const QString url, QObject *caller, - std::function callback); + static void getLinkInfo( + const QString url, QObject *caller, + std::function callback); }; } // namespace chatterino diff --git a/src/providers/bttv/BttvEmotes.cpp b/src/providers/bttv/BttvEmotes.cpp index d0c4c2a19..815e6afa4 100644 --- a/src/providers/bttv/BttvEmotes.cpp +++ b/src/providers/bttv/BttvEmotes.cpp @@ -8,6 +8,7 @@ #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/ImageSet.hpp" +#include "messages/MessageBuilder.hpp" #include "providers/twitch/TwitchChannel.hpp" namespace chatterino { @@ -64,11 +65,12 @@ namespace { return {Success, std::move(emotes)}; } - std::pair parseChannelEmotes(const QJsonObject &jsonRoot) + std::pair parseChannelEmotes(const QJsonObject &jsonRoot, + const QString &userName) { auto emotes = EmoteMap(); - auto innerParse = [&jsonRoot, &emotes](const char *key) { + auto innerParse = [&jsonRoot, &emotes, &userName](const char *key) { auto jsonEmotes = jsonRoot.value(key).toArray(); for (auto jsonEmote_ : jsonEmotes) { @@ -76,6 +78,10 @@ namespace { auto id = EmoteId{jsonEmote.value("id").toString()}; auto name = EmoteName{jsonEmote.value("code").toString()}; + auto author = EmoteAuthor{jsonEmote.value("user") + .toObject() + .value("name") + .toString()}; // emoteObject.value("imageType").toString(); auto emote = Emote({ @@ -85,7 +91,10 @@ namespace { Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5), Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25), }, - Tooltip{name.string + "
Channel BetterTTV Emote"}, + Tooltip{name.string + "
Channel BetterTTV Emote" + + ((author.string.isEmpty()) + ? "
By: " + userName.toUtf8() + : "
By: " + author.string)}, Url{emoteLinkFormat.arg(id.string)}, }); @@ -138,17 +147,51 @@ void BttvEmotes::loadEmotes() .execute(); } -void BttvEmotes::loadChannel(const QString &channelId, - std::function callback) +void BttvEmotes::loadChannel(std::weak_ptr channel, + const QString &channelId, const QString &userName, + std::function callback, + bool manualRefresh) { NetworkRequest(QString(bttvChannelEmoteApiUrl) + channelId) .timeout(3000) - .onSuccess([callback = std::move(callback)](auto result) -> Outcome { - auto pair = parseChannelEmotes(result.parseJson()); + .onSuccess([callback = std::move(callback), channel, &userName, + manualRefresh](auto result) -> Outcome { + auto pair = parseChannelEmotes(result.parseJson(), userName); if (pair.first) callback(std::move(pair.second)); + if (auto shared = channel.lock(); manualRefresh) + shared->addMessage( + makeSystemMessage("BetterTTV channel emotes reloaded.")); return pair.first; }) + .onError([channelId, channel, manualRefresh](auto result) { + auto shared = channel.lock(); + if (!shared) + return; + if (result.status() == 203) + { + // User does not have any BTTV emotes + if (manualRefresh) + shared->addMessage(makeSystemMessage( + "This channel has no BetterTTV channel emotes.")); + } + else if (result.status() == NetworkResult::timedoutStatus) + { + // TODO: Auto retry in case of a timeout, with a delay + qDebug() << "Fetching BTTV emotes for channel" << channelId + << "failed due to timeout"; + shared->addMessage(makeSystemMessage( + "Failed to fetch BetterTTV channel emotes. (timed out)")); + } + else + { + qDebug() << "Error fetching BTTV emotes for channel" + << channelId << ", error" << result.status(); + shared->addMessage( + makeSystemMessage("Failed to fetch BetterTTV channel " + "emotes. (unknown error)")); + } + }) .execute(); } diff --git a/src/providers/bttv/BttvEmotes.hpp b/src/providers/bttv/BttvEmotes.hpp index 9c16edeff..72b21aef4 100644 --- a/src/providers/bttv/BttvEmotes.hpp +++ b/src/providers/bttv/BttvEmotes.hpp @@ -4,6 +4,7 @@ #include "boost/optional.hpp" #include "common/Aliases.hpp" #include "common/Atomic.hpp" +#include "providers/twitch/TwitchChannel.hpp" namespace chatterino { @@ -24,8 +25,10 @@ public: std::shared_ptr emotes() const; boost::optional emote(const EmoteName &name) const; void loadEmotes(); - static void loadChannel(const QString &channelId, - std::function callback); + static void loadChannel(std::weak_ptr channel, + const QString &channelId, const QString &userName, + std::function callback, + bool manualRefresh); private: Atomic> global_; diff --git a/src/providers/colors/ColorProvider.cpp b/src/providers/colors/ColorProvider.cpp index 492689aa0..ad35db056 100644 --- a/src/providers/colors/ColorProvider.cpp +++ b/src/providers/colors/ColorProvider.cpp @@ -1,6 +1,5 @@ #include "providers/colors/ColorProvider.hpp" -#include "controllers/highlights/HighlightController.hpp" #include "singletons/Theme.hpp" namespace chatterino { @@ -38,12 +37,12 @@ QSet ColorProvider::recentColors() const * Currently, only colors used in highlight phrases are considered. This * may change at any point in the future. */ - for (auto phrase : getApp()->highlights->phrases) + for (auto phrase : getSettings()->highlightedMessages) { retVal.insert(*phrase.getColor()); } - for (auto userHl : getApp()->highlights->highlightedUsers) + for (auto userHl : getSettings()->highlightedUsers) { retVal.insert(*userHl.getColor()); } @@ -65,7 +64,6 @@ void ColorProvider::initTypeColorMap() { // Read settings for custom highlight colors and save them in map. // If no custom values can be found, set up default values instead. - auto backgrounds = getApp()->themes->messages.backgrounds; QString customColor = getSettings()->selfHighlightColor; if (QColor(customColor).isValid()) @@ -77,7 +75,8 @@ void ColorProvider::initTypeColorMap() { this->typeColorMap_.insert( {ColorType::SelfHighlight, - std::make_shared(backgrounds.highlighted)}); + std::make_shared( + HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR)}); } customColor = getSettings()->subHighlightColor; @@ -90,7 +89,7 @@ void ColorProvider::initTypeColorMap() { this->typeColorMap_.insert( {ColorType::Subscription, - std::make_shared(backgrounds.subscription)}); + std::make_shared(HighlightPhrase::FALLBACK_SUB_COLOR)}); } customColor = getSettings()->whisperHighlightColor; @@ -103,22 +102,41 @@ void ColorProvider::initTypeColorMap() { this->typeColorMap_.insert( {ColorType::Whisper, - std::make_shared(backgrounds.highlighted)}); + std::make_shared( + HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR)}); + } + + customColor = getSettings()->redeemedHighlightColor; + if (QColor(customColor).isValid()) + { + this->typeColorMap_.insert({ColorType::RedeemedHighlight, + std::make_shared(customColor)}); + } + else + { + this->typeColorMap_.insert( + {ColorType::RedeemedHighlight, + std::make_shared( + HighlightPhrase::FALLBACK_REDEEMED_HIGHLIGHT_COLOR)}); } } void ColorProvider::initDefaultColors() { // Init default colors - this->defaultColors_.emplace_back(31, 141, 43, 127); // Green-ish - this->defaultColors_.emplace_back(28, 126, 141, 127); // Blue-ish - this->defaultColors_.emplace_back(136, 141, 49, 127); // Golden-ish - this->defaultColors_.emplace_back(143, 48, 24, 127); // Red-ish - this->defaultColors_.emplace_back(28, 141, 117, 127); // Cyan-ish + this->defaultColors_.emplace_back(75, 127, 107, 100); // Teal + this->defaultColors_.emplace_back(105, 127, 63, 100); // Olive + this->defaultColors_.emplace_back(63, 83, 127, 100); // Blue + this->defaultColors_.emplace_back(72, 127, 63, 100); // Green - auto backgrounds = getApp()->themes->messages.backgrounds; - this->defaultColors_.push_back(backgrounds.highlighted); - this->defaultColors_.push_back(backgrounds.subscription); + this->defaultColors_.emplace_back(31, 141, 43, 115); // Green + this->defaultColors_.emplace_back(28, 126, 141, 90); // Blue + this->defaultColors_.emplace_back(136, 141, 49, 90); // Golden + this->defaultColors_.emplace_back(143, 48, 24, 127); // Red + this->defaultColors_.emplace_back(28, 141, 117, 90); // Cyan + + this->defaultColors_.push_back(HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR); + this->defaultColors_.push_back(HighlightPhrase::FALLBACK_SUB_COLOR); } } // namespace chatterino diff --git a/src/providers/colors/ColorProvider.hpp b/src/providers/colors/ColorProvider.hpp index e038b17f2..1595c53d6 100644 --- a/src/providers/colors/ColorProvider.hpp +++ b/src/providers/colors/ColorProvider.hpp @@ -2,7 +2,12 @@ namespace chatterino { -enum class ColorType { SelfHighlight, Subscription, Whisper }; +enum class ColorType { + SelfHighlight, + Subscription, + Whisper, + RedeemedHighlight +}; class ColorProvider { diff --git a/src/providers/ffz/FfzEmotes.cpp b/src/providers/ffz/FfzEmotes.cpp index 29dbac52d..dc1b1ab3c 100644 --- a/src/providers/ffz/FfzEmotes.cpp +++ b/src/providers/ffz/FfzEmotes.cpp @@ -6,6 +6,8 @@ #include "common/Outcome.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/TwitchChannel.hpp" namespace chatterino { namespace { @@ -102,7 +104,7 @@ namespace { modBadge = std::make_shared(Emote{ {""}, modBadgeImageSet, - Tooltip{"Twitch Channel Moderator"}, + Tooltip{"Moderator"}, modBadge1x, }); } @@ -126,11 +128,17 @@ namespace { auto id = EmoteId{QString::number(jsonEmote.value("id").toInt())}; auto name = EmoteName{jsonEmote.value("name").toString()}; + auto author = EmoteAuthor{jsonEmote.value("owner") + .toObject() + .value("display_name") + .toString()}; auto urls = jsonEmote.value("urls").toObject(); Emote emote; fillInEmoteData(urls, name, - name.string + "
Channel FFZ Emote", emote); + name.string + "
Channel FFZ Emote" + + "
By: " + author.string, + emote); emote.homePage = Url{QString("https://www.frankerfacez.com/emoticon/%1-%2") .arg(id.string) @@ -182,8 +190,10 @@ void FfzEmotes::loadEmotes() } void FfzEmotes::loadChannel( - const QString &channelId, std::function emoteCallback, - std::function)> modBadgeCallback) + std::weak_ptr channel, const QString &channelId, + std::function emoteCallback, + std::function)> modBadgeCallback, + bool manualRefresh) { qDebug() << "[FFZEmotes] Reload FFZ Channel Emotes for channel" << channelId; @@ -192,32 +202,47 @@ void FfzEmotes::loadChannel( .timeout(20000) .onSuccess([emoteCallback = std::move(emoteCallback), - modBadgeCallback = - std::move(modBadgeCallback)](auto result) -> Outcome { + modBadgeCallback = std::move(modBadgeCallback), channel, + manualRefresh](auto result) -> Outcome { auto json = result.parseJson(); auto emoteMap = parseChannelEmotes(json); auto modBadge = parseModBadge(json); emoteCallback(std::move(emoteMap)); modBadgeCallback(std::move(modBadge)); + if (auto shared = channel.lock(); manualRefresh) + shared->addMessage( + makeSystemMessage("FrankerFaceZ channel emotes reloaded.")); return Success; }) - .onError([channelId](NetworkResult result) { + .onError([channelId, channel, manualRefresh](NetworkResult result) { + auto shared = channel.lock(); + if (!shared) + return; if (result.status() == 203) { // User does not have any FFZ emotes + if (manualRefresh) + shared->addMessage(makeSystemMessage( + "This channel has no FrankerFaceZ channel emotes.")); } else if (result.status() == NetworkResult::timedoutStatus) { // TODO: Auto retry in case of a timeout, with a delay qDebug() << "Fetching FFZ emotes for channel" << channelId << "failed due to timeout"; + shared->addMessage( + makeSystemMessage("Failed to fetch FrankerFaceZ channel " + "emotes. (timed out)")); } else { qDebug() << "Error fetching FFZ emotes for channel" << channelId << ", error" << result.status(); + shared->addMessage( + makeSystemMessage("Failed to fetch FrankerFaceZ channel " + "emotes. (unknown error)")); } }) .execute(); diff --git a/src/providers/ffz/FfzEmotes.hpp b/src/providers/ffz/FfzEmotes.hpp index 297ac4257..817341c83 100644 --- a/src/providers/ffz/FfzEmotes.hpp +++ b/src/providers/ffz/FfzEmotes.hpp @@ -4,6 +4,7 @@ #include "boost/optional.hpp" #include "common/Aliases.hpp" #include "common/Atomic.hpp" +#include "providers/twitch/TwitchChannel.hpp" namespace chatterino { @@ -23,9 +24,10 @@ public: boost::optional emote(const EmoteName &name) const; void loadEmotes(); static void loadChannel( - const QString &channelId, + std::weak_ptr channel, const QString &channelId, std::function emoteCallback, - std::function)> modBadgeCallback); + std::function)> modBadgeCallback, + bool manualRefresh); private: Atomic> global_; diff --git a/src/providers/irc/AbstractIrcServer.cpp b/src/providers/irc/AbstractIrcServer.cpp index 0a3019e5c..2618465a5 100644 --- a/src/providers/irc/AbstractIrcServer.cpp +++ b/src/providers/irc/AbstractIrcServer.cpp @@ -71,8 +71,30 @@ AbstractIrcServer::AbstractIrcServer() }); } +void AbstractIrcServer::initializeIrc() +{ + assert(!this->initialized_); + + if (this->hasSeparateWriteConnection()) + { + this->initializeConnectionSignals(this->writeConnection_.get(), + ConnectionType::Write); + this->initializeConnectionSignals(this->readConnection_.get(), + ConnectionType::Read); + } + else + { + this->initializeConnectionSignals(this->readConnection_.get(), + ConnectionType::Both); + } + + this->initialized_ = true; +} + void AbstractIrcServer::connect() { + assert(this->initialized_); + this->disconnect(); if (this->hasSeparateWriteConnection()) diff --git a/src/providers/irc/AbstractIrcServer.hpp b/src/providers/irc/AbstractIrcServer.hpp index 20d782490..1c865ef52 100644 --- a/src/providers/irc/AbstractIrcServer.hpp +++ b/src/providers/irc/AbstractIrcServer.hpp @@ -21,6 +21,10 @@ public: virtual ~AbstractIrcServer() = default; + // initializeIrc must be called from the derived class + // this allows us to initialize the abstract irc server based on the derived class's parameters + void initializeIrc(); + // connection void connect(); void disconnect(); @@ -45,8 +49,15 @@ public: protected: AbstractIrcServer(); + // initializeConnectionSignals is called on a connection once in its lifetime. + // it can be used to connect signals to your class + virtual void initializeConnectionSignals(IrcConnection *connection, + ConnectionType type){}; + + // initializeConnection is called every time before we try to connect to the irc server virtual void initializeConnection(IrcConnection *connection, ConnectionType type) = 0; + virtual std::shared_ptr createChannel( const QString &channelName) = 0; @@ -83,6 +94,8 @@ private: // bool autoReconnect_ = false; pajlada::Signals::SignalHolder connections_; + + bool initialized_{false}; }; } // namespace chatterino diff --git a/src/providers/irc/Irc2.cpp b/src/providers/irc/Irc2.cpp index 0a8f2ed60..2f685be63 100644 --- a/src/providers/irc/Irc2.cpp +++ b/src/providers/irc/Irc2.cpp @@ -143,7 +143,7 @@ Irc::Irc() QAbstractTableModel *Irc::newConnectionModel(QObject *parent) { auto model = new Model(parent); - model->init(&this->connections); + model->initialize(&this->connections); return model; } @@ -252,7 +252,7 @@ void Irc::load() { ids.insert(data.id); - this->connections.appendItem(data); + this->connections.append(data); } } } diff --git a/src/providers/irc/Irc2.hpp b/src/providers/irc/Irc2.hpp index de484924a..0cdc60c77 100644 --- a/src/providers/irc/Irc2.hpp +++ b/src/providers/irc/Irc2.hpp @@ -41,7 +41,7 @@ public: static inline void *const noEraseCredentialCaller = reinterpret_cast(1); - UnsortedSignalVector connections; + SignalVector connections; QAbstractTableModel *newConnectionModel(QObject *parent); ChannelPtr getOrAddChannel(int serverId, QString name); diff --git a/src/providers/irc/IrcMessageBuilder.cpp b/src/providers/irc/IrcMessageBuilder.cpp new file mode 100644 index 000000000..c7529a5e5 --- /dev/null +++ b/src/providers/irc/IrcMessageBuilder.cpp @@ -0,0 +1,93 @@ +#include "providers/irc/IrcMessageBuilder.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/ignores/IgnoreController.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" +#include "messages/Message.hpp" +#include "providers/chatterino/ChatterinoBadges.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Resources.hpp" +#include "singletons/Settings.hpp" +#include "singletons/Theme.hpp" +#include "singletons/WindowManager.hpp" +#include "util/IrcHelpers.hpp" +#include "widgets/Window.hpp" + +namespace chatterino { + +IrcMessageBuilder::IrcMessageBuilder( + Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, + const MessageParseArgs &_args) + : SharedMessageBuilder(_channel, _ircMessage, _args) +{ + this->usernameColor_ = getApp()->themes->messages.textColors.system; +} + +IrcMessageBuilder::IrcMessageBuilder(Channel *_channel, + const Communi::IrcMessage *_ircMessage, + const MessageParseArgs &_args, + QString content, bool isAction) + : SharedMessageBuilder(_channel, _ircMessage, _args, content, isAction) +{ + assert(false); + this->usernameColor_ = getApp()->themes->messages.textColors.system; +} + +MessagePtr IrcMessageBuilder::build() +{ + // PARSE + this->parse(); + + // PUSH ELEMENTS + this->appendChannelName(); + + this->emplace(); + + this->appendUsername(); + + // words + this->addWords(this->originalMessage_.split(' ')); + + this->message().messageText = this->originalMessage_; + this->message().searchText = this->message().localizedName + " " + + this->userName + ": " + this->originalMessage_; + + // highlights + this->parseHighlights(); + + // highlighting incoming whispers if requested per setting + if (this->args.isReceivedWhisper && getSettings()->highlightInlineWhispers) + { + this->message().flags.set(MessageFlag::HighlightedWhisper, true); + } + + return this->release(); +} + +void IrcMessageBuilder::addWords(const QStringList &words) +{ + this->emplace(words.join(' '), MessageElementFlag::Text); +} + +void IrcMessageBuilder::appendUsername() +{ + auto app = getApp(); + + QString username = this->userName; + this->message().loginName = username; + + // The full string that will be rendered in the chat widget + QString usernameText = username; + + if (!this->action_) + { + usernameText += ":"; + } + + this->emplace(usernameText, MessageElementFlag::Username, + this->usernameColor_, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, this->message().displayName}); +} + +} // namespace chatterino diff --git a/src/providers/irc/IrcMessageBuilder.hpp b/src/providers/irc/IrcMessageBuilder.hpp new file mode 100644 index 000000000..d6a83da8a --- /dev/null +++ b/src/providers/irc/IrcMessageBuilder.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "common/Aliases.hpp" +#include "common/Outcome.hpp" +#include "messages/SharedMessageBuilder.hpp" +#include "providers/twitch/TwitchBadge.hpp" + +#include +#include +#include + +namespace chatterino { + +struct Emote; +using EmotePtr = std::shared_ptr; + +class Channel; +class TwitchChannel; + +class IrcMessageBuilder : public SharedMessageBuilder +{ +public: + IrcMessageBuilder() = delete; + + explicit IrcMessageBuilder(Channel *_channel, + const Communi::IrcPrivateMessage *_ircMessage, + const MessageParseArgs &_args); + explicit IrcMessageBuilder(Channel *_channel, + const Communi::IrcMessage *_ircMessage, + const MessageParseArgs &_args, QString content, + bool isAction); + + MessagePtr build() override; + +private: + void appendUsername(); + + void addWords(const QStringList &words); +}; + +} // namespace chatterino diff --git a/src/providers/irc/IrcServer.cpp b/src/providers/irc/IrcServer.cpp index dd7505ff9..006e55acb 100644 --- a/src/providers/irc/IrcServer.cpp +++ b/src/providers/irc/IrcServer.cpp @@ -4,9 +4,9 @@ #include #include "messages/Message.hpp" -#include "messages/MessageBuilder.hpp" #include "providers/irc/Irc2.hpp" #include "providers/irc/IrcChannel2.hpp" +#include "providers/irc/IrcMessageBuilder.hpp" #include "singletons/Settings.hpp" #include "util/QObjectRef.hpp" @@ -15,6 +15,8 @@ namespace chatterino { IrcServer::IrcServer(const IrcServerData &data) : data_(new IrcServerData(data)) { + this->initializeIrc(); + this->connect(); } @@ -51,6 +53,58 @@ const QString &IrcServer::nick() return this->data_->nick.isEmpty() ? this->data_->user : this->data_->nick; } +void IrcServer::initializeConnectionSignals(IrcConnection *connection, + ConnectionType type) +{ + assert(type == Both); + + QObject::connect( + connection, &Communi::IrcConnection::socketError, this, + [this](QAbstractSocket::SocketError error) { + static int index = + QAbstractSocket::staticMetaObject.indexOfEnumerator( + "SocketError"); + + std::lock_guard lock(this->channelMutex); + + for (auto &&weak : this->channels) + { + if (auto shared = weak.lock()) + { + shared->addMessage(makeSystemMessage( + QStringLiteral("Socket error: ") + + QAbstractSocket::staticMetaObject.enumerator(index) + .valueToKey(error))); + } + } + }); + + QObject::connect(connection, &Communi::IrcConnection::nickNameRequired, + this, [](const QString &reserved, QString *result) { + *result = reserved + (std::rand() % 100); + }); + + QObject::connect(connection, &Communi::IrcConnection::noticeMessageReceived, + this, [this](Communi::IrcNoticeMessage *message) { + // XD PAJLADA + MessageBuilder builder; + + builder.emplace(); + builder.emplace( + message->nick(), MessageElementFlag::Username); + builder.emplace( + "-> you:", MessageElementFlag::Username); + builder.emplace(message->content(), + MessageElementFlag::Text); + + auto msg = builder.release(); + + for (auto &&weak : this->channels) + if (auto shared = weak.lock()) + shared->addMessage(msg); + }); +} + void IrcServer::initializeConnection(IrcConnection *connection, ConnectionType type) { @@ -90,47 +144,6 @@ void IrcServer::initializeConnection(IrcConnection *connection, this->open(Both); } } - - QObject::connect( - connection, &Communi::IrcConnection::socketError, this, - [this](QAbstractSocket::SocketError error) { - static int index = - QAbstractSocket::staticMetaObject.indexOfEnumerator( - "SocketError"); - - std::lock_guard lock(this->channelMutex); - - for (auto &&weak : this->channels) - if (auto shared = weak.lock()) - shared->addMessage(makeSystemMessage( - QStringLiteral("Socket error: ") + - QAbstractSocket::staticMetaObject.enumerator(index) - .valueToKey(error))); - }); - - QObject::connect(connection, &Communi::IrcConnection::nickNameRequired, - this, [](const QString &reserved, QString *result) { - *result = reserved + (std::rand() % 100); - }); - - QObject::connect(connection, &Communi::IrcConnection::noticeMessageReceived, - this, [this](Communi::IrcNoticeMessage *message) { - MessageBuilder builder; - - builder.emplace(); - builder.emplace( - message->nick(), MessageElementFlag::Username); - builder.emplace( - "-> you:", MessageElementFlag::Username); - builder.emplace(message->content(), - MessageElementFlag::Text); - - auto msg = builder.release(); - - for (auto &&weak : this->channels) - if (auto shared = weak.lock()) - shared->addMessage(msg); - }); } std::shared_ptr IrcServer::createChannel(const QString &channelName) @@ -164,15 +177,18 @@ void IrcServer::privateMessageReceived(Communi::IrcPrivateMessage *message) if (auto channel = this->getChannelOrEmpty(target); !channel->isEmpty()) { - MessageBuilder builder; + MessageParseArgs args; + IrcMessageBuilder builder(channel.get(), message, args); - builder.emplace(); - builder.emplace(message->nick() + ":", - MessageElementFlag::Username); - builder.emplace(message->content(), - MessageElementFlag::Text); - - channel->addMessage(builder.release()); + if (!builder.isIgnored()) + { + builder.triggerHighlights(); + channel->addMessage(builder.build()); + } + else + { + qDebug() << "message ignored :rage:"; + } } } @@ -193,8 +209,7 @@ void IrcServer::readConnectionMessageReceived(Communi::IrcMessage *message) { if (message->nick() == this->data_->nick) { - shared->addMessage( - MessageBuilder(systemMessage, "joined").release()); + shared->addMessage(makeSystemMessage("joined")); } else { @@ -218,8 +233,7 @@ void IrcServer::readConnectionMessageReceived(Communi::IrcMessage *message) { if (message->nick() == this->data_->nick) { - shared->addMessage( - MessageBuilder(systemMessage, "parted").release()); + shared->addMessage(makeSystemMessage("parted")); } else { diff --git a/src/providers/irc/IrcServer.hpp b/src/providers/irc/IrcServer.hpp index 0b5a0aad5..6c1251fa8 100644 --- a/src/providers/irc/IrcServer.hpp +++ b/src/providers/irc/IrcServer.hpp @@ -21,6 +21,8 @@ public: // AbstractIrcServer interface protected: + void initializeConnectionSignals(IrcConnection *connection, + ConnectionType type) override; void initializeConnection(IrcConnection *connection, ConnectionType type) override; std::shared_ptr createChannel(const QString &channelName) override; diff --git a/src/providers/twitch/ChannelPointReward.cpp b/src/providers/twitch/ChannelPointReward.cpp new file mode 100644 index 000000000..95d8d9890 --- /dev/null +++ b/src/providers/twitch/ChannelPointReward.cpp @@ -0,0 +1,127 @@ +#include "ChannelPointReward.hpp" +#include "util/RapidjsonHelpers.hpp" + +namespace chatterino { + +QString parseRewardImage(const rapidjson::Value &obj, const char *key, + bool &result) +{ + QString url; + if (!(result = rj::getSafe(obj, key, url))) + { + qDebug() << "No url value found for key in reward image object:" << key; + return ""; + } + + return url; +} + +ChannelPointReward::ChannelPointReward(rapidjson::Value &redemption) +{ + rapidjson::Value user; + if (!(this->hasParsedSuccessfully = + rj::getSafeObject(redemption, "user", user))) + { + qDebug() << "No user info found for redemption"; + return; + } + + rapidjson::Value reward; + if (!(this->hasParsedSuccessfully = + rj::getSafeObject(redemption, "reward", reward))) + { + qDebug() << "No reward info found for redemption"; + return; + } + + if (!(this->hasParsedSuccessfully = rj::getSafe(reward, "id", this->id))) + { + qDebug() << "No id found for reward"; + return; + } + + if (!(this->hasParsedSuccessfully = + rj::getSafe(reward, "channel_id", this->channelId))) + { + qDebug() << "No channel_id found for reward"; + return; + } + + if (!(this->hasParsedSuccessfully = + rj::getSafe(reward, "title", this->title))) + { + qDebug() << "No title found for reward"; + return; + } + + if (!(this->hasParsedSuccessfully = + rj::getSafe(reward, "cost", this->cost))) + { + qDebug() << "No cost found for reward"; + return; + } + + if (!(this->hasParsedSuccessfully = rj::getSafe( + reward, "is_user_input_required", this->isUserInputRequired))) + { + qDebug() << "No information if user input is required found for reward"; + return; + } + + // We don't need to store user information for rewards with user input + // because we will get the user info from a corresponding IRC message + if (!this->isUserInputRequired) + { + this->parseUser(user); + } + + rapidjson::Value obj; + if (rj::getSafeObject(reward, "image", obj) && !obj.IsNull() && + obj.IsObject()) + { + this->image = ImageSet{ + Image::fromUrl( + {parseRewardImage(obj, "url_1x", this->hasParsedSuccessfully)}, + 1), + Image::fromUrl( + {parseRewardImage(obj, "url_2x", this->hasParsedSuccessfully)}, + 0.5), + Image::fromUrl( + {parseRewardImage(obj, "url_4x", this->hasParsedSuccessfully)}, + 0.25), + }; + } + else + { + static const ImageSet defaultImage{ + Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("1.png")}, 1), + Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("2.png")}, 0.5), + Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("4.png")}, 0.25)}; + this->image = defaultImage; + } +} + +void ChannelPointReward::parseUser(rapidjson::Value &user) +{ + if (!(this->hasParsedSuccessfully = rj::getSafe(user, "id", this->user.id))) + { + qDebug() << "No id found for user in reward"; + return; + } + + if (!(this->hasParsedSuccessfully = + rj::getSafe(user, "login", this->user.login))) + { + qDebug() << "No login name found for user in reward"; + return; + } + + if (!(this->hasParsedSuccessfully = + rj::getSafe(user, "display_name", this->user.displayName))) + { + qDebug() << "No display name found for user in reward"; + return; + } +} + +} // namespace chatterino diff --git a/src/providers/twitch/ChannelPointReward.hpp b/src/providers/twitch/ChannelPointReward.hpp new file mode 100644 index 000000000..fcd9ccd6f --- /dev/null +++ b/src/providers/twitch/ChannelPointReward.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "common/Aliases.hpp" +#include "messages/Image.hpp" +#include "messages/ImageSet.hpp" + +#define TWITCH_CHANNEL_POINT_REWARD_URL(x) \ + QString("https://static-cdn.jtvnw.net/custom-reward-images/default-%1") \ + .arg(x) + +namespace chatterino { +struct ChannelPointReward { + ChannelPointReward(rapidjson::Value &reward); + ChannelPointReward() = delete; + QString id; + QString channelId; + QString title; + int cost; + ImageSet image; + bool hasParsedSuccessfully = false; + bool isUserInputRequired = false; + + struct { + QString id; + QString login; + QString displayName; + } user; + +private: + void parseUser(rapidjson::Value &user); +}; + +} // namespace chatterino diff --git a/src/providers/twitch/ChatroomChannel.cpp b/src/providers/twitch/ChatroomChannel.cpp deleted file mode 100644 index 5f393e427..000000000 --- a/src/providers/twitch/ChatroomChannel.cpp +++ /dev/null @@ -1,67 +0,0 @@ -#include "ChatroomChannel.hpp" - -#include -#include "TwitchApi.hpp" -#include "common/Common.hpp" -#include "messages/Emote.hpp" -#include "providers/bttv/BttvEmotes.hpp" -#include "providers/bttv/LoadBttvChannelEmote.hpp" -#include "singletons/Emotes.hpp" - -namespace chatterino { - -ChatroomChannel::ChatroomChannel(const QString &channelName, - TwitchBadges &globalTwitchBadges, - BttvEmotes &globalBttv, FfzEmotes &globalFfz) - : TwitchChannel(channelName, globalTwitchBadges, globalBttv, globalFfz) -{ - auto listRef = channelName.splitRef(":"); - if (listRef.size() > 2) - { - this->chatroomOwnerId = listRef[1].toString(); - } -} - -void ChatroomChannel::refreshBTTVChannelEmotes() -{ - if (this->chatroomOwnerId.isEmpty()) - { - return; - } - TwitchApi::findUserName( - this->chatroomOwnerId, - [this, weak = weakOf(this)](QString username) { - BttvEmotes::loadChannel(username, [this, weak](auto &&emoteMap) { - if (auto shared = weak.lock()) - this->bttvEmotes_.set( - std::make_shared(std::move(emoteMap))); - }); - if (auto shared = weak.lock()) - { - this->chatroomOwnerName = username; - } - }); -} -void ChatroomChannel::refreshFFZChannelEmotes() -{ - if (this->chatroomOwnerId.isEmpty()) - { - return; - } - FfzEmotes::loadChannel( - this->chatroomOwnerId, - [this](auto &&emoteMap) { - this->ffzEmotes_.set( - std::make_shared(std::move(emoteMap))); - }, - [this](auto &&modBadge) { - this->ffzCustomModBadge_.set(std::move(modBadge)); - }); -} - -const QString &ChatroomChannel::getDisplayName() const -{ - return this->chatroomOwnerName; -} - -} // namespace chatterino diff --git a/src/providers/twitch/ChatroomChannel.hpp b/src/providers/twitch/ChatroomChannel.hpp deleted file mode 100644 index 2ed249141..000000000 --- a/src/providers/twitch/ChatroomChannel.hpp +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include "TwitchChannel.hpp" - -#include -#include - -namespace chatterino { - -class ChatroomChannel : public TwitchChannel -{ -protected: - explicit ChatroomChannel(const QString &channelName, - TwitchBadges &globalTwitchBadges, - BttvEmotes &globalBttv, FfzEmotes &globalFfz); - virtual void refreshBTTVChannelEmotes() override; - virtual void refreshFFZChannelEmotes() override; - virtual const QString &getDisplayName() const override; - - QString chatroomOwnerId; - QString chatroomOwnerName; - - friend class TwitchIrcServer; - friend class TwitchMessageBuilder; - friend class IrcMessageHandler; -}; - -} // namespace chatterino diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 7a456ecaf..5853d02fc 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -1,8 +1,7 @@ -#include "IrcMessageHandler.hpp" +#include "IrcMessageHandler.hpp" #include "Application.hpp" #include "controllers/accounts/AccountController.hpp" -#include "controllers/highlights/HighlightController.hpp" #include "messages/LimitedQueue.hpp" #include "messages/Message.hpp" #include "providers/twitch/TwitchAccountManager.hpp" @@ -54,7 +53,10 @@ static float relativeSimilarity(const QString &str1, const QString &str2) } } - return z == 0 ? 0.f : float(z) / std::max(str1.size(), str2.size()); + // ensure that no div by 0 + return z == 0 ? 0.f + : float(z) / + std::max(1, std::max(str1.size(), str2.size())); }; float IrcMessageHandler::similarity( @@ -213,6 +215,32 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, args.isStaffOrBroadcaster = true; } + auto channel = dynamic_cast(chan.get()); + + const auto &tags = _message->tags(); + if (const auto &it = tags.find("custom-reward-id"); it != tags.end()) + { + const auto rewardId = it.value().toString(); + if (!channel->isChannelPointRewardKnown(rewardId)) + { + // Need to wait for pubsub reward notification + auto clone = _message->clone(); + channel->channelPointRewardAdded.connect( + [=, &server](ChannelPointReward reward) { + if (reward.id == rewardId) + { + this->addMessage(clone, target, content, server, isSub, + isAction); + clone->deleteLater(); + return true; + } + return false; + }); + return; + } + args.channelPointRewardId = rewardId; + } + TwitchMessageBuilder builder(chan.get(), _message, args, content, isAction); if (isSub || !builder.isIgnored()) @@ -222,7 +250,6 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, builder->flags.set(MessageFlag::Subscription); builder->flags.unset(MessageFlag::Highlighted); } - auto msg = builder.build(); IrcMessageHandler::setSimilarityFlags(msg, chan); @@ -241,7 +268,6 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, if (highlighted) { server.mentionsChannel->addMessage(msg); - getApp()->highlights->addHighlight(msg); } } @@ -398,9 +424,9 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message) if (chan->isEmpty()) { - qDebug() - << "[IrcMessageHandler:handleClearMessageMessage] Twitch channel" - << chanName << "not found"; + qDebug() << "[IrcMessageHandler:handleClearMessageMessage] Twitch " + "channel" + << chanName << "not found"; return; } @@ -453,7 +479,6 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *message) { auto app = getApp(); - qDebug() << "Received whisper!"; MessageParseArgs args; args.isReceivedWhisper = true; @@ -634,7 +659,25 @@ std::vector IrcMessageHandler::parseNoticeMessage( { std::vector builtMessages; - builtMessages.emplace_back(makeSystemMessage(message->content())); + if (message->tags().contains("historical")) + { + bool customReceived = false; + qint64 ts = message->tags() + .value("rm-received-ts") + .toLongLong(&customReceived); + if (!customReceived) + { + ts = message->tags().value("tmi-sent-ts").toLongLong(); + } + + QDateTime dateTime = QDateTime::fromMSecsSinceEpoch(ts); + builtMessages.emplace_back( + makeSystemMessage(message->content(), dateTime.time())); + } + else + { + builtMessages.emplace_back(makeSystemMessage(message->content())); + } return builtMessages; } diff --git a/src/providers/twitch/PartialTwitchUser.cpp b/src/providers/twitch/PartialTwitchUser.cpp deleted file mode 100644 index 5407fbada..000000000 --- a/src/providers/twitch/PartialTwitchUser.cpp +++ /dev/null @@ -1,85 +0,0 @@ -#include "providers/twitch/PartialTwitchUser.hpp" - -#include "common/Common.hpp" -#include "common/NetworkRequest.hpp" -#include "providers/twitch/TwitchCommon.hpp" - -#include -#include - -namespace chatterino { - -PartialTwitchUser PartialTwitchUser::byName(const QString &username) -{ - PartialTwitchUser user; - user.username_ = username; - - return user; -} - -PartialTwitchUser PartialTwitchUser::byId(const QString &id) -{ - PartialTwitchUser user; - user.id_ = id; - - return user; -} - -void PartialTwitchUser::getId(std::function successCallback, - const QObject *caller) -{ - getId( - successCallback, [] {}, caller); -} -void PartialTwitchUser::getId(std::function successCallback, - std::function failureCallback, - const QObject *caller) -{ - assert(!this->username_.isEmpty()); - - NetworkRequest("https://api.twitch.tv/kraken/users?login=" + - this->username_) - .caller(caller) - .authorizeTwitchV5(getDefaultClientID()) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { - auto root = result.parseJson(); - if (!root.value("users").isArray()) - { - qDebug() - << "API Error while getting user id, users is not an array"; - failureCallback(); - return Failure; - } - - auto users = root.value("users").toArray(); - if (users.size() != 1) - { - qDebug() << "API Error while getting user id, users array size " - "is not 1"; - failureCallback(); - return Failure; - } - if (!users[0].isObject()) - { - qDebug() << "API Error while getting user id, first user is " - "not an object"; - failureCallback(); - return Failure; - } - auto firstUser = users[0].toObject(); - auto id = firstUser.value("_id"); - if (!id.isString()) - { - qDebug() << "API Error: while getting user id, first user " - "object `_id` key is not a string"; - failureCallback(); - return Failure; - } - successCallback(id.toString()); - - return Success; - }) - .execute(); -} - -} // namespace chatterino diff --git a/src/providers/twitch/PartialTwitchUser.hpp b/src/providers/twitch/PartialTwitchUser.hpp deleted file mode 100644 index 6537bb279..000000000 --- a/src/providers/twitch/PartialTwitchUser.hpp +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace chatterino { - -// Experimental class to test a method of calling APIs on twitch users -class PartialTwitchUser -{ - PartialTwitchUser() = default; - - QString username_; - QString id_; - -public: - static PartialTwitchUser byName(const QString &username); - static PartialTwitchUser byId(const QString &id); - - void getId(std::function successCallback, - const QObject *caller = nullptr); - - void getId(std::function successCallback, - std::function failureCallback, - const QObject *caller = nullptr); -}; - -} // namespace chatterino diff --git a/src/providers/twitch/PubsubClient.cpp b/src/providers/twitch/PubsubClient.cpp index 43b24f2ef..71a309d5c 100644 --- a/src/providers/twitch/PubsubClient.cpp +++ b/src/providers/twitch/PubsubClient.cpp @@ -347,30 +347,31 @@ PubSub::PubSub() this->moderationActionHandlers["mod"] = [this](const auto &data, const auto &roomID) { ModerationStateAction action(data, roomID); - - getTargetUser(data, action.target); - - try - { - const auto &args = getArgs(data); - - if (args.Size() < 1) - { - return; - } - - if (!rj::getSafe(args[0], action.target.name)) - { - return; - } - } - catch (const std::runtime_error &ex) - { - qDebug() << "Error parsing moderation action:" << ex.what(); - } - action.modded = true; + QString innerType; + if (rj::getSafe(data, "type", innerType) && + innerType == "chat_login_moderation") + { + // Don't display the old message type + return; + } + + if (!getTargetUser(data, action.target)) + { + qDebug() << "Error parsing moderation action mod: Unable to get " + "target_user_id"; + return; + } + + // Load target name from message.data.target_user_login + if (!getTargetUserName(data, action.target)) + { + qDebug() << "Error parsing moderation action mod: Unable to get " + "target_user_name"; + return; + } + this->signals_.moderation.moderationStateChanged.invoke(action); }; @@ -803,6 +804,26 @@ void PubSub::listenToChannelModerationActions( this->listenToTopic(topic, account); } +void PubSub::listenToChannelPointRewards(const QString &channelID, + std::shared_ptr account) +{ + static const QString topicFormat("community-points-channel-v1.%1"); + assert(!channelID.isEmpty()); + assert(account != nullptr); + QString userID = account->getUserId(); + + auto topic = topicFormat.arg(channelID); + + if (this->isListeningToTopic(topic)) + { + return; + } + + qDebug() << "Listen to topic" << topic; + + this->listenToTopic(topic, account); +} + void PubSub::listenToTopic(const QString &topic, std::shared_ptr account) { @@ -820,14 +841,12 @@ void PubSub::listen(rapidjson::Document &&msg) this->addClient(); - qDebug() << "Added to the back of the queue"; this->requests.emplace_back( std::make_unique(std::move(msg))); } bool PubSub::tryListen(rapidjson::Document &msg) { - qDebug() << "tryListen with" << this->clients.size() << "clients"; for (const auto &p : this->clients) { const auto &client = p.second; @@ -1094,6 +1113,34 @@ void PubSub::handleMessageResponse(const rapidjson::Value &outerData) // Invoke handler function handlerIt->second(data, topicParts[2]); } + else if (topic.startsWith("community-points-channel-v1.")) + { + std::string pointEventType; + if (!rj::getSafe(msg, "type", pointEventType)) + { + qDebug() << "Bad channel point event data"; + return; + } + + if (pointEventType == "reward-redeemed") + { + if (!rj::getSafeObject(msg, "data", msg)) + { + qDebug() << "No data found for redeemed reward"; + return; + } + if (!rj::getSafeObject(msg, "redemption", msg)) + { + qDebug() << "No redemption info found for redeemed reward"; + return; + } + this->signals_.pointReward.redeemed.invoke(msg); + } + else + { + qDebug() << "Invalid point event type:" << pointEventType.c_str(); + } + } else { qDebug() << "Unknown topic:" << topic; diff --git a/src/providers/twitch/PubsubClient.hpp b/src/providers/twitch/PubsubClient.hpp index 0400683e2..3c76533b3 100644 --- a/src/providers/twitch/PubsubClient.hpp +++ b/src/providers/twitch/PubsubClient.hpp @@ -122,6 +122,10 @@ public: Signal received; Signal sent; } whisper; + + struct { + Signal redeemed; + } pointReward; } signals_; void listenToWhispers(std::shared_ptr account); @@ -131,6 +135,9 @@ public: void listenToChannelModerationActions( const QString &channelID, std::shared_ptr account); + void listenToChannelPointRewards(const QString &channelID, + std::shared_ptr account); + std::vector> requests; private: diff --git a/src/providers/twitch/PubsubHelpers.cpp b/src/providers/twitch/PubsubHelpers.cpp index d2e26ce13..f6f9b38e9 100644 --- a/src/providers/twitch/PubsubHelpers.cpp +++ b/src/providers/twitch/PubsubHelpers.cpp @@ -46,6 +46,11 @@ bool getTargetUser(const rapidjson::Value &data, ActionUser &user) return rj::getSafe(data, "target_user_id", user.id); } +bool getTargetUserName(const rapidjson::Value &data, ActionUser &user) +{ + return rj::getSafe(data, "target_user_login", user.name); +} + rapidjson::Document createListenMessage(const std::vector &topicsVec, std::shared_ptr account) { diff --git a/src/providers/twitch/PubsubHelpers.hpp b/src/providers/twitch/PubsubHelpers.hpp index 3c784697a..d0854af26 100644 --- a/src/providers/twitch/PubsubHelpers.hpp +++ b/src/providers/twitch/PubsubHelpers.hpp @@ -16,6 +16,7 @@ const rapidjson::Value &getMsgID(const rapidjson::Value &data); bool getCreatedByUser(const rapidjson::Value &data, ActionUser &user); bool getTargetUser(const rapidjson::Value &data, ActionUser &user); +bool getTargetUserName(const rapidjson::Value &data, ActionUser &user); rapidjson::Document createListenMessage(const std::vector &topicsVec, std::shared_ptr account); diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 13b4d4092..9ece767ae 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -6,8 +6,8 @@ #include "common/Env.hpp" #include "common/NetworkRequest.hpp" #include "common/Outcome.hpp" -#include "providers/twitch/PartialTwitchUser.hpp" #include "providers/twitch/TwitchCommon.hpp" +#include "providers/twitch/api/Helix.hpp" #include "singletons/Emotes.hpp" #include "util/RapidjsonHelpers.hpp" @@ -151,12 +151,14 @@ void TwitchAccount::ignore( const QString &targetName, std::function onFinished) { - const auto onIdFetched = [this, targetName, - onFinished](QString targetUserId) { - this->ignoreByID(targetUserId, targetName, onFinished); // + const auto onUserFetched = [this, targetName, + onFinished](const auto &user) { + this->ignoreByID(user.id, targetName, onFinished); // }; - PartialTwitchUser::byName(targetName).getId(onIdFetched); + const auto onUserFetchFailed = [] {}; + + getHelix()->getUserByName(targetName, onUserFetched, onUserFetchFailed); } void TwitchAccount::ignoreByID( @@ -226,12 +228,14 @@ void TwitchAccount::unignore( const QString &targetName, std::function onFinished) { - const auto onIdFetched = [this, targetName, - onFinished](QString targetUserId) { - this->unignoreByID(targetUserId, targetName, onFinished); // + const auto onUserFetched = [this, targetName, + onFinished](const auto &user) { + this->unignoreByID(user.id, targetName, onFinished); // }; - PartialTwitchUser::byName(targetName).getId(onIdFetched); + const auto onUserFetchFailed = [] {}; + + getHelix()->getUserByName(targetName, onUserFetched, onUserFetchFailed); } void TwitchAccount::unignoreByID( @@ -270,28 +274,18 @@ void TwitchAccount::unignoreByID( void TwitchAccount::checkFollow(const QString targetUserID, std::function onFinished) { - QString url("https://api.twitch.tv/kraken/users/" + this->getUserId() + - "/follows/channels/" + targetUserID); + const auto onResponse = [onFinished](bool following, const auto &record) { + if (!following) + { + onFinished(FollowResult_NotFollowing); + return; + } - NetworkRequest(url) + onFinished(FollowResult_Following); + }; - .authorizeTwitchV5(this->getOAuthClient(), this->getOAuthToken()) - .onError([=](NetworkResult result) { - if (result.status() == 203) - { - onFinished(FollowResult_NotFollowing); - } - else - { - onFinished(FollowResult_Failed); - } - }) - .onSuccess([=](auto result) -> Outcome { - auto document = result.parseRapidJson(); - onFinished(FollowResult_Following); - return Success; - }) - .execute(); + getHelix()->getUserFollow(this->getUserId(), targetUserID, onResponse, + [] {}); } void TwitchAccount::followUser(const QString userID, @@ -392,7 +386,6 @@ void TwitchAccount::autoModAllow(const QString msgID) QString url("https://api.twitch.tv/kraken/chat/twitchbot/approve"); auto qba = (QString("{\"msg_id\":\"") + msgID + "\"}").toUtf8(); - qDebug() << qba; NetworkRequest(url, NetworkRequestType::Post) .header("Content-Type", "application/json") @@ -412,7 +405,6 @@ void TwitchAccount::autoModDeny(const QString msgID) QString url("https://api.twitch.tv/kraken/chat/twitchbot/deny"); auto qba = (QString("{\"msg_id\":\"") + msgID + "\"}").toUtf8(); - qDebug() << qba; NetworkRequest(url, NetworkRequestType::Post) .header("Content-Type", "application/json") diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index b772f2911..3813e9822 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -3,11 +3,14 @@ #include "common/Common.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchCommon.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/api/Kraken.hpp" namespace chatterino { TwitchAccountManager::TwitchAccountManager() - : anonymousUser_(new TwitchAccount(ANONYMOUS_USERNAME, "", "", "")) + : accounts(SharedPtrElementLess{}) + , anonymousUser_(new TwitchAccount(ANONYMOUS_USERNAME, "", "", "")) { this->currentUserChanged.connect([this] { auto currentUser = this->getCurrent(); @@ -140,6 +143,8 @@ void TwitchAccountManager::load() if (user) { qDebug() << "Twitch user updated to" << newUsername; + getHelix()->update(user->getOAuthClient(), user->getOAuthToken()); + getKraken()->update(user->getOAuthClient(), user->getOAuthToken()); this->currentUser_ = user; } else @@ -221,7 +226,7 @@ TwitchAccountManager::AddUserResponse TwitchAccountManager::addUser( // std::lock_guard lock(this->mutex); - this->accounts.insertItem(newUser); + this->accounts.insert(newUser); return AddUserResponse::UserAdded; } diff --git a/src/providers/twitch/TwitchAccountManager.hpp b/src/providers/twitch/TwitchAccountManager.hpp index c9ce0215e..5956fdf00 100644 --- a/src/providers/twitch/TwitchAccountManager.hpp +++ b/src/providers/twitch/TwitchAccountManager.hpp @@ -51,9 +51,7 @@ public: pajlada::Signals::NoArgSignal currentUserChanged; pajlada::Signals::NoArgSignal userListUpdated; - SortedSignalVector, - SharedPtrElementLess> - accounts; + SignalVector> accounts; private: enum class AddUserResponse { diff --git a/src/providers/twitch/TwitchApi.cpp b/src/providers/twitch/TwitchApi.cpp deleted file mode 100644 index 6c3b6b6ee..000000000 --- a/src/providers/twitch/TwitchApi.cpp +++ /dev/null @@ -1,85 +0,0 @@ -#include "providers/twitch/TwitchApi.hpp" - -#include "common/Common.hpp" -#include "common/NetworkRequest.hpp" -#include "providers/twitch/TwitchCommon.hpp" - -#include -#include - -namespace chatterino { - -void TwitchApi::findUserId(const QString user, - std::function successCallback) -{ - QString requestUrl("https://api.twitch.tv/kraken/users?login=" + user); - - NetworkRequest(requestUrl) - - .authorizeTwitchV5(getDefaultClientID()) - .timeout(30000) - .onSuccess([successCallback](auto result) mutable -> Outcome { - auto root = result.parseJson(); - if (!root.value("users").isArray()) - { - qDebug() - << "API Error while getting user id, users is not an array"; - successCallback(""); - return Failure; - } - auto users = root.value("users").toArray(); - if (users.size() != 1) - { - qDebug() << "API Error while getting user id, users array size " - "is not 1"; - successCallback(""); - return Failure; - } - if (!users[0].isObject()) - { - qDebug() << "API Error while getting user id, first user is " - "not an object"; - successCallback(""); - return Failure; - } - auto firstUser = users[0].toObject(); - auto id = firstUser.value("_id"); - if (!id.isString()) - { - qDebug() << "API Error: while getting user id, first user " - "object `_id` key is not a string"; - successCallback(""); - return Failure; - } - successCallback(id.toString()); - return Success; - }) - .execute(); -} - -void TwitchApi::findUserName(const QString userid, - std::function successCallback) -{ - QString requestUrl("https://api.twitch.tv/kraken/users/" + userid); - - NetworkRequest(requestUrl) - - .authorizeTwitchV5(getDefaultClientID()) - .timeout(30000) - .onSuccess([successCallback](auto result) mutable -> Outcome { - auto root = result.parseJson(); - auto name = root.value("name"); - if (!name.isString()) - { - qDebug() << "API Error: while getting user name, `name` is not " - "a string"; - successCallback(""); - return Failure; - } - successCallback(name.toString()); - return Success; - }) - .execute(); -} - -} // namespace chatterino diff --git a/src/providers/twitch/TwitchApi.hpp b/src/providers/twitch/TwitchApi.hpp deleted file mode 100644 index 8b1b85c13..000000000 --- a/src/providers/twitch/TwitchApi.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include -#include - -namespace chatterino { - -class TwitchApi -{ -public: - static void findUserId(const QString user, - std::function callback); - static void findUserName(const QString userid, - std::function callback); - -private: -}; - -} // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index d305f0c43..c559ff29d 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -14,13 +14,17 @@ #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "providers/twitch/TwitchParseCheerEmotes.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/api/Kraken.hpp" #include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" #include "singletons/Toasts.hpp" #include "singletons/WindowManager.hpp" +#include "util/FormatTime.hpp" #include "util/PostToThread.hpp" #include "widgets/Window.hpp" +#include #include #include #include @@ -33,6 +37,47 @@ namespace { constexpr int TITLE_REFRESH_PERIOD = 10; constexpr char MAGIC_MESSAGE_SUFFIX[] = u8" \U000E0000"; + // convertClearchatToNotice takes a Communi::IrcMessage that is a CLEARCHAT command and converts it to a readable NOTICE message + // This has historically been done in the Recent Messages API, but this functionality is being moved to Chatterino instead + auto convertClearchatToNotice(Communi::IrcMessage *message) + { + auto channelName = message->parameter(0); + QString noticeMessage{}; + if (message->tags().contains("target-user-id")) + { + auto target = message->parameter(1); + + if (message->tags().contains("ban-duration")) + { + // User was timed out + noticeMessage = + QString("%1 has been timed out for %2.") + .arg(target) + .arg(formatTime( + message->tag("ban-duration").toString())); + } + else + { + // User was permanently banned + noticeMessage = + QString("%1 has been permanently banned.").arg(target); + } + } + else + { + // Chat was cleared + noticeMessage = "Chat has been cleared by a moderator."; + } + + // rebuild the raw irc message so we can convert it back to an ircmessage again! + // this could probably be done in a smarter way + auto s = QString(":tmi.twitch.tv NOTICE %1 :%2") + .arg(channelName) + .arg(noticeMessage); + + return Communi::IrcMessage::fromData(s.toUtf8(), nullptr); + } + // parseRecentMessages takes a json object and returns a vector of // Communi IrcMessages auto parseRecentMessages(const QJsonObject &jsonRoot, ChannelPtr channel) @@ -46,8 +91,15 @@ namespace { for (const auto jsonMessage : jsonMessages) { auto content = jsonMessage.toString().toUtf8(); - messages.emplace_back( - Communi::IrcMessage::fromData(content, nullptr)); + + auto message = Communi::IrcMessage::fromData(content, nullptr); + + if (message->command() == "CLEARCHAT") + { + message = convertClearchatToNotice(message); + } + + messages.emplace_back(std::move(message)); } return messages; @@ -82,7 +134,8 @@ TwitchChannel::TwitchChannel(const QString &name, , ChannelChatters(*static_cast(this)) , subscriptionUrl_("https://www.twitch.tv/subs/" + name) , channelUrl_("https://twitch.tv/" + name) - , popoutPlayerUrl_("https://player.twitch.tv/?channel=" + name) + , popoutPlayerUrl_("https://player.twitch.tv/?parent=twitch.tv&channel=" + + name) , globalTwitchBadges_(globalTwitchBadges) , globalBttv_(bttv) , globalFfz_(ffz) @@ -115,8 +168,8 @@ TwitchChannel::TwitchChannel(const QString &name, this->refreshLiveStatus(); this->refreshBadges(); this->refreshCheerEmotes(); - this->refreshFFZChannelEmotes(); - this->refreshBTTVChannelEmotes(); + this->refreshFFZChannelEmotes(false); + this->refreshBTTVChannelEmotes(false); }); // timers @@ -152,20 +205,22 @@ bool TwitchChannel::canSendMessage() const return !this->isEmpty(); } -void TwitchChannel::refreshBTTVChannelEmotes() +void TwitchChannel::refreshBTTVChannelEmotes(bool manualRefresh) { BttvEmotes::loadChannel( - this->roomId(), [this, weak = weakOf(this)](auto &&emoteMap) { + weakOf(this), this->roomId(), this->getName(), + [this, weak = weakOf(this)](auto &&emoteMap) { if (auto shared = weak.lock()) this->bttvEmotes_.set( std::make_shared(std::move(emoteMap))); - }); + }, + manualRefresh); } -void TwitchChannel::refreshFFZChannelEmotes() +void TwitchChannel::refreshFFZChannelEmotes(bool manualRefresh) { FfzEmotes::loadChannel( - this->roomId(), + weakOf(this), this->roomId(), [this, weak = weakOf(this)](auto &&emoteMap) { if (auto shared = weak.lock()) this->ffzEmotes_.set( @@ -176,7 +231,52 @@ void TwitchChannel::refreshFFZChannelEmotes() { this->ffzCustomModBadge_.set(std::move(modBadge)); } - }); + }, + manualRefresh); +} + +void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) +{ + if (!reward.hasParsedSuccessfully) + { + return; + } + + if (!reward.isUserInputRequired) + { + MessageBuilder builder; + TwitchMessageBuilder::appendChannelPointRewardMessage(reward, &builder); + this->addMessage(builder.release()); + return; + } + + bool result; + { + auto channelPointRewards = this->channelPointRewards_.access(); + result = channelPointRewards->try_emplace(reward.id, reward).second; + } + if (result) + { + this->channelPointRewardAdded.invoke(reward); + } +} + +bool TwitchChannel::isChannelPointRewardKnown(const QString &rewardId) +{ + const auto &pointRewards = this->channelPointRewards_.accessConst(); + const auto &it = pointRewards->find(rewardId); + return it != pointRewards->end(); +} + +boost::optional TwitchChannel::channelPointReward( + const QString &rewardId) const +{ + auto rewards = this->channelPointRewards_.accessConst(); + auto it = rewards->find(rewardId); + + if (it == rewards->end()) + return boost::none; + return it->second; } void TwitchChannel::sendMessage(const QString &message) @@ -454,35 +554,25 @@ void TwitchChannel::refreshTitle() } this->titleRefreshedTime_ = QTime::currentTime(); - QString url("https://api.twitch.tv/kraken/channels/" + roomID); - NetworkRequest::twitchRequest(url) - .onSuccess( - [this, weak = weakOf(this)](auto result) -> Outcome { - ChannelPtr shared = weak.lock(); - if (!shared) - return Failure; + const auto onSuccess = [this, + weak = weakOf(this)](const auto &channel) { + ChannelPtr shared = weak.lock(); + if (!shared) + { + return; + } - const auto document = result.parseRapidJson(); + { + auto status = this->streamStatus_.access(); + status->title = channel.status; + } - auto statusIt = document.FindMember("status"); + this->liveStatusChanged.invoke(); + }; - if (statusIt == document.MemberEnd()) - { - return Failure; - } + const auto onFailure = [] {}; - { - auto status = this->streamStatus_.access(); - if (!rj::getSafe(statusIt->value, status->title)) - { - return Failure; - } - } - - this->liveStatusChanged.invoke(); - return Success; - }) - .execute(); + getKraken()->getChannel(roomID, onSuccess, onFailure); } void TwitchChannel::refreshLiveStatus() @@ -497,106 +587,72 @@ void TwitchChannel::refreshLiveStatus() return; } - QString url("https://api.twitch.tv/kraken/streams/" + roomID); + getHelix()->getStreamById( + roomID, + [this, weak = weakOf(this)](bool live, const auto &stream) { + ChannelPtr shared = weak.lock(); + if (!shared) + { + return; + } - // auto request = makeGetStreamRequest(roomID, QThread::currentThread()); - NetworkRequest::twitchRequest(url) - - .onSuccess( - [this, weak = weakOf(this)](auto result) -> Outcome { - ChannelPtr shared = weak.lock(); - if (!shared) - return Failure; - - return this->parseLiveStatus(result.parseRapidJson()); - }) - .execute(); + this->parseLiveStatus(live, stream); + }, + [] { + // failure + }); } -Outcome TwitchChannel::parseLiveStatus(const rapidjson::Document &document) +void TwitchChannel::parseLiveStatus(bool live, const HelixStream &stream) { - if (!document.IsObject()) + if (!live) { - qDebug() << "[TwitchChannel:refreshLiveStatus] root is not an object"; - return Failure; - } - - if (!document.HasMember("stream")) - { - qDebug() << "[TwitchChannel:refreshLiveStatus] Missing stream in root"; - return Failure; - } - - const auto &stream = document["stream"]; - - if (!stream.IsObject()) - { - // Stream is offline (stream is most likely null) this->setLive(false); - return Failure; + return; } - if (!stream.HasMember("viewers") || !stream.HasMember("game") || - !stream.HasMember("channel") || !stream.HasMember("created_at")) - { - qDebug() - << "[TwitchChannel:refreshLiveStatus] Missing members in stream"; - this->setLive(false); - return Failure; - } - - const rapidjson::Value &streamChannel = stream["channel"]; - - if (!streamChannel.IsObject() || !streamChannel.HasMember("status")) - { - qDebug() << "[TwitchChannel:refreshLiveStatus] Missing member " - "\"status\" in channel"; - return Failure; - } - - // Stream is live - { auto status = this->streamStatus_.access(); - status->viewerCount = stream["viewers"].GetUint(); - status->game = stream["game"].GetString(); - status->title = streamChannel["status"].GetString(); - QDateTime since = QDateTime::fromString( - stream["created_at"].GetString(), Qt::ISODate); + status->viewerCount = stream.viewerCount; + if (status->gameId != stream.gameId) + { + status->gameId = stream.gameId; + + // Resolve game ID to game name + getHelix()->getGameById( + stream.gameId, + [this, weak = weakOf(this)](const auto &game) { + ChannelPtr shared = weak.lock(); + if (!shared) + { + return; + } + + { + auto status = this->streamStatus_.access(); + status->game = game.name; + } + + this->liveStatusChanged.invoke(); + }, + [] { + // failure + }); + } + status->title = stream.title; + QDateTime since = QDateTime::fromString(stream.startedAt, Qt::ISODate); auto diff = since.secsTo(QDateTime::currentDateTime()); status->uptime = QString::number(diff / 3600) + "h " + QString::number(diff % 3600 / 60) + "m"; status->rerun = false; - if (stream.HasMember("stream_type")) - { - status->streamType = stream["stream_type"].GetString(); - } - else - { - status->streamType = QString(); - } - - if (stream.HasMember("broadcast_platform")) - { - const auto &broadcastPlatformValue = stream["broadcast_platform"]; - - if (broadcastPlatformValue.IsString()) - { - const char *broadcastPlatform = - stream["broadcast_platform"].GetString(); - if (strcmp(broadcastPlatform, "rerun") == 0) - { - status->rerun = true; - } - } - } + status->streamType = stream.type; } - setLive(true); + + this->setLive(true); + // Signal all listeners that the stream status has been updated this->liveStatusChanged.invoke(); - - return Success; } void TwitchChannel::loadRecentMessages() @@ -641,9 +697,6 @@ void TwitchChannel::loadRecentMessages() void TwitchChannel::refreshPubsub() { - // listen to moderation actions - if (!this->hasModRights()) - return; auto roomId = this->roomId(); if (roomId.isEmpty()) return; @@ -651,6 +704,7 @@ void TwitchChannel::refreshPubsub() auto account = getApp()->accounts->twitch.getCurrent(); getApp()->twitch2->pubsub->listenToChannelModerationActions(roomId, account); + getApp()->twitch2->pubsub->listenToChannelPointRewards(roomId, account); } void TwitchChannel::refreshChatters() diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 026c90717..86ecaad2d 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -7,15 +7,17 @@ #include "common/Outcome.hpp" #include "common/UniqueAccess.hpp" #include "common/UsernameSet.hpp" +#include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/TwitchEmotes.hpp" +#include "providers/twitch/api/Helix.hpp" -#include #include #include #include #include -#include #include + +#include #include namespace chatterino { @@ -43,6 +45,7 @@ public: unsigned viewerCount = 0; QString title; QString game; + QString gameId; QString uptime; QString streamType; }; @@ -89,8 +92,8 @@ public: std::shared_ptr bttvEmotes() const; std::shared_ptr ffzEmotes() const; - virtual void refreshBTTVChannelEmotes(); - virtual void refreshFFZChannelEmotes(); + virtual void refreshBTTVChannelEmotes(bool manualRefresh); + virtual void refreshFFZChannelEmotes(bool manualRefresh); // Badges boost::optional ffzCustomModBadge() const; @@ -106,6 +109,14 @@ public: pajlada::Signals::NoArgSignal liveStatusChanged; pajlada::Signals::NoArgSignal roomModesChanged; + // Channel point rewards + pajlada::Signals::SelfDisconnectingSignal + channelPointRewardAdded; + void addChannelPointReward(const ChannelPointReward &reward); + bool isChannelPointRewardKnown(const QString &rewardId); + boost::optional channelPointReward( + const QString &rewardId) const; + private: struct NameOptions { QString displayName; @@ -120,7 +131,7 @@ protected: private: // Methods void refreshLiveStatus(); - Outcome parseLiveStatus(const rapidjson::Document &document); + void parseLiveStatus(bool live, const HelixStream &stream); void refreshPubsub(); void refreshChatters(); void refreshBadges(); @@ -156,6 +167,7 @@ private: UniqueAccess>> badgeSets_; // "subscribers": { "0": ... "3": ... "6": ... UniqueAccess> cheerEmoteSets_; + UniqueAccess> channelPointRewards_; bool mod_ = false; bool vip_ = false; diff --git a/src/providers/twitch/TwitchCommon.hpp b/src/providers/twitch/TwitchCommon.hpp index af9db84f1..329da1074 100644 --- a/src/providers/twitch/TwitchCommon.hpp +++ b/src/providers/twitch/TwitchCommon.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include namespace chatterino { @@ -11,4 +12,22 @@ inline QByteArray getDefaultClientID() return QByteArray("7ue61iz46fz11y3cugd0l3tawb4taal"); } +static const std::vector TWITCH_USERNAME_COLORS = { + {255, 0, 0}, // Red + {0, 0, 255}, // Blue + {0, 255, 0}, // Green + {178, 34, 34}, // FireBrick + {255, 127, 80}, // Coral + {154, 205, 50}, // YellowGreen + {255, 69, 0}, // OrangeRed + {46, 139, 87}, // SeaGreen + {218, 165, 32}, // GoldenRod + {210, 105, 30}, // Chocolate + {95, 158, 160}, // CadetBlue + {30, 144, 255}, // DodgerBlue + {255, 105, 180}, // HotPink + {138, 43, 226}, // BlueViolet + {0, 255, 127}, // SpringGreen +}; + } // namespace chatterino diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 45f50cecd..b2c2fe98a 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -7,16 +7,13 @@ #include "common/Common.hpp" #include "common/Env.hpp" #include "controllers/accounts/AccountController.hpp" -#include "controllers/highlights/HighlightController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" -#include "providers/twitch/ChatroomChannel.hpp" #include "providers/twitch/IrcMessageHandler.hpp" #include "providers/twitch/PubsubClient.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchHelpers.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "util/PostToThread.hpp" // using namespace Communi; @@ -24,26 +21,13 @@ using namespace std::chrono_literals; namespace chatterino { -namespace { - bool isChatroom(const QString &channel) - { - if (channel.left(10) == "chatrooms:") - { - auto reflist = channel.splitRef(':'); - if (reflist.size() == 3) - { - return true; - } - } - return false; - } -} // namespace - TwitchIrcServer::TwitchIrcServer() : whispersChannel(new Channel("/whispers", Channel::Type::TwitchWhispers)) , mentionsChannel(new Channel("/mentions", Channel::Type::TwitchMentions)) , watchingChannel(Channel::getEmpty(), Channel::Type::TwitchWatching) { + this->initializeIrc(); + this->pubsub = new PubSub; // getSettings()->twitchSeperateWriteConnection.connect([this](auto, auto) { @@ -100,18 +84,8 @@ void TwitchIrcServer::initializeConnection(IrcConnection *connection, std::shared_ptr TwitchIrcServer::createChannel( const QString &channelName) { - std::shared_ptr channel; - if (isChatroom(channelName)) - { - channel = std::static_pointer_cast( - std::shared_ptr(new ChatroomChannel( - channelName, this->twitchBadges, this->bttv, this->ffz))); - } - else - { - channel = std::shared_ptr(new TwitchChannel( - channelName, this->twitchBadges, this->bttv, this->ffz)); - } + auto channel = std::shared_ptr(new TwitchChannel( + channelName, this->twitchBadges, this->bttv, this->ffz)); channel->initialize(); channel->sendMessageSignal.connect( diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 0d4680472..0e64beaf4 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -2,13 +2,13 @@ #include "Application.hpp" #include "controllers/accounts/AccountController.hpp" -#include "controllers/highlights/HighlightController.hpp" #include "controllers/ignores/IgnoreController.hpp" -#include "controllers/pings/PingController.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" #include "messages/Message.hpp" #include "providers/chatterino/ChatterinoBadges.hpp" #include "providers/twitch/TwitchBadges.hpp" #include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Emotes.hpp" #include "singletons/Resources.hpp" @@ -27,68 +27,35 @@ namespace { +// matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username" +const QRegularExpression mentionRegex("^@(\\w+)[.,!?;]*?$"); + const QSet zeroWidthEmotes{ - "SoSnowy", "IceCold", "SantaHat", "TopHat", "ReinDeer", "CandyCane", + "SoSnowy", "IceCold", "SantaHat", "TopHat", + "ReinDeer", "CandyCane", "cvMask", "cvHazmat", }; -QColor getRandomColor(const QVariant &userId) -{ - static const std::vector twitchUsernameColors = { - {255, 0, 0}, // Red - {0, 0, 255}, // Blue - {0, 255, 0}, // Green - {178, 34, 34}, // FireBrick - {255, 127, 80}, // Coral - {154, 205, 50}, // YellowGreen - {255, 69, 0}, // OrangeRed - {46, 139, 87}, // SeaGreen - {218, 165, 32}, // GoldenRod - {210, 105, 30}, // Chocolate - {95, 158, 160}, // CadetBlue - {30, 144, 255}, // DodgerBlue - {255, 105, 180}, // HotPink - {138, 43, 226}, // BlueViolet - {0, 255, 127}, // SpringGreen - }; - - bool ok = true; - int colorSeed = userId.toInt(&ok); - if (!ok) - { - // We were unable to convert the user ID to an integer, this means Twitch has decided to start using non-integer user IDs - // Just randomize the users color - colorSeed = std::rand(); - } - - const auto colorIndex = colorSeed % twitchUsernameColors.size(); - return twitchUsernameColors[colorIndex]; -} - -QUrl getFallbackHighlightSound() -{ - using namespace chatterino; - - QString path = getSettings()->pathHighlightSound; - bool fileExists = QFileInfo::exists(path) && QFileInfo(path).isFile(); - - // Use fallback sound when checkbox is not checked - // or custom file doesn't exist - if (getSettings()->customHighlightSound && fileExists) - { - return QUrl::fromLocalFile(path); - } - else - { - return QUrl("qrc:/sounds/ping2.wav"); - } -} - } // namespace namespace chatterino { namespace { + QColor getRandomColor(const QVariant &userId) + { + bool ok = true; + int colorSeed = userId.toInt(&ok); + if (!ok) + { + // We were unable to convert the user ID to an integer, this means Twitch has decided to start using non-integer user IDs + // Just randomize the users color + colorSeed = std::rand(); + } + + const auto colorIndex = colorSeed % TWITCH_USERNAME_COLORS.size(); + return TWITCH_USERNAME_COLORS[colorIndex]; + } + QStringList parseTagList(const QVariantMap &tags, const QString &key) { auto iterator = tags.find(key); @@ -140,13 +107,8 @@ namespace { TwitchMessageBuilder::TwitchMessageBuilder( Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, const MessageParseArgs &_args) - : channel(_channel) + : SharedMessageBuilder(_channel, _ircMessage, _args) , twitchChannel(dynamic_cast(_channel)) - , ircMessage(_ircMessage) - , args(_args) - , tags(this->ircMessage->tags()) - , originalMessage_(_ircMessage->content()) - , action_(_ircMessage->isAction()) { this->usernameColor_ = getApp()->themes->messages.textColors.system; } @@ -154,32 +116,21 @@ TwitchMessageBuilder::TwitchMessageBuilder( TwitchMessageBuilder::TwitchMessageBuilder( Channel *_channel, const Communi::IrcMessage *_ircMessage, const MessageParseArgs &_args, QString content, bool isAction) - : channel(_channel) + : SharedMessageBuilder(_channel, _ircMessage, _args, content, isAction) , twitchChannel(dynamic_cast(_channel)) - , ircMessage(_ircMessage) - , args(_args) - , tags(this->ircMessage->tags()) - , originalMessage_(content) - , action_(isAction) { this->usernameColor_ = getApp()->themes->messages.textColors.system; } bool TwitchMessageBuilder::isIgnored() const { - auto app = getApp(); - - // TODO(pajlada): Do we need to check if the phrase is valid first? - for (const auto &phrase : app->ignores->phrases) + if (SharedMessageBuilder::isIgnored()) { - if (phrase.isBlock() && phrase.isMatch(this->originalMessage_)) - { - qDebug() << "Blocking message because it contains ignored phrase" - << phrase.getPattern(); - return true; - } + return true; } + auto app = getApp(); + if (getSettings()->enableTwitchIgnoredUsers && this->tags.contains("user-id")) { @@ -205,8 +156,7 @@ bool TwitchMessageBuilder::isIgnored() const case ShowIgnoredUsersMessages::Never: break; } - qDebug() << "Blocking message because it's from blocked user" - << user.name; + return true; } } @@ -215,79 +165,44 @@ bool TwitchMessageBuilder::isIgnored() const return false; } -inline QMediaPlayer *getPlayer() -{ - if (isGuiThread()) - { - static auto player = new QMediaPlayer; - return player; - } - else - { - return nullptr; - } -} - void TwitchMessageBuilder::triggerHighlights() { - static QUrl currentPlayerUrl; - if (this->historicalMessage_) { // Do nothing. Highlights should not be triggered on historical messages. return; } - if (getApp()->pings->isMuted(this->channel->getName())) - { - // Do nothing. Pings are muted in this channel. - return; - } - - bool hasFocus = (QApplication::focusWidget() != nullptr); - bool resolveFocus = !hasFocus || getSettings()->highlightAlwaysPlaySound; - - if (this->highlightSound_ && resolveFocus) - { - if (auto player = getPlayer()) - { - // update the media player url if necessary - if (currentPlayerUrl != this->highlightSoundUrl_) - { - player->setMedia(this->highlightSoundUrl_); - - currentPlayerUrl = this->highlightSoundUrl_; - } - - player->play(); - } - } - - if (this->highlightAlert_) - { - getApp()->windows->sendAlert(); - } + SharedMessageBuilder::triggerHighlights(); } MessagePtr TwitchMessageBuilder::build() { - // PARSING + // PARSE this->userId_ = this->ircMessage->tag("user-id").toString(); - this->parseUsername(); + this->parse(); if (this->userName == this->channel->getName()) { this->senderIsBroadcaster = true; } - this->message().flags.set(MessageFlag::Collapsed); - - // PARSING this->parseMessageID(); this->parseRoomID(); + // If it is a reward it has to be appended first + if (this->args.channelPointRewardId != "") + { + const auto &reward = this->twitchChannel->channelPointReward( + this->args.channelPointRewardId); + if (reward) + { + this->appendChannelPointRewardMessage(reward.get(), this); + } + } + this->appendChannelName(); if (this->tags.contains("rm-deleted")) @@ -297,6 +212,13 @@ MessagePtr TwitchMessageBuilder::build() this->historicalMessage_ = this->tags.contains("historical"); + if (this->tags.contains("msg-id") && + this->tags["msg-id"].toString().split(';').contains( + "highlighted-message")) + { + this->message().flags.set(MessageFlag::RedeemedHighlight); + } + // timestamp if (this->historicalMessage_) { @@ -410,6 +332,8 @@ MessagePtr TwitchMessageBuilder::build() if (this->args.isReceivedWhisper && getSettings()->highlightInlineWhispers) { this->message().flags.set(MessageFlag::HighlightedWhisper, true); + this->message().highlightColor = + ColorProvider::instance().color(ColorType::Whisper); } return this->release(); @@ -471,7 +395,7 @@ void TwitchMessageBuilder::addWords( void TwitchMessageBuilder::addTextOrEmoji(EmotePtr emote) { - this->emplace(emote, MessageElementFlag::EmojiAll); + return SharedMessageBuilder::addTextOrEmoji(emote); } void TwitchMessageBuilder::addTextOrEmoji(const QString &string_) @@ -497,63 +421,50 @@ void TwitchMessageBuilder::addTextOrEmoji(const QString &string_) // Actually just text auto linkString = this->matchLink(string); - auto link = Link(); auto textColor = this->action_ ? MessageColor(this->usernameColor_) : MessageColor(MessageColor::Text); - if (linkString.isEmpty()) - { - if (string.startsWith('@')) - { - this->emplace(string, MessageElementFlag::BoldUsername, - textColor, FontStyle::ChatMediumBold); - this->emplace( - string, MessageElementFlag::NonBoldUsername, textColor); - } - else - { - this->emplace(string, MessageElementFlag::Text, - textColor); - } - } - else + if (!linkString.isEmpty()) { this->addLink(string, linkString); + return; } - // if (!linkString.isEmpty()) { - // if (getSettings()->lowercaseLink) { - // QRegularExpression httpRegex("\\bhttps?://", - // QRegularExpression::CaseInsensitiveOption); QRegularExpression - // ftpRegex("\\bftps?://", - // QRegularExpression::CaseInsensitiveOption); QRegularExpression - // getDomain("\\/\\/([^\\/]*)"); QString tempString = string; + if (string.startsWith('@')) + { + auto match = mentionRegex.match(string); + // Only treat as @mention if valid username + if (match.hasMatch()) + { + QString username = match.captured(1); + this->emplace(string, MessageElementFlag::BoldUsername, + textColor, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, username}); - // if (!string.contains(httpRegex)) { - // if (!string.contains(ftpRegex)) { - // tempString.insert(0, "http://"); - // } - // } - // QString domain = getDomain.match(tempString).captured(1); - // string.replace(domain, domain.toLower()); - // } - // link = Link(Link::Url, linkString); - // textColor = MessageColor(MessageColor::Link); - //} - // if (string.startsWith('@')) { - // this->emplace(string, MessageElementFlag::BoldUsername, - // textColor, - // FontStyle::ChatMediumBold) // - // ->setLink(link); - // this->emplace(string, - // MessageElementFlag::NonBoldUsername, - // textColor) // - // ->setLink(link); - //} else { - // this->emplace(string, MessageElementFlag::Text, - // textColor) // - // ->setLink(link); - //} + this->emplace( + string, MessageElementFlag::NonBoldUsername, textColor) + ->setLink({Link::UserInfo, username}); + return; + } + } + + if (this->twitchChannel != nullptr && getSettings()->findAllUsernames) + { + auto chatters = this->twitchChannel->accessChatters(); + if (chatters->contains(string)) + { + this->emplace(string, MessageElementFlag::BoldUsername, + textColor, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, string}); + + this->emplace( + string, MessageElementFlag::NonBoldUsername, textColor) + ->setLink({Link::UserInfo, string}); + return; + } + } + + this->emplace(string, MessageElementFlag::Text, textColor); } void TwitchMessageBuilder::parseMessageID() @@ -586,16 +497,6 @@ void TwitchMessageBuilder::parseRoomID() } } -void TwitchMessageBuilder::appendChannelName() -{ - QString channelName("#" + this->channel->getName()); - Link link(Link::Url, this->channel->getName() + "\n" + this->message().id); - - this->emplace(channelName, MessageElementFlag::ChannelName, - MessageColor::System) // - ->setLink(link); -} - void TwitchMessageBuilder::parseUsernameColor() { const auto iterator = this->tags.find("color"); @@ -616,10 +517,7 @@ void TwitchMessageBuilder::parseUsernameColor() void TwitchMessageBuilder::parseUsername() { - this->parseUsernameColor(); - - // username - this->userName = this->ircMessage->nick(); + SharedMessageBuilder::parseUsername(); if (this->userName.isEmpty() || this->args.trimSubscriberUsername) { @@ -764,8 +662,7 @@ void TwitchMessageBuilder::appendUsername() void TwitchMessageBuilder::runIgnoreReplaces( std::vector> &twitchEmotes) { - auto app = getApp(); - const auto &phrases = app->ignores->phrases; + auto phrases = getCSettings().ignoredMessages.readOnly(); auto removeEmotesInRange = [](int pos, int len, std::vector> @@ -828,7 +725,7 @@ void TwitchMessageBuilder::runIgnoreReplaces( } }; - for (const auto &phrase : phrases) + for (const auto &phrase : *phrases) { if (phrase.isBlock()) { @@ -976,200 +873,6 @@ void TwitchMessageBuilder::runIgnoreReplaces( } } -void TwitchMessageBuilder::parseHighlights() -{ - auto app = getApp(); - - if (this->message().flags.has(MessageFlag::Subscription) && - getSettings()->enableSubHighlight) - { - if (getSettings()->enableSubHighlightTaskbar) - { - this->highlightAlert_ = true; - } - - if (getSettings()->enableSubHighlightSound) - { - this->highlightSound_ = true; - - // Use custom sound if set, otherwise use fallback - if (!getSettings()->subHighlightSoundUrl.getValue().isEmpty()) - { - this->highlightSoundUrl_ = - QUrl(getSettings()->subHighlightSoundUrl.getValue()); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - } - - this->message().flags.set(MessageFlag::Highlighted); - this->message().highlightColor = - ColorProvider::instance().color(ColorType::Subscription); - - // This message was a subscription. - // Don't check for any other highlight phrases. - return; - } - - auto currentUser = app->accounts->twitch.getCurrent(); - - QString currentUsername = currentUser->getUserName(); - - if (app->highlights->blacklistContains(this->ircMessage->nick())) - { - // Do nothing. We ignore highlights from this user. - return; - } - - // Highlight because it's a whisper - if (this->args.isReceivedWhisper && getSettings()->enableWhisperHighlight) - { - if (getSettings()->enableWhisperHighlightTaskbar) - { - this->highlightAlert_ = true; - } - - if (getSettings()->enableWhisperHighlightSound) - { - this->highlightSound_ = true; - - // Use custom sound if set, otherwise use fallback - if (!getSettings()->whisperHighlightSoundUrl.getValue().isEmpty()) - { - this->highlightSoundUrl_ = - QUrl(getSettings()->whisperHighlightSoundUrl.getValue()); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - } - - this->message().highlightColor = - ColorProvider::instance().color(ColorType::Whisper); - - /* - * Do _NOT_ return yet, we might want to apply phrase/user name - * highlights (which override whisper color/sound). - */ - } - - std::vector userHighlights = - app->highlights->highlightedUsers.cloneVector(); - - // Highlight because of sender - for (const HighlightPhrase &userHighlight : userHighlights) - { - if (!userHighlight.isMatch(this->ircMessage->nick())) - { - continue; - } - qDebug() << "Highlight because user" << this->ircMessage->nick() - << "sent a message"; - - this->message().flags.set(MessageFlag::Highlighted); - this->message().highlightColor = userHighlight.getColor(); - - if (userHighlight.hasAlert()) - { - this->highlightAlert_ = true; - } - - if (userHighlight.hasSound()) - { - this->highlightSound_ = true; - // Use custom sound if set, otherwise use the fallback sound - if (userHighlight.hasCustomSound()) - { - this->highlightSoundUrl_ = userHighlight.getSoundUrl(); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - } - - if (this->highlightAlert_ && this->highlightSound_) - { - /* - * User name highlights "beat" highlight phrases: If a message has - * all attributes (color, taskbar flashing, sound) set, highlight - * phrases will not be checked. - */ - return; - } - } - - if (this->ircMessage->nick() == currentUsername) - { - // Do nothing. Highlights cannot be triggered by yourself - return; - } - - // TODO: This vector should only be rebuilt upon highlights being changed - // fourtf: should be implemented in the HighlightsController - std::vector activeHighlights = - app->highlights->phrases.cloneVector(); - - if (getSettings()->enableSelfHighlight && currentUsername.size() > 0) - { - HighlightPhrase selfHighlight( - currentUsername, getSettings()->enableSelfHighlightTaskbar, - getSettings()->enableSelfHighlightSound, false, false, - getSettings()->selfHighlightSoundUrl.getValue(), - ColorProvider::instance().color(ColorType::SelfHighlight)); - activeHighlights.emplace_back(std::move(selfHighlight)); - } - - // Highlight because of message - for (const HighlightPhrase &highlight : activeHighlights) - { - if (!highlight.isMatch(this->originalMessage_)) - { - continue; - } - - qDebug() << "Highlight because" << this->originalMessage_ << "matches" - << highlight.getPattern(); - - this->message().flags.set(MessageFlag::Highlighted); - this->message().highlightColor = highlight.getColor(); - - if (highlight.hasAlert()) - { - this->highlightAlert_ = true; - } - - // Only set highlightSound_ if it hasn't been set by username - // highlights already. - if (highlight.hasSound() && !this->highlightSound_) - { - this->highlightSound_ = true; - - // Use custom sound if set, otherwise use fallback sound - if (highlight.hasCustomSound()) - { - this->highlightSoundUrl_ = highlight.getSoundUrl(); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - } - - if (this->highlightAlert_ && this->highlightSound_) - { - /* - * Break once no further attributes (taskbar, sound) can be - * applied. - */ - break; - } - } -} - void TwitchMessageBuilder::appendTwitchEmote( const QString &emote, std::vector> &vec, @@ -1301,7 +1004,6 @@ void TwitchMessageBuilder::appendTwitchBadges() auto badgeEmote = this->getTwitchBadge(badge); if (!badgeEmote) { - qDebug() << "No channel/global variant found" << badge.key_; continue; } auto tooltip = (*badgeEmote)->tooltip.string; @@ -1328,8 +1030,17 @@ void TwitchMessageBuilder::appendTwitchBadges() auto badgeInfoIt = badgeInfos.find(badge.key_); if (badgeInfoIt != badgeInfos.end()) { + // badge.value_ is 4 chars long if user is subbed on higher tier + // (tier + amount of months with leading zero if less than 100) + // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub + const auto &subTier = + badge.value_.length() > 3 ? badge.value_.front() : '1'; const auto &subMonths = badgeInfoIt->second; - tooltip += QString(" (%0 months)").arg(subMonths); + tooltip += + QString(" (%1%2 months)") + .arg(subTier != '1' ? QString("Tier %1, ").arg(subTier) + : "") + .arg(subMonths); } } @@ -1430,4 +1141,34 @@ Outcome TwitchMessageBuilder::tryParseCheermote(const QString &string) return Success; } +void TwitchMessageBuilder::appendChannelPointRewardMessage( + const ChannelPointReward &reward, MessageBuilder *builder) +{ + QString redeemed = "Redeemed"; + if (!reward.isUserInputRequired) + { + builder->emplace( + reward.user.login, MessageElementFlag::ChannelPointReward, + MessageColor::Text, FontStyle::ChatMediumBold); + redeemed = "redeemed"; + } + builder->emplace(redeemed, + MessageElementFlag::ChannelPointReward); + builder->emplace( + reward.title, MessageElementFlag::ChannelPointReward, + MessageColor::Text, FontStyle::ChatMediumBold); + builder->emplace( + reward.image, MessageElementFlag::ChannelPointRewardImage); + builder->emplace( + QString::number(reward.cost), MessageElementFlag::ChannelPointReward, + MessageColor::Text, FontStyle::ChatMediumBold); + if (reward.isUserInputRequired) + { + builder->emplace( + MessageElementFlag::ChannelPointReward); + } + + builder->message().flags.set(MessageFlag::RedeemedChannelPointReward); +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 7066c3267..5bdaebbf4 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -1,8 +1,9 @@ -#pragma once +#pragma once #include "common/Aliases.hpp" #include "common/Outcome.hpp" -#include "messages/MessageBuilder.hpp" +#include "messages/SharedMessageBuilder.hpp" +#include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/TwitchBadge.hpp" #include @@ -17,7 +18,7 @@ using EmotePtr = std::shared_ptr; class Channel; class TwitchChannel; -class TwitchMessageBuilder : public MessageBuilder +class TwitchMessageBuilder : public SharedMessageBuilder { public: enum UsernameDisplayMode : int { @@ -36,30 +37,23 @@ public: const MessageParseArgs &_args, QString content, bool isAction); - Channel *channel; TwitchChannel *twitchChannel; - const Communi::IrcMessage *ircMessage; - MessageParseArgs args; - const QVariantMap tags; - QString userName; + [[nodiscard]] bool isIgnored() const override; + void triggerHighlights() override; + MessagePtr build() override; - [[nodiscard]] bool isIgnored() const; - // triggerHighlights triggers any alerts or sounds parsed by parseHighlights - void triggerHighlights(); - MessagePtr build(); + static void appendChannelPointRewardMessage( + const ChannelPointReward &reward, MessageBuilder *builder); private: + void parseUsernameColor() override; + void parseUsername() override; void parseMessageID(); void parseRoomID(); - void appendChannelName(); - void parseUsernameColor(); - void parseUsername(); void appendUsername(); void runIgnoreReplaces( std::vector> &twitchEmotes); - // parseHighlights only updates the visual state of the message, but leaves the playing of alerts and sounds to the triggerHighlights function - void parseHighlights(); boost::optional getTwitchBadge(const Badge &badge); void appendTwitchEmote( @@ -71,8 +65,8 @@ private: void addWords( const QStringList &words, const std::vector> &twitchEmotes); - void addTextOrEmoji(EmotePtr emote); - void addTextOrEmoji(const QString &value); + void addTextOrEmoji(EmotePtr emote) override; + void addTextOrEmoji(const QString &value) override; void appendTwitchBadges(); void appendChatterinoBadges(); @@ -86,16 +80,7 @@ private: bool historicalMessage_ = false; QString userId_; - QColor usernameColor_; - QString originalMessage_; bool senderIsBroadcaster{}; - - const bool action_ = false; - - bool highlightAlert_ = false; - bool highlightSound_ = false; - - QUrl highlightSoundUrl_; }; } // namespace chatterino diff --git a/src/providers/twitch/TwitchParseCheerEmotes.cpp b/src/providers/twitch/TwitchParseCheerEmotes.cpp index 5258baf9a..9667ffd2a 100644 --- a/src/providers/twitch/TwitchParseCheerEmotes.cpp +++ b/src/providers/twitch/TwitchParseCheerEmotes.cpp @@ -278,6 +278,7 @@ namespace { return true; } + } // namespace // Look through the results of diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp new file mode 100644 index 000000000..974b62128 --- /dev/null +++ b/src/providers/twitch/api/Helix.cpp @@ -0,0 +1,368 @@ +#include "providers/twitch/api/Helix.hpp" + +#include "common/Outcome.hpp" + +namespace chatterino { + +static Helix *instance = nullptr; + +void Helix::fetchUsers(QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) +{ + QUrlQuery urlQuery; + + for (const auto &id : userIds) + { + urlQuery.addQueryItem("id", id); + } + + for (const auto &login : userLogins) + { + urlQuery.addQueryItem("login", login); + } + + // TODO: set on success and on error + this->makeRequest("users", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + auto data = root.value("data"); + + if (!data.isArray()) + { + failureCallback(); + return Failure; + } + + std::vector users; + + for (const auto &jsonUser : data.toArray()) + { + users.emplace_back(jsonUser.toObject()); + } + + successCallback(users); + + return Success; + }) + .onError([failureCallback](auto result) { + // TODO: make better xd + failureCallback(); + }) + .execute(); +} + +void Helix::getUserByName(QString userId, + ResultCallback successCallback, + HelixFailureCallback failureCallback) +{ + QStringList userIds; + QStringList userLogins{userId}; + + this->fetchUsers( + userIds, userLogins, + [successCallback, + failureCallback](const std::vector &users) { + if (users.empty()) + { + failureCallback(); + return; + } + successCallback(users[0]); + }, + failureCallback); +} + +void Helix::getUserById(QString userId, + ResultCallback successCallback, + HelixFailureCallback failureCallback) +{ + QStringList userIds{userId}; + QStringList userLogins; + + this->fetchUsers( + userIds, userLogins, + [successCallback, failureCallback](const auto &users) { + if (users.empty()) + { + failureCallback(); + return; + } + successCallback(users[0]); + }, + failureCallback); +} + +void Helix::fetchUsersFollows( + QString fromId, QString toId, + ResultCallback successCallback, + HelixFailureCallback failureCallback) +{ + assert(!fromId.isEmpty() || !toId.isEmpty()); + + QUrlQuery urlQuery; + + if (!fromId.isEmpty()) + { + urlQuery.addQueryItem("from_id", fromId); + } + + if (!toId.isEmpty()) + { + urlQuery.addQueryItem("to_id", toId); + } + + // TODO: set on success and on error + this->makeRequest("users/follows", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + if (root.empty()) + { + failureCallback(); + return Failure; + } + successCallback(HelixUsersFollowsResponse(root)); + return Success; + }) + .onError([failureCallback](auto result) { + // TODO: make better xd + failureCallback(); + }) + .execute(); +} + +void Helix::getUserFollowers( + QString userId, ResultCallback successCallback, + HelixFailureCallback failureCallback) +{ + this->fetchUsersFollows("", userId, successCallback, failureCallback); +} + +void Helix::getUserFollow( + QString userId, QString targetId, + ResultCallback successCallback, + HelixFailureCallback failureCallback) +{ + this->fetchUsersFollows( + userId, targetId, + [successCallback](const auto &response) { + if (response.data.empty()) + { + successCallback(false, HelixUsersFollowsRecord()); + return; + } + + successCallback(true, response.data[0]); + }, + failureCallback); +} + +void Helix::fetchStreams( + QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) +{ + QUrlQuery urlQuery; + + for (const auto &id : userIds) + { + urlQuery.addQueryItem("user_id", id); + } + + for (const auto &login : userLogins) + { + urlQuery.addQueryItem("user_login", login); + } + + // TODO: set on success and on error + this->makeRequest("streams", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + auto data = root.value("data"); + + if (!data.isArray()) + { + failureCallback(); + return Failure; + } + + std::vector streams; + + for (const auto &jsonStream : data.toArray()) + { + streams.emplace_back(jsonStream.toObject()); + } + + successCallback(streams); + + return Success; + }) + .onError([failureCallback](auto result) { + // TODO: make better xd + failureCallback(); + }) + .execute(); +} + +void Helix::getStreamById(QString userId, + ResultCallback successCallback, + HelixFailureCallback failureCallback) +{ + QStringList userIds{userId}; + QStringList userLogins; + + this->fetchStreams( + userIds, userLogins, + [successCallback, failureCallback](const auto &streams) { + if (streams.empty()) + { + successCallback(false, HelixStream()); + return; + } + successCallback(true, streams[0]); + }, + failureCallback); +} + +void Helix::getStreamByName(QString userName, + ResultCallback successCallback, + HelixFailureCallback failureCallback) +{ + QStringList userIds; + QStringList userLogins{userName}; + + this->fetchStreams( + userIds, userLogins, + [successCallback, failureCallback](const auto &streams) { + if (streams.empty()) + { + successCallback(false, HelixStream()); + return; + } + successCallback(true, streams[0]); + }, + failureCallback); +} + +/// + +void Helix::fetchGames(QStringList gameIds, QStringList gameNames, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) +{ + assert((gameIds.length() + gameNames.length()) > 0); + + QUrlQuery urlQuery; + + for (const auto &id : gameIds) + { + urlQuery.addQueryItem("id", id); + } + + for (const auto &login : gameNames) + { + urlQuery.addQueryItem("name", login); + } + + // TODO: set on success and on error + this->makeRequest("games", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + auto data = root.value("data"); + + if (!data.isArray()) + { + failureCallback(); + return Failure; + } + + std::vector games; + + for (const auto &jsonStream : data.toArray()) + { + games.emplace_back(jsonStream.toObject()); + } + + successCallback(games); + + return Success; + }) + .onError([failureCallback](auto result) { + // TODO: make better xd + failureCallback(); + }) + .execute(); +} + +void Helix::getGameById(QString gameId, + ResultCallback successCallback, + HelixFailureCallback failureCallback) +{ + QStringList gameIds{gameId}; + QStringList gameNames; + + this->fetchGames( + gameIds, gameNames, + [successCallback, failureCallback](const auto &games) { + if (games.empty()) + { + failureCallback(); + return; + } + successCallback(games[0]); + }, + failureCallback); +} + +NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) +{ + assert(!url.startsWith("/")); + + if (this->clientId.isEmpty()) + { + qDebug() + << "Helix::makeRequest called without a client ID set BabyRage"; + // return boost::none; + } + + if (this->oauthToken.isEmpty()) + { + qDebug() + << "Helix::makeRequest called without an oauth token set BabyRage"; + // return boost::none; + } + + const QString baseUrl("https://api.twitch.tv/helix/"); + + QUrl fullUrl(baseUrl + url); + + fullUrl.setQuery(urlQuery); + + return NetworkRequest(fullUrl) + .timeout(5 * 1000) + .header("Accept", "application/json") + .header("Client-ID", this->clientId) + .header("Authorization", "Bearer " + this->oauthToken); +} + +void Helix::update(QString clientId, QString oauthToken) +{ + this->clientId = clientId; + this->oauthToken = oauthToken; +} + +void Helix::initialize() +{ + assert(instance == nullptr); + + instance = new Helix(); +} + +Helix *getHelix() +{ + assert(instance != nullptr); + + return instance; +} + +} // namespace chatterino diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp new file mode 100644 index 000000000..bfe54b699 --- /dev/null +++ b/src/providers/twitch/api/Helix.hpp @@ -0,0 +1,198 @@ +#pragma once + +#include "common/NetworkRequest.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace chatterino { + +using HelixFailureCallback = std::function; +template +using ResultCallback = std::function; + +struct HelixUser { + QString id; + QString login; + QString displayName; + QString description; + QString profileImageUrl; + int viewCount; + + explicit HelixUser(QJsonObject jsonObject) + : id(jsonObject.value("id").toString()) + , login(jsonObject.value("login").toString()) + , displayName(jsonObject.value("display_name").toString()) + , description(jsonObject.value("description").toString()) + , profileImageUrl(jsonObject.value("profile_image_url").toString()) + , viewCount(jsonObject.value("view_count").toInt()) + { + } +}; + +struct HelixUsersFollowsRecord { + QString fromId; + QString fromName; + QString toId; + QString toName; + QString followedAt; // date time object + + HelixUsersFollowsRecord() + : fromId("") + , fromName("") + , toId("") + , toName("") + , followedAt("") + { + } + + explicit HelixUsersFollowsRecord(QJsonObject jsonObject) + : fromId(jsonObject.value("from_id").toString()) + , fromName(jsonObject.value("from_name").toString()) + , toId(jsonObject.value("to_id").toString()) + , toName(jsonObject.value("to_name").toString()) + , followedAt(jsonObject.value("followed_at").toString()) + { + } +}; + +struct HelixUsersFollowsResponse { + int total; + std::vector data; + explicit HelixUsersFollowsResponse(QJsonObject jsonObject) + : total(jsonObject.value("total").toInt()) + { + const auto &jsonData = jsonObject.value("data").toArray(); + std::transform(jsonData.begin(), jsonData.end(), + std::back_inserter(this->data), + [](const QJsonValue &record) { + return HelixUsersFollowsRecord(record.toObject()); + }); + } +}; + +struct HelixStream { + QString id; // stream id + QString userId; + QString userName; + QString gameId; + QString type; + QString title; + int viewerCount; + QString startedAt; + QString language; + QString thumbnailUrl; + + HelixStream() + : id("") + , userId("") + , userName("") + , gameId("") + , type("") + , title("") + , viewerCount() + , startedAt("") + , language("") + , thumbnailUrl("") + { + } + + explicit HelixStream(QJsonObject jsonObject) + : id(jsonObject.value("id").toString()) + , userId(jsonObject.value("user_id").toString()) + , userName(jsonObject.value("user_name").toString()) + , gameId(jsonObject.value("game_id").toString()) + , type(jsonObject.value("type").toString()) + , title(jsonObject.value("title").toString()) + , viewerCount(jsonObject.value("viewer_count").toInt()) + , startedAt(jsonObject.value("started_at").toString()) + , language(jsonObject.value("language").toString()) + , thumbnailUrl(jsonObject.value("thumbnail_url").toString()) + { + } +}; + +struct HelixGame { + QString id; // stream id + QString name; + QString boxArtUrl; + + explicit HelixGame(QJsonObject jsonObject) + : id(jsonObject.value("id").toString()) + , name(jsonObject.value("name").toString()) + , boxArtUrl(jsonObject.value("box_art_url").toString()) + { + } +}; + +class Helix final : boost::noncopyable +{ +public: + // https://dev.twitch.tv/docs/api/reference#get-users + void fetchUsers(QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback); + void getUserByName(QString userName, + ResultCallback successCallback, + HelixFailureCallback failureCallback); + void getUserById(QString userId, ResultCallback successCallback, + HelixFailureCallback failureCallback); + + // https://dev.twitch.tv/docs/api/reference#get-users-follows + void fetchUsersFollows( + QString fromId, QString toId, + ResultCallback successCallback, + HelixFailureCallback failureCallback); + + void getUserFollowers( + QString userId, + ResultCallback successCallback, + HelixFailureCallback failureCallback); + + void getUserFollow( + QString userId, QString targetId, + ResultCallback successCallback, + HelixFailureCallback failureCallback); + + // https://dev.twitch.tv/docs/api/reference#get-streams + void fetchStreams(QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback); + + void getStreamById(QString userId, + ResultCallback successCallback, + HelixFailureCallback failureCallback); + + void getStreamByName(QString userName, + ResultCallback successCallback, + HelixFailureCallback failureCallback); + + // https://dev.twitch.tv/docs/api/reference#get-games + void fetchGames(QStringList gameIds, QStringList gameNames, + ResultCallback> successCallback, + HelixFailureCallback failureCallback); + + void getGameById(QString gameId, ResultCallback successCallback, + HelixFailureCallback failureCallback); + + void update(QString clientId, QString oauthToken); + + static void initialize(); + +private: + NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); + + QString clientId; + QString oauthToken; +}; + +Helix *getHelix(); + +} // namespace chatterino diff --git a/src/providers/twitch/api/Kraken.cpp b/src/providers/twitch/api/Kraken.cpp new file mode 100644 index 000000000..be7e98b36 --- /dev/null +++ b/src/providers/twitch/api/Kraken.cpp @@ -0,0 +1,104 @@ +#include "providers/twitch/api/Kraken.hpp" + +#include "common/Outcome.hpp" +#include "providers/twitch/TwitchCommon.hpp" + +namespace chatterino { + +static Kraken *instance = nullptr; + +void Kraken::getChannel(QString userId, + ResultCallback successCallback, + KrakenFailureCallback failureCallback) +{ + assert(!userId.isEmpty()); + + this->makeRequest("channels/" + userId, {}) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + + successCallback(root); + + return Success; + }) + .onError([failureCallback](auto result) { + // TODO: make better xd + failureCallback(); + }) + .execute(); +} + +void Kraken::getUser(QString userId, ResultCallback successCallback, + KrakenFailureCallback failureCallback) +{ + assert(!userId.isEmpty()); + + this->makeRequest("users/" + userId, {}) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + + successCallback(root); + + return Success; + }) + .onError([failureCallback](auto result) { + // TODO: make better xd + failureCallback(); + }) + .execute(); +} + +NetworkRequest Kraken::makeRequest(QString url, QUrlQuery urlQuery) +{ + assert(!url.startsWith("/")); + + if (this->clientId.isEmpty()) + { + qDebug() + << "Kraken::makeRequest called without a client ID set BabyRage"; + } + + const QString baseUrl("https://api.twitch.tv/kraken/"); + + QUrl fullUrl(baseUrl + url); + + fullUrl.setQuery(urlQuery); + + if (!this->oauthToken.isEmpty()) + { + return NetworkRequest(fullUrl) + .timeout(5 * 1000) + .header("Accept", "application/vnd.twitchtv.v5+json") + .header("Client-ID", this->clientId) + .header("Authorization", "OAuth " + this->oauthToken); + } + + return NetworkRequest(fullUrl) + .timeout(5 * 1000) + .header("Accept", "application/vnd.twitchtv.v5+json") + .header("Client-ID", this->clientId); +} + +void Kraken::update(QString clientId, QString oauthToken) +{ + this->clientId = clientId; + this->oauthToken = oauthToken; +} + +void Kraken::initialize() +{ + assert(instance == nullptr); + + instance = new Kraken(); + + getKraken()->update(getDefaultClientID(), ""); +} + +Kraken *getKraken() +{ + assert(instance != nullptr); + + return instance; +} + +} // namespace chatterino diff --git a/src/providers/twitch/api/Kraken.hpp b/src/providers/twitch/api/Kraken.hpp new file mode 100644 index 000000000..047173d6e --- /dev/null +++ b/src/providers/twitch/api/Kraken.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "common/NetworkRequest.hpp" + +#include +#include +#include +#include + +#include + +namespace chatterino { + +using KrakenFailureCallback = std::function; +template +using ResultCallback = std::function; + +struct KrakenChannel { + const QString status; + + KrakenChannel(QJsonObject jsonObject) + : status(jsonObject.value("status").toString()) + { + } +}; + +struct KrakenUser { + const QString createdAt; + + KrakenUser(QJsonObject jsonObject) + : createdAt(jsonObject.value("created_at").toString()) + { + } +}; + +class Kraken final : boost::noncopyable +{ +public: + // https://dev.twitch.tv/docs/v5/reference/users#follow-channel + void getChannel(QString userId, + ResultCallback resultCallback, + KrakenFailureCallback failureCallback); + + // https://dev.twitch.tv/docs/v5/reference/users#get-user-by-id + void getUser(QString userId, ResultCallback resultCallback, + KrakenFailureCallback failureCallback); + + void update(QString clientId, QString oauthToken); + + static void initialize(); + +private: + NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); + + QString clientId; + QString oauthToken; +}; + +Kraken *getKraken(); + +} // namespace chatterino diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md new file mode 100644 index 000000000..a95ac6bcc --- /dev/null +++ b/src/providers/twitch/api/README.md @@ -0,0 +1,125 @@ +# Twitch API +this folder describes what sort of API requests we do, what permissions are required for the requests etc + +## Kraken (V5) +We use a bunch of Kraken (V5) in Chatterino2. + +### Get User +URL: https://dev.twitch.tv/docs/v5/reference/users#get-user-by-id + +Migration path: **Unknown** + + * We implement this in `providers/twitch/api/Kraken.cpp getUser` + Used in: + * `UserInfoPopup` to get the "created at" date of a user + +### Get Channel +URL: https://dev.twitch.tv/docs/v5/reference/channels#get-channel + +Migration path: **Unknown** + + * We implement this in `providers/twitch/api/Kraken.cpp getChannel` + Used in: + * `TwitchChannel::refreshTitle` to check the current stream title/game of offline channels + +### Follow Channel +URL: https://dev.twitch.tv/docs/v5/reference/users#follow-channel +Requires `user_follows_edit` scope + +Migration path: **Unknown** + + * We implement this API in `providers/twitch/TwitchAccount.cpp followUser` + +### Unfollow Channel +URL: https://dev.twitch.tv/docs/v5/reference/users#unfollow-channel +Requires `user_follows_edit` scope + +Migration path: **Unknown** + + * We implement this API in `providers/twitch/TwitchAccount.cpp unfollowUser` + + +### Get Cheermotes +URL: https://dev.twitch.tv/docs/v5/reference/bits#get-cheermotes + +Migration path: **Not checked** + + * We implement this API in `providers/twitch/TwitchChannel.cpp` to resolve a chats available cheer emotes. This helps us parse incoming messages like `pajaCheer1000` + +### Get User Block List +URL: https://dev.twitch.tv/docs/v5/reference/users#get-user-block-list + +Migration path: **Unknown** + + * We use this in `providers/twitch/TwitchAccount.cpp loadIgnores` + +### Block User +URL: https://dev.twitch.tv/docs/v5/reference/users#block-user +Requires `user_blocks_edit` scope + +Migration path: **Unknown** + + * We use this in `providers/twitch/TwitchAccount.cpp ignoreByID` + +### Unblock User +URL: https://dev.twitch.tv/docs/v5/reference/users#unblock-user +Requires `user_blocks_edit` scope + +Migration path: **Unknown** + + * We use this in `providers/twitch/TwitchAccount.cpp unignoreByID` + +### Get User Emotes +URL: https://dev.twitch.tv/docs/v5/reference/users#get-user-emotes +Requires `user_subscriptions` scope + +Migration path: **Unknown** + + * We use this in `providers/twitch/TwitchAccount.cpp loadEmotes` to figure out which emotes a user is allowed to use! + +### AUTOMOD APPROVE +**Unofficial** documentation: https://discuss.dev.twitch.tv/t/allowing-others-aka-bots-to-use-twitchbot-reject/8508/2 + + * We use this in `providers/twitch/TwitchAccount.cpp autoModAllow` to approve an automod deny/allow question + +### AUTOMOD DENY +**Unofficial** documentation: https://discuss.dev.twitch.tv/t/allowing-others-aka-bots-to-use-twitchbot-reject/8508/2 + + * We use this in `providers/twitch/TwitchAccount.cpp autoModDeny` to deny an automod deny/allow question + +## Helix +Full Helix API reference: https://dev.twitch.tv/docs/api/reference + +### Get Users +URL: https://dev.twitch.tv/docs/api/reference#get-users + + * We implement this in `providers/twitch/api/Helix.cpp fetchUsers`. + Used in: + * `UserInfoPopup` to get ID and viewcount of username we clicked + * `CommandController` to power any commands that need to get a user ID + * `Toasts` to get the profile picture of a streamer who just went live + * `TwitchAccount` ignore and unignore features to translate user name to user ID + +### Get Users Follows +URL: https://dev.twitch.tv/docs/api/reference#get-users-follows + + * We implement this in `providers/twitch/api/Helix.cpp fetchUsersFollows` + Used in: + * `UserInfoPopup` to get number of followers a user has + +### Get Streams +URL: https://dev.twitch.tv/docs/api/reference#get-streams + + * We implement this in `providers/twitch/api/Helix.cpp fetchStreams` + Used in: + * `TwitchChannel` to get live status, game, title, and viewer count of a channel + * `NotificationController` to provide notifications for channels you might not have open in Chatterino, but are still interested in getting notifications for + +## TMI +The TMI api is undocumented. + +### Get Chatters +**Undocumented** + + * We use this in `widgets/splits/Split.cpp showViewerList` + * We use this in `providers/twitch/TwitchChannel.cpp refreshChatters` diff --git a/src/singletons/Emotes.hpp b/src/singletons/Emotes.hpp index fc475d752..be7fdc480 100644 --- a/src/singletons/Emotes.hpp +++ b/src/singletons/Emotes.hpp @@ -2,8 +2,6 @@ #include "common/Singleton.hpp" -#define GIF_FRAME_LENGTH 33 - #include "providers/bttv/BttvEmotes.hpp" #include "providers/emoji/Emojis.hpp" #include "providers/ffz/FfzEmotes.hpp" diff --git a/src/singletons/NativeMessaging.cpp b/src/singletons/NativeMessaging.cpp index 4d22f6fa4..94700257f 100644 --- a/src/singletons/NativeMessaging.cpp +++ b/src/singletons/NativeMessaging.cpp @@ -187,8 +187,6 @@ void NativeMessagingServer::ReceiverThread::handleMessage( return; } - qDebug() << root; - if (action == "select") { QString _type = root.value("type").toString(); @@ -200,11 +198,12 @@ void NativeMessagingServer::ReceiverThread::handleMessage( AttachedWindow::GetArgs args; args.winId = root.value("winId").toString(); args.yOffset = root.value("yOffset").toInt(-1); + args.x = root.value("size").toObject().value("x").toInt(-1); args.width = root.value("size").toObject().value("width").toInt(-1); args.height = root.value("size").toObject().value("height").toInt(-1); args.fullscreen = attachFullscreen; - qDebug() << args.width << args.height << args.winId; + qDebug() << args.x << args.width << args.height << args.winId; if (_type.isNull() || args.winId.isNull()) { diff --git a/src/singletons/Settings.cpp b/src/singletons/Settings.cpp index 30ba15ec3..60e38e554 100644 --- a/src/singletons/Settings.cpp +++ b/src/singletons/Settings.cpp @@ -1,19 +1,120 @@ #include "singletons/Settings.hpp" #include "Application.hpp" +#include "controllers/highlights/HighlightBlacklistUser.hpp" +#include "controllers/highlights/HighlightPhrase.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" #include "singletons/Paths.hpp" #include "singletons/Resources.hpp" #include "singletons/WindowManager.hpp" +#include "util/PersistSignalVector.hpp" #include "util/WindowsHelper.hpp" namespace chatterino { +ConcurrentSettings *concurrentInstance_{}; + +ConcurrentSettings::ConcurrentSettings() + // NOTE: these do not get deleted + : highlightedMessages(*new SignalVector()) + , highlightedUsers(*new SignalVector()) + , blacklistedUsers(*new SignalVector()) + , ignoredMessages(*new SignalVector()) + , mutedChannels(*new SignalVector()) + , moderationActions(*new SignalVector) +{ + persist(this->highlightedMessages, "/highlighting/highlights"); + persist(this->blacklistedUsers, "/highlighting/blacklist"); + persist(this->highlightedUsers, "/highlighting/users"); + persist(this->ignoredMessages, "/ignore/phrases"); + persist(this->mutedChannels, "/pings/muted"); + // tagged users? + persist(this->moderationActions, "/moderation/actions"); +} + +bool ConcurrentSettings::isHighlightedUser(const QString &username) +{ + for (const auto &highlightedUser : this->highlightedUsers) + { + if (highlightedUser.isMatch(username)) + return true; + } + + return false; +} + +bool ConcurrentSettings::isBlacklistedUser(const QString &username) +{ + auto items = this->blacklistedUsers.readOnly(); + + for (const auto &blacklistedUser : *items) + { + if (blacklistedUser.isMatch(username)) + return true; + } + + return false; +} + +bool ConcurrentSettings::isMutedChannel(const QString &channelName) +{ + for (const auto &channel : this->mutedChannels) + { + if (channelName.toLower() == channel.toLower()) + { + return true; + } + } + return false; +} + +void ConcurrentSettings::mute(const QString &channelName) +{ + mutedChannels.append(channelName); +} + +void ConcurrentSettings::unmute(const QString &channelName) +{ + for (std::vector::size_type i = 0; i != mutedChannels.raw().size(); + i++) + { + if (mutedChannels.raw()[i].toLower() == channelName.toLower()) + { + mutedChannels.removeAt(i); + i--; + } + } +} + +bool ConcurrentSettings::toggleMutedChannel(const QString &channelName) +{ + if (this->isMutedChannel(channelName)) + { + unmute(channelName); + return false; + } + else + { + mute(channelName); + return true; + } +} + +ConcurrentSettings &getCSettings() +{ + // `concurrentInstance_` gets assigned in Settings ctor. + assert(concurrentInstance_); + + return *concurrentInstance_; +} + Settings *Settings::instance_ = nullptr; Settings::Settings(const QString &settingsDirectory) : ABSettings(settingsDirectory) { instance_ = this; + concurrentInstance_ = this; #ifdef USEWINSDK this->autorun = isRegisteredForStartup(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index f0d1679d4..869fcdf0e 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -5,13 +5,47 @@ #include "BaseSettings.hpp" #include "common/Channel.hpp" +#include "common/SignalVector.hpp" #include "controllers/highlights/HighlightPhrase.hpp" #include "controllers/moderationactions/ModerationAction.hpp" #include "singletons/Toasts.hpp" namespace chatterino { -class Settings : public ABSettings +class HighlightPhrase; +class HighlightBlacklistUser; +class IgnorePhrase; +class TaggedUser; + +/// Settings which are availlable for reading on all threads. +class ConcurrentSettings +{ +public: + ConcurrentSettings(); + + SignalVector &highlightedMessages; + SignalVector &highlightedUsers; + SignalVector &blacklistedUsers; + SignalVector &ignoredMessages; + SignalVector &mutedChannels; + //SignalVector &taggedUsers; + SignalVector &moderationActions; + + bool isHighlightedUser(const QString &username); + bool isBlacklistedUser(const QString &username); + bool isMutedChannel(const QString &channelName); + bool toggleMutedChannel(const QString &channelName); + +private: + void mute(const QString &channelName); + void unmute(const QString &channelName); +}; + +ConcurrentSettings &getCSettings(); + +/// Settings which are availlable for reading and writing on the gui thread. +// These settings are still accessed concurrently in the code but it is bad practice. +class Settings : public ABSettings, public ConcurrentSettings { static Settings *instance_; @@ -60,6 +94,8 @@ public: BoolSetting enableSmoothScrollingNewMessages = { "/appearance/smoothScrollingNewMessages", false}; BoolSetting boldUsernames = {"/appearance/messages/boldUsernames", false}; + BoolSetting findAllUsernames = {"/appearance/messages/findAllUsernames", + false}; // BoolSetting customizable splitheader BoolSetting headerViewerCount = {"/appearance/splitheader/showViewerCount", false}; @@ -90,6 +126,7 @@ public: BoolSetting showParts = {"/behaviour/showParts", false}; FloatSetting mouseScrollMultiplier = {"/behaviour/mouseScrollMultiplier", 1.0}; + BoolSetting autoCloseUserPopup = {"/behaviour/autoCloseUserPopup", true}; // BoolSetting twitchSeperateWriteConnection = // {"/behaviour/twitchSeperateWriteConnection", false}; @@ -100,6 +137,8 @@ public: "/behaviour/autocompletion/smallStreamerLimit", 1000}; BoolSetting prefixOnlyEmoteCompletion = { "/behaviour/autocompletion/prefixOnlyCompletion", true}; + BoolSetting userCompletionOnlyWithAt = { + "/behaviour/autocompletion/userCompletionOnlyWithAt", false}; FloatSetting pauseOnHoverDuration = {"/behaviour/pauseOnHoverDuration", 0}; EnumSetting pauseChatModifier = { @@ -125,6 +164,8 @@ public: /// Links BoolSetting linksDoubleClickOnly = {"/links/doubleClickToOpen", false}; BoolSetting linkInfoTooltip = {"/links/linkInfoTooltip", false}; + IntSetting thumbnailSize = {"/appearance/thumbnailSize", 0}; + IntSetting thumbnailSizeStream = {"/appearance/thumbnailSizeStream", 2}; BoolSetting unshortLinks = {"/links/unshortLinks", false}; BoolSetting lowercaseDomains = {"/links/linkLowercase", true}; @@ -169,6 +210,17 @@ public: QStringSetting whisperHighlightColor = { "/highlighting/whisperHighlightColor", ""}; + BoolSetting enableRedeemedHighlight = { + "/highlighting/redeemedHighlight/highlighted", true}; + // BoolSetting enableRedeemedHighlightSound = { + // "/highlighting/redeemedHighlight/enableSound", false}; + // BoolSetting enableRedeemedHighlightTaskbar = { + // "/highlighting/redeemedHighlight/enableTaskbarFlashing", false}; + QStringSetting redeemedHighlightSoundUrl = { + "/highlighting/redeemedHighlightSoundUrl", ""}; + QStringSetting redeemedHighlightColor = { + "/highlighting/redeemedHighlightColor", ""}; + BoolSetting enableSubHighlight = { "/highlighting/subHighlight/subsHighlighted", true}; BoolSetting enableSubHighlightSound = { @@ -221,6 +273,20 @@ public: "Choose"}; QStringSetting streamlinkOpts = {"/external/streamlink/options", ""}; + // Custom URI Scheme + QStringSetting customURIScheme = {"/external/urischeme"}; + + // Image Uploader + QStringSetting imageUploaderUrl = {"/external/imageUploader/url", + "https://i.nuuls.com/upload"}; + QStringSetting imageUploaderFormField = { + "/external/imageUploader/formField", "attachment"}; + QStringSetting imageUploaderHeaders = {"/external/imageUploader/headers", + ""}; + QStringSetting imageUploaderLink = {"/external/imageUploader/link", ""}; + QStringSetting imageUploaderDeletionLink = { + "/external/imageUploader/deletionLink", ""}; + /// Misc BoolSetting betaUpdates = {"/misc/beta", false}; #ifdef Q_OS_LINUX @@ -239,6 +305,9 @@ public: BoolSetting restartOnCrash = {"/misc/restartOnCrash", false}; BoolSetting attachExtensionToAnyProcess = { "/misc/attachExtensionToAnyProcess", false}; + BoolSetting hideViewerCountAndDuration = { + "/misc/hideViewerCountAndDuration", false}; + BoolSetting askOnImageUpload = {"/misc/askOnImageUpload", true}; /// Debug BoolSetting showUnhandledIrcMessages = {"/debug/showUnhandledIrcMessages", diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index 1876126e2..0411e860e 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -48,8 +48,6 @@ void Theme::actuallyUpdate(double hue, double multiplier) this->splits.resizeHandleBackground = QColor(0, 148, 255, 0x20); } - this->messages.backgrounds.highlighted = QColor(140, 84, 89, 127); - this->splits.header.background = getColor(0, sat, flat ? 1 : 0.9); this->splits.header.border = getColor(0, sat, flat ? 1 : 0.85); this->splits.header.text = this->messages.textColors.regular; @@ -74,15 +72,6 @@ void Theme::actuallyUpdate(double hue, double multiplier) this->splits.background = getColor(0, sat, 1); this->splits.dropPreview = QColor(0, 148, 255, 0x30); this->splits.dropPreviewBorder = QColor(0, 148, 255, 0xff); - - // Highlighted Messages - // hidden setting from PR #744 - if set it will overwrite theme color - // TODO: implement full theme support - if (getSettings()->highlightColor != "") - { - this->messages.backgrounds.highlighted = - QColor(getSettings()->highlightColor.getValue()); - } } void Theme::normalizeColor(QColor &color) diff --git a/src/singletons/Toasts.cpp b/src/singletons/Toasts.cpp index def5df00f..1f627a22e 100644 --- a/src/singletons/Toasts.cpp +++ b/src/singletons/Toasts.cpp @@ -7,6 +7,7 @@ #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "providers/twitch/api/Helix.hpp" #include "singletons/Paths.hpp" #include "util/StreamLink.hpp" #include "widgets/helper/CommonTexts.hpp" @@ -85,14 +86,17 @@ void Toasts::sendChannelNotification(const QString &channelName, Platform p) } else { - this->fetchChannelAvatar( + getHelix()->getUserByName( channelName, - [channelName, sendChannelNotification](QString avatarLink) { + [channelName, sendChannelNotification](const auto &user) { DownloadManager *manager = new DownloadManager(); - manager->setFile(avatarLink, channelName); + manager->setFile(user.profileImageUrl, channelName); manager->connect(manager, &DownloadManager::downloadComplete, sendChannelNotification); + }, + [] { + // on failure }); } } @@ -130,7 +134,9 @@ public: case ToastReaction::OpenInPlayer: if (platform_ == Platform::Twitch) { - link = "https://player.twitch.tv/?channel=" + channelName_; + link = + "https://player.twitch.tv/?parent=twitch.tv&channel=" + + channelName_; } QDesktopServices::openUrl(QUrl(link)); break; @@ -206,53 +212,4 @@ void Toasts::sendWindowsNotification(const QString &channelName, Platform p) #endif -void Toasts::fetchChannelAvatar(const QString channelName, - std::function successCallback) -{ - QString requestUrl("https://api.twitch.tv/kraken/users?login=" + - channelName); - - NetworkRequest(requestUrl) - - .authorizeTwitchV5(getDefaultClientID()) - .timeout(30000) - .onSuccess([successCallback](auto result) mutable -> Outcome { - auto root = result.parseJson(); - if (!root.value("users").isArray()) - { - // log("API Error while getting user id, users is not an array"); - successCallback(""); - return Failure; - } - auto users = root.value("users").toArray(); - if (users.size() != 1) - { - // log("API Error while getting user id, users array size is not - // 1"); - successCallback(""); - return Failure; - } - if (!users[0].isObject()) - { - // log("API Error while getting user id, first user is not an - // object"); - successCallback(""); - return Failure; - } - auto firstUser = users[0].toObject(); - auto avatar = firstUser.value("logo"); - if (!avatar.isString()) - { - // log("API Error: while getting user avatar, first user object " - // "`avatar` key " - // "is not a " - // "string"); - successCallback(""); - return Failure; - } - successCallback(avatar.toString()); - return Success; - }) - .execute(); -} } // namespace chatterino diff --git a/src/singletons/Toasts.hpp b/src/singletons/Toasts.hpp index 43c6d1da8..5ef792c77 100644 --- a/src/singletons/Toasts.hpp +++ b/src/singletons/Toasts.hpp @@ -29,9 +29,5 @@ private: #ifdef Q_OS_WIN void sendWindowsNotification(const QString &channelName, Platform p); #endif - - static void fetchChannelAvatar( - const QString channelName, - std::function successCallback); }; } // namespace chatterino diff --git a/src/singletons/TooltipPreviewImage.cpp b/src/singletons/TooltipPreviewImage.cpp index ebb0fe6a6..2885d9341 100644 --- a/src/singletons/TooltipPreviewImage.cpp +++ b/src/singletons/TooltipPreviewImage.cpp @@ -38,6 +38,14 @@ void TooltipPreviewImage::setImage(ImagePtr image) this->refreshTooltipWidgetPixmap(); } +void TooltipPreviewImage::setImageScale(int w, int h) +{ + this->imageWidth_ = w; + this->imageHeight_ = h; + + this->refreshTooltipWidgetPixmap(); +} + void TooltipPreviewImage::refreshTooltipWidgetPixmap() { auto tooltipWidget = TooltipWidget::instance(); @@ -46,7 +54,17 @@ void TooltipPreviewImage::refreshTooltipWidgetPixmap() { if (auto pixmap = this->image_->pixmapOrLoad()) { - tooltipWidget->setImage(*pixmap); + if (this->imageWidth_ != 0 && this->imageHeight_) + { + tooltipWidget->setImage(pixmap->scaled(this->imageWidth_, + this->imageHeight_, + Qt::KeepAspectRatio)); + } + else + { + tooltipWidget->setImage(*pixmap); + } + this->attemptRefresh = false; } else diff --git a/src/singletons/TooltipPreviewImage.hpp b/src/singletons/TooltipPreviewImage.hpp index 0d1f6f070..aeccdf9d4 100644 --- a/src/singletons/TooltipPreviewImage.hpp +++ b/src/singletons/TooltipPreviewImage.hpp @@ -9,6 +9,7 @@ class TooltipPreviewImage public: static TooltipPreviewImage &instance(); void setImage(ImagePtr image); + void setImageScale(int w, int h); TooltipPreviewImage(const TooltipPreviewImage &) = delete; @@ -17,6 +18,9 @@ private: private: ImagePtr image_ = nullptr; + int imageWidth_ = 0; + int imageHeight_ = 0; + std::vector connections_; // attemptRefresh is set to true in case we want to preview an image that has not loaded yet (if pixmapOrLoad fails) diff --git a/src/singletons/Updates.cpp b/src/singletons/Updates.cpp index 8f1054982..3b6ca327f 100644 --- a/src/singletons/Updates.cpp +++ b/src/singletons/Updates.cpp @@ -259,7 +259,7 @@ void Updates::checkForUpdates() } #if defined Q_OS_WIN || defined Q_OS_MACOS - /// Windows downloads an installer for the new version + /// Downloads an installer for the new version QJsonValue updateExe_val = object.value("updateexe"); if (!updateExe_val.isString()) { @@ -268,7 +268,8 @@ void Updates::checkForUpdates() return Failure; } this->updateExe_ = updateExe_val.toString(); - +#endif +#ifdef Q_OS_WIN /// Windows portable QJsonValue portable_val = object.value("portable_download"); if (!portable_val.isString()) @@ -278,14 +279,15 @@ void Updates::checkForUpdates() return Failure; } this->updatePortable_ = portable_val.toString(); - -#elif defined Q_OS_LINUX +#endif +#ifdef Q_OS_LINUX QJsonValue updateGuide_val = object.value("updateguide"); if (updateGuide_val.isString()) { this->updateGuideLink_ = updateGuide_val.toString(); } -#else +#endif +#if !defined(Q_OS_WIN) && !defined(Q_OS_MAC) && !defined(Q_OS_LINUX) return Failure; #endif diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index fb1a28a3b..53ed0fad4 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -171,6 +171,7 @@ void WindowManager::updateWordTypeMask() : MEF::NonBoldUsername); flags.set(settings->lowercaseDomains ? MEF::LowercaseLink : MEF::OriginalLink); + flags.set(MEF::ChannelPointReward); // update flags MessageElementFlags newFlags = static_cast(flags); @@ -274,6 +275,16 @@ Window *WindowManager::windowAt(int index) return this->windows_.at(index); } +QPoint WindowManager::emotePopupPos() +{ + return this->emotePopupPos_; +} + +void WindowManager::setEmotePopupPos(QPoint pos) +{ + this->emotePopupPos_ = pos; +} + void WindowManager::initialize(Settings &settings, Paths &paths) { assertInGuiThread(); @@ -401,6 +412,10 @@ void WindowManager::initialize(Settings &settings, Paths &paths) } window.show(); + QJsonObject emote_popup_obj = window_obj.value("emotePopup").toObject(); + this->emotePopupPos_ = QPoint(emote_popup_obj.value("x").toInt(), + emote_popup_obj.value("y").toInt()); + if (window_obj.value("state") == "minimized") { window.setWindowState(Qt::WindowMinimized); @@ -431,6 +446,8 @@ void WindowManager::initialize(Settings &settings, Paths &paths) [this](auto, auto) { this->forceLayoutChannelViews(); }); settings.collpseMessagesMinLines.connect( [this](auto, auto) { this->forceLayoutChannelViews(); }); + settings.enableRedeemedHighlight.connect( + [this](auto, auto) { this->forceLayoutChannelViews(); }); this->initialized_ = true; } @@ -478,6 +495,11 @@ void WindowManager::save() window_obj.insert("width", rect.width()); window_obj.insert("height", rect.height()); + QJsonObject emote_popup_obj; + emote_popup_obj.insert("x", this->emotePopupPos_.x()); + emote_popup_obj.insert("y", this->emotePopupPos_.y()); + window_obj.insert("emotePopup", emote_popup_obj); + // window tabs QJsonArray tabs_arr; diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 9dfd8d9bb..7ffe52e11 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -51,6 +51,9 @@ public: int windowCount(); Window *windowAt(int index); + QPoint emotePopupPos(); + void setEmotePopupPos(QPoint pos); + virtual void initialize(Settings &settings, Paths &paths) override; virtual void save() override; void closeAll(); @@ -89,6 +92,8 @@ private: bool initialized_ = false; + QPoint emotePopupPos_; + std::atomic generation_{0}; std::vector windows_; diff --git a/src/singletons/helper/GifTimer.cpp b/src/singletons/helper/GifTimer.cpp index 28e1b77ed..2edf7bacd 100644 --- a/src/singletons/helper/GifTimer.cpp +++ b/src/singletons/helper/GifTimer.cpp @@ -22,6 +22,7 @@ void GIFTimer::initialize() qApp->activeWindow() == nullptr) return; + this->position_ += gifFrameLength; this->signal.invoke(); getApp()->windows->repaintGifEmotes(); }); diff --git a/src/singletons/helper/GifTimer.hpp b/src/singletons/helper/GifTimer.hpp index b44fcfb7f..da47149b8 100644 --- a/src/singletons/helper/GifTimer.hpp +++ b/src/singletons/helper/GifTimer.hpp @@ -5,15 +5,22 @@ namespace chatterino { +constexpr long unsigned gifFrameLength = 33; + class GIFTimer { public: void initialize(); pajlada::Signals::NoArgSignal signal; + long unsigned position() + { + return this->position_; + } private: QTimer timer; + long unsigned position_{}; }; } // namespace chatterino diff --git a/src/util/FormatTime.cpp b/src/util/FormatTime.cpp index 3f2b4f753..ba66031ba 100644 --- a/src/util/FormatTime.cpp +++ b/src/util/FormatTime.cpp @@ -50,4 +50,16 @@ QString formatTime(int totalSeconds) return res; } +QString formatTime(QString totalSecondsString) +{ + bool ok = true; + int totalSeconds(totalSecondsString.toInt(&ok)); + if (ok) + { + return formatTime(totalSeconds); + } + + return "n/a"; +} + } // namespace chatterino diff --git a/src/util/FormatTime.hpp b/src/util/FormatTime.hpp index fd5a2d582..0e4eb2725 100644 --- a/src/util/FormatTime.hpp +++ b/src/util/FormatTime.hpp @@ -6,5 +6,6 @@ namespace chatterino { // format: 1h 23m 42s QString formatTime(int totalSeconds); +QString formatTime(QString totalSecondsString); } // namespace chatterino diff --git a/src/util/IncognitoBrowser.cpp b/src/util/IncognitoBrowser.cpp index 216c6f8c5..460f8fcfa 100644 --- a/src/util/IncognitoBrowser.cpp +++ b/src/util/IncognitoBrowser.cpp @@ -61,8 +61,6 @@ namespace { if (command.isNull()) return QString(); - qDebug() << command; - // inject switch to enable private browsing command = injectPrivateSwitch(command); if (command.isNull()) diff --git a/src/util/InitUpdateButton.cpp b/src/util/InitUpdateButton.cpp index 826c8feb8..4c1de6b89 100644 --- a/src/util/InitUpdateButton.cpp +++ b/src/util/InitUpdateButton.cpp @@ -14,8 +14,17 @@ void initUpdateButton(Button &button, QObject::connect(&button, &Button::leftClicked, [&button] { auto dialog = new UpdateDialog(); dialog->setActionOnFocusLoss(BaseWindow::Delete); - dialog->move(button.mapToGlobal( - QPoint(int(-100 * button.scale()), button.height()))); + + auto globalPoint = button.mapToGlobal( + QPoint(int(-100 * button.scale()), button.height())); + + // Make sure that update dialog will not go off left edge of screen + if (globalPoint.x() < 0) + { + globalPoint.setX(0); + } + + dialog->move(globalPoint); dialog->show(); dialog->raise(); diff --git a/src/util/NuulsUploader.cpp b/src/util/NuulsUploader.cpp new file mode 100644 index 000000000..997e354b4 --- /dev/null +++ b/src/util/NuulsUploader.cpp @@ -0,0 +1,329 @@ +#include "NuulsUploader.hpp" + +#include "common/Env.hpp" +#include "common/NetworkRequest.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "singletons/Paths.hpp" +#include "singletons/Settings.hpp" +#include "util/CombinePath.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#define UPLOAD_DELAY 2000 +// Delay between uploads in milliseconds + +namespace { + +boost::optional convertToPng(QImage image) +{ + QByteArray imageData; + QBuffer buf(&imageData); + buf.open(QIODevice::WriteOnly); + bool success = image.save(&buf, "png"); + if (success) + { + return boost::optional(imageData); + } + else + { + return boost::optional(boost::none); + } +} +} // namespace + +namespace chatterino { +// These variables are only used from the main thread. +static auto uploadMutex = QMutex(); +static std::queue uploadQueue; + +// logging information on successful uploads to a json file +void logToFile(const QString originalFilePath, QString imageLink, + QString deletionLink, ChannelPtr channel) +{ + const QString logFileName = + combinePath((getSettings()->logPath.getValue().isEmpty() + ? getPaths()->messageLogDirectory + : getSettings()->logPath), + "ImageUploader.json"); + + //reading existing logs + QFile logReadFile(logFileName); + bool isLogFileOkay = + logReadFile.open(QIODevice::ReadWrite | QIODevice::Text); + if (!isLogFileOkay) + { + channel->addMessage(makeSystemMessage( + QString("Failed to open log file with links at ") + logFileName)); + return; + } + auto logs = logReadFile.readAll(); + if (logs.isEmpty()) + { + logs = QJsonDocument(QJsonArray()).toJson(); + } + logReadFile.close(); + + //writing new data to logs + QJsonObject newLogEntry; + newLogEntry["channelName"] = channel->getName(); + newLogEntry["deletionLink"] = + deletionLink.isEmpty() ? QJsonValue(QJsonValue::Null) : deletionLink; + newLogEntry["imageLink"] = imageLink; + newLogEntry["localPath"] = originalFilePath.isEmpty() + ? QJsonValue(QJsonValue::Null) + : originalFilePath; + newLogEntry["timestamp"] = QDateTime::currentSecsSinceEpoch(); + // channel name + // deletion link (can be empty) + // image link + // local path to an image (can be empty) + // timestamp + QSaveFile logSaveFile(logFileName); + logSaveFile.open(QIODevice::WriteOnly | QIODevice::Text); + QJsonArray entries = QJsonDocument::fromJson(logs).array(); + entries.push_back(newLogEntry); + logSaveFile.write(QJsonDocument(entries).toJson()); + logSaveFile.commit(); +} + +// extracting link to either image or its deletion from response body +QString getJSONValue(QJsonValue responseJson, QString jsonPattern) +{ + for (const QString &key : jsonPattern.split(".")) + { + responseJson = responseJson[key]; + } + return responseJson.toString(); +} + +QString getLinkFromResponse(NetworkResult response, QString pattern) +{ + QRegExp regExp("\\{(.+)\\}"); + regExp.setMinimal(true); + while (regExp.indexIn(pattern) != -1) + { + pattern.replace(regExp.cap(0), + getJSONValue(response.parseJson(), regExp.cap(1))); + } + return pattern; +} + +void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, + ResizingTextEdit &textEdit) +{ + const static char *const boundary = "thisistheboudaryasd"; + const static QString contentType = + QString("multipart/form-data; boundary=%1").arg(boundary); + QUrl url(getSettings()->imageUploaderUrl.getValue().isEmpty() + ? getSettings()->imageUploaderUrl.getDefaultValue() + : getSettings()->imageUploaderUrl); + QString formField( + getSettings()->imageUploaderFormField.getValue().isEmpty() + ? getSettings()->imageUploaderFormField.getDefaultValue() + : getSettings()->imageUploaderFormField); + QStringList extraHeaders( + getSettings()->imageUploaderHeaders.getValue().split(";")); + QString originalFilePath = imageData.filePath; + + QHttpMultiPart *payload = new QHttpMultiPart(QHttpMultiPart::FormDataType); + QHttpPart part = QHttpPart(); + part.setBody(imageData.data); + part.setHeader(QNetworkRequest::ContentTypeHeader, + QString("image/%1").arg(imageData.format)); + part.setHeader(QNetworkRequest::ContentLengthHeader, + QVariant(imageData.data.length())); + part.setHeader(QNetworkRequest::ContentDispositionHeader, + QString("form-data; name=\"%1\"; filename=\"control_v.%2\"") + .arg(formField) + .arg(imageData.format)); + payload->setBoundary(boundary); + payload->append(part); + + NetworkRequest(url, NetworkRequestType::Post) + .header("Content-Type", contentType) + .headerList(extraHeaders) + .multiPart(payload) + .onSuccess([&textEdit, channel, + originalFilePath](NetworkResult result) -> Outcome { + QString link = getSettings()->imageUploaderLink.getValue().isEmpty() + ? result.getData() + : getLinkFromResponse( + result, getSettings()->imageUploaderLink); + QString deletionLink = + getSettings()->imageUploaderDeletionLink.getValue().isEmpty() + ? "" + : getLinkFromResponse( + result, getSettings()->imageUploaderDeletionLink); + qDebug() << link << deletionLink; + textEdit.insertPlainText(link + " "); + if (uploadQueue.empty()) + { + channel->addMessage(makeSystemMessage( + QString("Your image has been uploaded to %1 %2.") + .arg(link) + .arg(deletionLink.isEmpty() + ? "" + : QString("(Deletion link: %1 )") + .arg(deletionLink)))); + uploadMutex.unlock(); + } + else + { + channel->addMessage(makeSystemMessage( + QString("Your image has been uploaded to %1 %2. %3 left. " + "Please wait until all of them are uploaded. " + "About %4 seconds left.") + .arg(link) + .arg(deletionLink.isEmpty() + ? "" + : QString("(Deletion link: %1 )") + .arg(deletionLink)) + .arg(uploadQueue.size()) + .arg(uploadQueue.size() * (UPLOAD_DELAY / 1000 + 1)))); + // 2 seconds for the timer that's there not to spam the remote server + // and 1 second of actual uploading. + + QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit]() { + uploadImageToNuuls(uploadQueue.front(), channel, textEdit); + uploadQueue.pop(); + }); + } + + logToFile(originalFilePath, link, deletionLink, channel); + + return Success; + }) + .onError([channel](NetworkResult result) -> bool { + channel->addMessage(makeSystemMessage( + QString("An error happened while uploading your image: %1") + .arg(result.status()))); + uploadMutex.unlock(); + return true; + }) + .execute(); +} + +void upload(const QMimeData *source, ChannelPtr channel, + ResizingTextEdit &outputTextEdit) +{ + if (!uploadMutex.tryLock()) + { + channel->addMessage(makeSystemMessage( + QString("Please wait until the upload finishes."))); + return; + } + + channel->addMessage(makeSystemMessage(QString("Started upload..."))); + if (source->hasUrls()) + { + auto mimeDb = QMimeDatabase(); + // This path gets chosen when files are copied from a file manager, like explorer.exe, caja. + // Each entry in source->urls() is a QUrl pointing to a file that was copied. + for (const QUrl &path : source->urls()) + { + QString localPath = path.toLocalFile(); + QMimeType mime = mimeDb.mimeTypeForUrl(path); + if (mime.name().startsWith("image") && !mime.inherits("image/gif")) + { + channel->addMessage(makeSystemMessage( + QString("Uploading image: %1").arg(localPath))); + QImage img = QImage(localPath); + if (img.isNull()) + { + channel->addMessage( + makeSystemMessage(QString("Couldn't load image :("))); + uploadMutex.unlock(); + return; + } + + boost::optional imageData = convertToPng(img); + if (imageData) + { + RawImageData data = {imageData.get(), "png", localPath}; + uploadQueue.push(data); + } + else + { + channel->addMessage(makeSystemMessage( + QString("Cannot upload file: %1. Couldn't convert " + "image to png.") + .arg(localPath))); + uploadMutex.unlock(); + return; + } + } + else if (mime.inherits("image/gif")) + { + channel->addMessage(makeSystemMessage( + QString("Uploading GIF: %1").arg(localPath))); + QFile file(localPath); + bool isOkay = file.open(QIODevice::ReadOnly); + if (!isOkay) + { + channel->addMessage( + makeSystemMessage(QString("Failed to open file. :("))); + uploadMutex.unlock(); + return; + } + RawImageData data = {file.readAll(), "gif", localPath}; + uploadQueue.push(data); + file.close(); + // file.readAll() => might be a bit big but it /should/ work + } + else + { + channel->addMessage(makeSystemMessage( + QString("Cannot upload file: %1. Not an image.") + .arg(localPath))); + uploadMutex.unlock(); + return; + } + } + if (!uploadQueue.empty()) + { + uploadImageToNuuls(uploadQueue.front(), channel, outputTextEdit); + uploadQueue.pop(); + } + } + else if (source->hasFormat("image/png")) + { + // the path to file is not present every time, thus the filePath is empty + uploadImageToNuuls({source->data("image/png"), "png", ""}, channel, + outputTextEdit); + } + else if (source->hasFormat("image/jpeg")) + { + uploadImageToNuuls({source->data("image/jpeg"), "jpeg", ""}, channel, + outputTextEdit); + } + else if (source->hasFormat("image/gif")) + { + uploadImageToNuuls({source->data("image/gif"), "gif", ""}, channel, + outputTextEdit); + } + + else + { // not PNG, try loading it into QImage and save it to a PNG. + QImage image = qvariant_cast(source->imageData()); + boost::optional imageData = convertToPng(image); + if (imageData) + { + uploadImageToNuuls({imageData.get(), "png", ""}, channel, + outputTextEdit); + } + else + { + channel->addMessage(makeSystemMessage( + QString("Cannot upload file, failed to convert to png."))); + uploadMutex.unlock(); + } + } +} + +} // namespace chatterino diff --git a/src/util/NuulsUploader.hpp b/src/util/NuulsUploader.hpp new file mode 100644 index 000000000..6339d0dae --- /dev/null +++ b/src/util/NuulsUploader.hpp @@ -0,0 +1,20 @@ +#include "common/Channel.hpp" +#include "widgets/helper/ResizingTextEdit.hpp" + +#include +#include + +namespace chatterino { +struct RawImageData { + QByteArray data; + QString format; + QString filePath; +}; + +void upload(QByteArray imageData, ChannelPtr channel, + ResizingTextEdit &textEdit, std::string format); +void upload(RawImageData imageData, ChannelPtr channel, + ResizingTextEdit &textEdit); +void upload(const QMimeData *source, ChannelPtr channel, + ResizingTextEdit &outputTextEdit); +} // namespace chatterino diff --git a/src/util/PersistSignalVector.hpp b/src/util/PersistSignalVector.hpp new file mode 100644 index 000000000..b1dde7786 --- /dev/null +++ b/src/util/PersistSignalVector.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include "common/ChatterinoSetting.hpp" +#include "common/SignalVector.hpp" + +namespace chatterino { + +template +inline void persist(SignalVector &vec, const std::string &name) +{ + auto setting = std::make_unique>>(name); + + for (auto &&item : setting->getValue()) + vec.append(item); + + vec.delayedItemsChanged.connect([setting = setting.get(), vec = &vec] { + setting->setValue(vec->raw()); + }); + + // TODO: Delete when appropriate. + setting.release(); +} + +} // namespace chatterino diff --git a/src/util/RapidjsonHelpers.cpp b/src/util/RapidjsonHelpers.cpp index 2a9742df2..b75f9ca4f 100644 --- a/src/util/RapidjsonHelpers.cpp +++ b/src/util/RapidjsonHelpers.cpp @@ -28,5 +28,22 @@ namespace rj { return std::string(buffer.GetString()); } + bool getSafeObject(rapidjson::Value &obj, const char *key, + rapidjson::Value &out) + { + if (!checkJsonValue(obj, key)) + { + return false; + } + + out = obj[key].Move(); + return true; + } + + bool checkJsonValue(const rapidjson::Value &obj, const char *key) + { + return obj.IsObject() && !obj.IsNull() && obj.HasMember(key); + } + } // namespace rj } // namespace chatterino diff --git a/src/util/RapidjsonHelpers.hpp b/src/util/RapidjsonHelpers.hpp index 84a4b8f7e..6088b44ca 100644 --- a/src/util/RapidjsonHelpers.hpp +++ b/src/util/RapidjsonHelpers.hpp @@ -67,20 +67,12 @@ namespace rj { arr.PushBack(pajlada::Serialize::get(value, a), a); } + bool checkJsonValue(const rapidjson::Value &obj, const char *key); + template bool getSafe(const rapidjson::Value &obj, const char *key, Type &out) { - if (!obj.IsObject()) - { - return false; - } - - if (!obj.HasMember(key)) - { - return false; - } - - if (obj.IsNull()) + if (!checkJsonValue(obj, key)) { return false; } @@ -100,6 +92,9 @@ namespace rj { return !error; } + bool getSafeObject(rapidjson::Value &obj, const char *key, + rapidjson::Value &out); + std::string stringify(const rapidjson::Value &value); } // namespace rj diff --git a/src/util/StandardItemHelper.hpp b/src/util/StandardItemHelper.hpp index 4b7ba52b9..f9caeb0a6 100644 --- a/src/util/StandardItemHelper.hpp +++ b/src/util/StandardItemHelper.hpp @@ -4,12 +4,20 @@ namespace chatterino { +static auto defaultItemFlags(bool selectable) +{ + return Qt::ItemIsEnabled | + (selectable ? Qt::ItemIsSelectable | Qt::ItemIsDragEnabled | + Qt::ItemIsDropEnabled + : Qt::ItemFlag()); +} + static void setBoolItem(QStandardItem *item, bool value, bool userCheckable = true, bool selectable = true) { - item->setFlags(Qt::ItemFlags( - Qt::ItemIsEnabled | (selectable ? Qt::ItemIsSelectable : 0) | - (userCheckable ? Qt::ItemIsUserCheckable : 0))); + item->setFlags( + Qt::ItemFlags(defaultItemFlags(selectable) | + (userCheckable ? Qt::ItemIsUserCheckable : 0))); item->setCheckState(value ? Qt::Checked : Qt::Unchecked); } @@ -17,28 +25,33 @@ static void setStringItem(QStandardItem *item, const QString &value, bool editable = true, bool selectable = true) { item->setData(value, Qt::EditRole); - item->setFlags(Qt::ItemFlags(Qt::ItemIsEnabled | - (selectable ? Qt::ItemIsSelectable : 0) | + item->setFlags(Qt::ItemFlags(defaultItemFlags(selectable) | (editable ? (Qt::ItemIsEditable) : 0))); } -static void setFilePathItem(QStandardItem *item, const QUrl &value) +static void setFilePathItem(QStandardItem *item, const QUrl &value, + bool selectable = true) { item->setData(value, Qt::UserRole); item->setData(value.fileName(), Qt::DisplayRole); - item->setFlags(Qt::ItemFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable)); + item->setFlags( + Qt::ItemFlags(defaultItemFlags(selectable) | + (selectable ? Qt::ItemIsSelectable : Qt::NoItemFlags))); } -static void setColorItem(QStandardItem *item, const QColor &value) +static void setColorItem(QStandardItem *item, const QColor &value, + bool selectable = true) { item->setData(value, Qt::DecorationRole); - item->setFlags(Qt::ItemFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable)); + item->setFlags( + Qt::ItemFlags(defaultItemFlags(selectable) | + (selectable ? Qt::ItemIsSelectable : Qt::NoItemFlags))); } static QStandardItem *emptyItem() { auto *item = new QStandardItem(); - item->setFlags(Qt::ItemFlags(0)); + item->setFlags(Qt::ItemFlags()); return item; } diff --git a/src/util/StreamerMode.cpp b/src/util/StreamerMode.cpp new file mode 100644 index 000000000..599b5b832 --- /dev/null +++ b/src/util/StreamerMode.cpp @@ -0,0 +1,53 @@ +#include "StreamerMode.hpp" + +#ifdef USEWINSDK +# include + +# include +# include +# pragma comment(lib, "Wtsapi32.lib") +#endif + +namespace chatterino { + +const QStringList &broadcastingBinaries() +{ +#ifdef USEWINSDK + static QStringList bins = {"obs.exe", "obs64.exe"}; +#else + static QStringList bins = {}; +#endif + return bins; +} + +bool isInStreamerMode() +{ +#ifdef USEWINSDK + if (IsWindowsVistaOrGreater()) + { + WTS_PROCESS_INFO *pWPIs = nullptr; + DWORD dwProcCount = 0; + + if (WTSEnumerateProcesses(WTS_CURRENT_SERVER_HANDLE, NULL, 1, &pWPIs, + &dwProcCount)) + { + //Go through all processes retrieved + for (DWORD i = 0; i < dwProcCount; i++) + { + QString processName = QString::fromUtf16( + reinterpret_cast(pWPIs[i].pProcessName)); + + if (broadcastingBinaries().contains(processName)) + return true; + } + } + + if (pWPIs) + WTSFreeMemory(pWPIs); + } +#endif + + return false; +} + +} // namespace chatterino diff --git a/src/util/StreamerMode.hpp b/src/util/StreamerMode.hpp new file mode 100644 index 000000000..10a58295c --- /dev/null +++ b/src/util/StreamerMode.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace chatterino { + +const QStringList &broadcastingBinaries(); +bool isInStreamerMode(); + +} // namespace chatterino diff --git a/src/util/Twitch.cpp b/src/util/Twitch.cpp new file mode 100644 index 000000000..ca1cf62c0 --- /dev/null +++ b/src/util/Twitch.cpp @@ -0,0 +1,12 @@ +#include "util/Twitch.hpp" + +#include + +namespace chatterino { + +void openTwitchUsercard(QString channel, QString username) +{ + QDesktopServices::openUrl("https://www.twitch.tv/popout/" + channel + + "/viewercard/" + username); +} +} // namespace chatterino diff --git a/src/util/Twitch.hpp b/src/util/Twitch.hpp new file mode 100644 index 000000000..7250b79a3 --- /dev/null +++ b/src/util/Twitch.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace chatterino { + +void openTwitchUsercard(const QString channel, const QString username); + +} // namespace chatterino diff --git a/src/widgets/AttachedWindow.cpp b/src/widgets/AttachedWindow.cpp index 04c0dde01..b97abb789 100644 --- a/src/widgets/AttachedWindow.cpp +++ b/src/widgets/AttachedWindow.cpp @@ -29,8 +29,6 @@ BOOL CALLBACK enumWindows(HWND hwnd, LPARAM) auto className = std::make_unique(length); GetClassName(hwnd, className.get(), length); - // qDebug() << QString::fromWCharArray(className.get(), length); - if (lstrcmp(className.get(), L"Shell_TrayWnd") == 0 || lstrcmp(className.get(), L"Shell_Secondary") == 0) { @@ -93,6 +91,8 @@ AttachedWindow *AttachedWindow::get(void *target, const GetArgs &args) window->fullscreen_ = args.fullscreen; + window->x_ = args.x; + if (args.height != -1) { if (args.height == 0) @@ -271,10 +271,21 @@ void AttachedWindow::updateWindowRect(void *_attachedPtr) // offset int o = this->fullscreen_ ? 0 : 8; - ::MoveWindow(hwnd, int(rect.right - this->width_ * scale - o), - int(rect.bottom - this->height_ * scale - o) + 4, - int(this->width_ * scale) - 5, - int(this->height_ * scale) - 5, true); + if (this->x_ != -1) + { + ::MoveWindow(hwnd, int(rect.left + this->x_ * scale + o), + int(rect.bottom - this->height_ * scale - o), + int(this->width_ * scale), int(this->height_ * scale), + true); + } + //support for old extension version 1.2 + else + { + ::MoveWindow(hwnd, int(rect.right - this->width_ * scale - o), + int(rect.bottom - this->height_ * scale - o), + int(this->width_ * scale), int(this->height_ * scale), + true); + } } // if (this->fullscreen_) diff --git a/src/widgets/AttachedWindow.hpp b/src/widgets/AttachedWindow.hpp index 0a910774d..4ecefa11c 100644 --- a/src/widgets/AttachedWindow.hpp +++ b/src/widgets/AttachedWindow.hpp @@ -17,6 +17,7 @@ public: struct GetArgs { QString winId; int yOffset = -1; + int x = -1; int width = -1; int height = -1; bool fullscreen = false; @@ -53,6 +54,7 @@ private: void *target_; int yOffset_; int currentYOffset_; + int x_ = -1; int width_ = 360; int height_ = -1; bool fullscreen_ = false; diff --git a/src/widgets/BaseWidget.cpp b/src/widgets/BaseWidget.cpp index 14c78040d..871c80f40 100644 --- a/src/widgets/BaseWidget.cpp +++ b/src/widgets/BaseWidget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace chatterino { @@ -113,7 +114,8 @@ float BaseWidget::qtFontScale() const { if (auto window = dynamic_cast(this->window())) { - return this->scale() / window->nativeScale_; + // ensure no div by 0 + return this->scale() / std::max(0.01f, window->nativeScale_); } else { diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index b1e939db1..58df75bbf 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -104,12 +104,12 @@ QRect BaseWindow::getBounds() float BaseWindow::scale() const { - return this->overrideScale().value_or(this->scale_); + return std::max(0.01f, this->overrideScale().value_or(this->scale_)); } float BaseWindow::qtFontScale() const { - return this->scale() / this->nativeScale_; + return this->scale() / std::max(0.01, this->nativeScale_); } void BaseWindow::init() @@ -214,9 +214,16 @@ void BaseWindow::init() }); } #else -// if (getSettings()->windowTopMost.getValue()) { -// this->setWindowFlag(Qt::WindowStaysOnTopHint); -// } + // TopMost flag overrides setting + if (!this->flags_.has(TopMost)) + { + getSettings()->windowTopMost.connect( + [this](bool topMost, auto) { + this->setWindowFlag(Qt::WindowStaysOnTopHint, topMost); + this->show(); + }, + this->managedConnections_); + } #endif } @@ -224,7 +231,7 @@ void BaseWindow::setStayInScreenRect(bool value) { this->stayInScreenRect_ = value; - this->moveIntoDesktopRect(this); + this->moveIntoDesktopRect(this, this->pos()); } bool BaseWindow::getStayInScreenRect() const @@ -384,7 +391,6 @@ void BaseWindow::mousePressEvent(QMouseEvent *event) if (!recursiveCheckMouseTracking(widget)) { - qDebug() << "Start moving"; this->moving = true; } } @@ -401,7 +407,6 @@ void BaseWindow::mouseReleaseEvent(QMouseEvent *event) { if (this->moving) { - qDebug() << "Stop moving"; this->moving = false; } } @@ -501,8 +506,7 @@ void BaseWindow::moveTo(QWidget *parent, QPoint point, bool offset) point.ry() += 16; } - this->move(point); - this->moveIntoDesktopRect(parent); + this->moveIntoDesktopRect(parent, point); } void BaseWindow::resizeEvent(QResizeEvent *) @@ -553,44 +557,53 @@ void BaseWindow::closeEvent(QCloseEvent *) void BaseWindow::showEvent(QShowEvent *) { - this->moveIntoDesktopRect(this); + this->moveIntoDesktopRect(this, this->pos()); if (this->frameless_) { - QTimer::singleShot(30, this, - [this] { this->moveIntoDesktopRect(this); }); + QTimer::singleShot( + 30, this, [this] { this->moveIntoDesktopRect(this, this->pos()); }); } } -void BaseWindow::moveIntoDesktopRect(QWidget *parent) +void BaseWindow::moveIntoDesktopRect(QWidget *parent, QPoint point) { if (!this->stayInScreenRect_) return; // move the widget into the screen geometry if it's not already in there QDesktopWidget *desktop = QApplication::desktop(); + QPoint globalCursorPos = QCursor::pos(); QRect s = desktop->availableGeometry(parent); - QPoint p = this->pos(); - if (p.x() < s.left()) + bool stickRight = false; + bool stickBottom = false; + + if (point.x() < s.left()) { - p.setX(s.left()); + point.setX(s.left()); } - if (p.y() < s.top()) + if (point.y() < s.top()) { - p.setY(s.top()); + point.setY(s.top()); } - if (p.x() + this->width() > s.right()) + if (point.x() + this->width() > s.right()) { - p.setX(s.right() - this->width()); + stickRight = true; + point.setX(s.right() - this->width()); } - if (p.y() + this->height() > s.bottom()) + if (point.y() + this->height() > s.bottom()) { - p.setY(s.bottom() - this->height()); + stickBottom = true; + point.setY(s.bottom() - this->height()); } - if (p != this->pos()) - this->move(p); + if (stickRight && stickBottom) + { + point.setY(globalCursorPos.y() - this->height() - 16); + } + + this->move(point); } bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message, diff --git a/src/widgets/BaseWindow.hpp b/src/widgets/BaseWindow.hpp index c9fbf63ba..5d0fe6bd1 100644 --- a/src/widgets/BaseWindow.hpp +++ b/src/widgets/BaseWindow.hpp @@ -90,7 +90,7 @@ protected: private: void init(); - void moveIntoDesktopRect(QWidget *parent); + void moveIntoDesktopRect(QWidget *parent, QPoint point); void calcButtonsSizes(); void drawCustomWindowFrame(QPainter &painter); void onFocusLost(); diff --git a/src/widgets/Label.cpp b/src/widgets/Label.cpp index 330ab8528..e1a839e03 100644 --- a/src/widgets/Label.cpp +++ b/src/widgets/Label.cpp @@ -93,10 +93,12 @@ void Label::paintEvent(QPaintEvent *) QFontMetrics metrics = getFonts()->getFontMetrics( this->getFontStyle(), - this->scale() * 96.f / this->logicalDpiX() * deviceDpi); + this->scale() * 96.f / + std::max(0.01, this->logicalDpiX() * deviceDpi)); painter.setFont(getFonts()->getFont( this->getFontStyle(), - this->scale() * 96.f / this->logicalDpiX() * deviceDpi)); + this->scale() * 96.f / + std::max(0.02, this->logicalDpiX() * deviceDpi))); int offset = this->getOffset(); diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index b0fa87e68..f840cea39 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -149,7 +149,6 @@ void Notebook::select(QWidget *page) { if (containsChild(page, item.selectedWidget)) { - qDebug() << item.selectedWidget; item.selectedWidget->setFocus(Qt::MouseFocusReason); } else diff --git a/src/widgets/Scrollbar.cpp b/src/widgets/Scrollbar.cpp index 9af6c307e..dcf9c3c08 100644 --- a/src/widgets/Scrollbar.cpp +++ b/src/widgets/Scrollbar.cpp @@ -3,6 +3,7 @@ #include "Application.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" +#include "singletons/WindowManager.hpp" #include "widgets/helper/ChannelView.hpp" #include @@ -242,6 +243,8 @@ void Scrollbar::paintEvent(QPaintEvent *) QPainter painter(this); painter.fillRect(rect(), this->theme->scrollbars.background); + bool enableRedeemedHighlights = getSettings()->enableRedeemedHighlight; + // painter.fillRect(QRect(xOffset, 0, width(), this->buttonHeight), // this->themeManager->ScrollbarArrow); // painter.fillRect(QRect(xOffset, height() - this->buttonHeight, @@ -274,7 +277,8 @@ void Scrollbar::paintEvent(QPaintEvent *) int w = this->width(); float y = 0; float dY = float(this->height()) / float(snapshotLength); - int highlightHeight = int(std::ceil(dY)); + int highlightHeight = + int(std::ceil(std::max(this->scale() * 2, dY))); for (size_t i = 0; i < snapshotLength; i++) { @@ -282,22 +286,26 @@ void Scrollbar::paintEvent(QPaintEvent *) if (!highlight.isNull()) { - QColor color = highlight.getColor(); - - switch (highlight.getStyle()) + if (!highlight.isRedeemedHighlight() || enableRedeemedHighlights) { - case ScrollbarHighlight::Default: { - painter.fillRect(w / 8 * 3, int(y), w / 4, highlightHeight, - color); - } - break; + QColor color = highlight.getColor(); + color.setAlpha(255); - case ScrollbarHighlight::Line: { - painter.fillRect(0, int(y), w, 1, color); - } - break; + switch (highlight.getStyle()) + { + case ScrollbarHighlight::Default: { + painter.fillRect(w / 8 * 3, int(y), w / 4, + highlightHeight, color); + } + break; - case ScrollbarHighlight::None:; + case ScrollbarHighlight::Line: { + painter.fillRect(0, int(y), w, 1, color); + } + break; + + case ScrollbarHighlight::None:; + } } } @@ -348,8 +356,10 @@ void Scrollbar::mouseMoveEvent(QMouseEvent *event) { int delta = event->pos().y() - this->lastMousePosition_.y(); - setDesiredValue(this->desiredValue_ + - qreal(delta) / this->trackHeight_ * this->maximum_); + setDesiredValue( + this->desiredValue_ + + (qreal(delta) / std::max(0.00000002, this->trackHeight_)) * + this->maximum_); } this->lastMousePosition_ = event->pos(); @@ -434,13 +444,14 @@ void Scrollbar::updateScroll() this->trackHeight_ = this->height() - this->buttonHeight_ - this->buttonHeight_ - MIN_THUMB_HEIGHT - 1; - this->thumbRect_ = - QRect(0, - int(this->currentValue_ / this->maximum_ * this->trackHeight_) + - 1 + this->buttonHeight_, - this->width(), - int(this->largeChange_ / this->maximum_ * this->trackHeight_) + - MIN_THUMB_HEIGHT); + auto div = std::max(0.0000001, this->maximum_); + + this->thumbRect_ = QRect( + 0, + int(this->currentValue_ / div * this->trackHeight_) + 1 + + this->buttonHeight_, + this->width(), + int(this->largeChange_ / div * this->trackHeight_) + MIN_THUMB_HEIGHT); this->update(); } diff --git a/src/widgets/TooltipWidget.cpp b/src/widgets/TooltipWidget.cpp index d0898b452..b1c4385a9 100644 --- a/src/widgets/TooltipWidget.cpp +++ b/src/widgets/TooltipWidget.cpp @@ -32,9 +32,13 @@ TooltipWidget::TooltipWidget(BaseWidget *parent) this->setStayInScreenRect(true); this->setAttribute(Qt::WA_ShowWithoutActivating); +#ifdef Q_OS_LINUX + this->setWindowFlags(Qt::ToolTip); +#else this->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::X11BypassWindowManagerHint | Qt::BypassWindowManagerHint); +#endif displayImage_->hide(); displayImage_->setAlignment(Qt::AlignHCenter); diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index c291be6c2..415224748 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -25,6 +25,8 @@ #include "widgets/splits/SplitContainer.hpp" #ifdef C_DEBUG +# include +# include "providers/twitch/PubsubClient.hpp" # include "util/SampleCheerMessages.hpp" # include "util/SampleLinks.hpp" #endif @@ -239,6 +241,12 @@ void Window::addDebugStuff() linkMessages.emplace_back(R"(@badge-info=subscriber/48;badges=broadcaster/1,subscriber/36,partner/1;color=#CC44FF;display-name=pajlada;emotes=;flags=;id=3c23cf3c-0864-4699-a76b-089350141147;mod=0;room-id=11148817;subscriber=1;tmi-sent-ts=1577628844607;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada : Links that should pass: )" + getValidLinks().join(' ')); linkMessages.emplace_back(R"(@badge-info=subscriber/48;badges=broadcaster/1,subscriber/36,partner/1;color=#CC44FF;display-name=pajlada;emotes=;flags=;id=3c23cf3c-0864-4699-a76b-089350141147;mod=0;room-id=11148817;subscriber=1;tmi-sent-ts=1577628844607;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada : Links that should NOT pass: )" + getInvalidLinks().join(' ')); linkMessages.emplace_back(R"(@badge-info=subscriber/48;badges=broadcaster/1,subscriber/36,partner/1;color=#CC44FF;display-name=pajlada;emotes=;flags=;id=3c23cf3c-0864-4699-a76b-089350141147;mod=0;room-id=11148817;subscriber=1;tmi-sent-ts=1577628844607;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada : Links that should technically pass but we choose not to parse them: )" + getValidButIgnoredLinks().join(' ')); + + // channel point reward test + const char *channelRewardMessage = "{ \"type\": \"MESSAGE\", \"data\": { \"topic\": \"community-points-channel-v1.11148817\", \"message\": { \"type\": \"reward-redeemed\", \"data\": { \"timestamp\": \"2020-07-13T20:19:31.430785354Z\", \"redemption\": { \"id\": \"b9628798-1b4e-4122-b2a6-031658df6755\", \"user\": { \"id\": \"91800084\", \"login\": \"cranken1337\", \"display_name\": \"cranken1337\" }, \"channel_id\": \"11148817\", \"redeemed_at\": \"2020-07-13T20:19:31.345237005Z\", \"reward\": { \"id\": \"313969fe-cc9f-4a0a-83c6-172acbd96957\", \"channel_id\": \"11148817\", \"title\": \"annoying reward pogchamp\", \"prompt\": \"\", \"cost\": 3000, \"is_user_input_required\": true, \"is_sub_only\": false, \"image\": null, \"default_image\": { \"url_1x\": \"https://static-cdn.jtvnw.net/custom-reward-images/default-1.png\", \"url_2x\": \"https://static-cdn.jtvnw.net/custom-reward-images/default-2.png\", \"url_4x\": \"https://static-cdn.jtvnw.net/custom-reward-images/default-4.png\" }, \"background_color\": \"#52ACEC\", \"is_enabled\": true, \"is_paused\": false, \"is_in_stock\": true, \"max_per_stream\": { \"is_enabled\": false, \"max_per_stream\": 0 }, \"should_redemptions_skip_request_queue\": false, \"template_id\": null, \"updated_for_indicator_at\": \"2020-01-20T04:33:33.624956679Z\" }, \"user_input\": \"wow, amazing reward\", \"status\": \"UNFULFILLED\", \"cursor\": \"Yjk2Mjg3OTgtMWI0ZS00MTIyLWIyYTYtMDMxNjU4ZGY2NzU1X18yMDIwLTA3LTEzVDIwOjE5OjMxLjM0NTIzNzAwNVo=\" } } } } }"; + const char *channelRewardMessage2 = "{ \"type\": \"MESSAGE\", \"data\": { \"topic\": \"community-points-channel-v1.11148817\", \"message\": { \"type\": \"reward-redeemed\", \"data\": { \"timestamp\": \"2020-07-13T20:19:31.430785354Z\", \"redemption\": { \"id\": \"b9628798-1b4e-4122-b2a6-031658df6755\", \"user\": { \"id\": \"91800084\", \"login\": \"cranken1337\", \"display_name\": \"cranken1337\" }, \"channel_id\": \"11148817\", \"redeemed_at\": \"2020-07-13T20:19:31.345237005Z\", \"reward\": { \"id\": \"313969fe-cc9f-4a0a-83c6-172acbd96957\", \"channel_id\": \"11148817\", \"title\": \"annoying reward pogchamp\", \"prompt\": \"\", \"cost\": 3000, \"is_user_input_required\": false, \"is_sub_only\": false, \"image\": null, \"default_image\": { \"url_1x\": \"https://static-cdn.jtvnw.net/custom-reward-images/default-1.png\", \"url_2x\": \"https://static-cdn.jtvnw.net/custom-reward-images/default-2.png\", \"url_4x\": \"https://static-cdn.jtvnw.net/custom-reward-images/default-4.png\" }, \"background_color\": \"#52ACEC\", \"is_enabled\": true, \"is_paused\": false, \"is_in_stock\": true, \"max_per_stream\": { \"is_enabled\": false, \"max_per_stream\": 0 }, \"should_redemptions_skip_request_queue\": false, \"template_id\": null, \"updated_for_indicator_at\": \"2020-01-20T04:33:33.624956679Z\" }, \"status\": \"UNFULFILLED\", \"cursor\": \"Yjk2Mjg3OTgtMWI0ZS00MTIyLWIyYTYtMDMxNjU4ZGY2NzU1X18yMDIwLTA3LTEzVDIwOjE5OjMxLjM0NTIzNzAwNVo=\" } } } } }"; + const char *channelRewardIRCMessage(R"(@badge-info=subscriber/43;badges=subscriber/42;color=#1E90FF;custom-reward-id=313969fe-cc9f-4a0a-83c6-172acbd96957;display-name=Cranken1337;emotes=;flags=;id=3cee3f27-a1d0-44d1-a606-722cebdad08b;mod=0;room-id=11148817;subscriber=1;tmi-sent-ts=1594756484132;turbo=0;user-id=91800084;user-type= :cranken1337!cranken1337@cranken1337.tmi.twitch.tv PRIVMSG #pajlada :wow, amazing reward)"); + // clang-format on createWindowShortcut(this, "F6", [=] { @@ -265,13 +273,28 @@ void Window::addDebugStuff() }); createWindowShortcut(this, "F9", [=] { - auto *dialog = new WelcomeDialog(); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->show(); + rapidjson::Document doc; + auto app = getApp(); + static bool alt = true; + if (alt) + { + doc.Parse(channelRewardMessage); + app->twitch.server->addFakeMessage(channelRewardIRCMessage); + app->twitch.pubsub->signals_.pointReward.redeemed.invoke( + doc["data"]["message"]["data"]["redemption"]); + alt = !alt; + } + else + { + doc.Parse(channelRewardMessage2); + app->twitch.pubsub->signals_.pointReward.redeemed.invoke( + doc["data"]["message"]["data"]["redemption"]); + alt = !alt; + } }); #endif -} +} // namespace chatterino void Window::addShortcuts() { @@ -388,24 +411,34 @@ void Window::onAccountSelected() { auto user = getApp()->accounts->twitch.getCurrent(); - // update title - this->setWindowTitle(Version::instance().fullVersion()); + // update title (also append username on Linux and MacOS) + QString windowTitle = Version::instance().fullVersion(); - // update user +#if defined(Q_OS_LINUX) || defined(Q_OS_MACOS) if (user->isAnon()) { - if (this->userLabel_) - { - this->userLabel_->getLabel().setText("anonymous"); - } + windowTitle += " - not logged in"; } else { - if (this->userLabel_) + windowTitle += " - " + user->getUserName(); + } +#endif + + this->setWindowTitle(windowTitle); + + // update user + if (this->userLabel_) + { + if (user->isAnon()) + { + this->userLabel_->getLabel().setText("anonymous"); + } + else { this->userLabel_->getLabel().setText(user->getUserName()); } } -} // namespace chatterino +} } // namespace chatterino diff --git a/src/widgets/dialogs/ColorPickerDialog.cpp b/src/widgets/dialogs/ColorPickerDialog.cpp index 669edf4f3..080442104 100644 --- a/src/widgets/dialogs/ColorPickerDialog.cpp +++ b/src/widgets/dialogs/ColorPickerDialog.cpp @@ -210,6 +210,7 @@ void ColorPickerDialog::initRecentColors(LayoutCreator &creator) this->ui_.recent.colors.push_back(new ColorButton(*it, this)); auto *button = this->ui_.recent.colors[ind]; + static_assert(RECENT_COLORS_PER_ROW != 0); const int rowInd = (ind / RECENT_COLORS_PER_ROW) + 1; const int columnInd = ind % RECENT_COLORS_PER_ROW; diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index 9e0911cb7..125ebf612 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -8,6 +8,7 @@ #include "messages/MessageBuilder.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Emotes.hpp" +#include "singletons/WindowManager.hpp" #include "util/Shortcut.hpp" #include "widgets/Notebook.hpp" #include "widgets/helper/ChannelView.hpp" @@ -113,6 +114,9 @@ namespace { EmotePopup::EmotePopup(QWidget *parent) : BasePopup(BaseWindow::EnableCustomFrame, parent) { + this->setStayInScreenRect(true); + this->moveTo(this, getApp()->windows->emotePopupPos(), false); + auto layout = new QVBoxLayout(this); this->getLayoutContainer()->setLayout(layout); @@ -220,4 +224,9 @@ void EmotePopup::loadEmojis() this->viewEmojis_->setChannel(emojiChannel); } +void EmotePopup::closeEvent(QCloseEvent *event) +{ + getApp()->windows->setEmotePopupPos(this->pos()); + QWidget::closeEvent(event); +} } // namespace chatterino diff --git a/src/widgets/dialogs/EmotePopup.hpp b/src/widgets/dialogs/EmotePopup.hpp index 7e24faf95..18647f26d 100644 --- a/src/widgets/dialogs/EmotePopup.hpp +++ b/src/widgets/dialogs/EmotePopup.hpp @@ -19,6 +19,8 @@ public: void loadChannel(ChannelPtr channel); void loadEmojis(); + virtual void closeEvent(QCloseEvent *event) override; + pajlada::Signals::Signal linkClicked; private: diff --git a/src/widgets/dialogs/LoginDialog.cpp b/src/widgets/dialogs/LoginDialog.cpp index f96c86342..3666ea657 100644 --- a/src/widgets/dialogs/LoginDialog.cpp +++ b/src/widgets/dialogs/LoginDialog.cpp @@ -4,7 +4,6 @@ #include "common/Common.hpp" #include "common/NetworkRequest.hpp" #include "controllers/accounts/AccountController.hpp" -#include "providers/twitch/PartialTwitchUser.hpp" #include "util/Helpers.hpp" #ifdef USEWINSDK @@ -159,7 +158,6 @@ AdvancedLoginWidget::AdvancedLoginWidget() this->ui_.layout.addWidget(&this->ui_.instructionsLabel); this->ui_.layout.addLayout(&this->ui_.formLayout); this->ui_.layout.addLayout(&this->ui_.buttonUpperRow.layout); - this->ui_.layout.addLayout(&this->ui_.buttonLowerRow.layout); this->refreshButtons(); @@ -207,29 +205,10 @@ AdvancedLoginWidget::AdvancedLoginWidget() LogInWithCredentials(userID, username, clientID, oauthToken); }); - - /// Lower button row - this->ui_.buttonLowerRow.fillInUserIDButton.setText( - "Get user ID from username"); - - this->ui_.buttonLowerRow.layout.addWidget( - &this->ui_.buttonLowerRow.fillInUserIDButton); - - connect(&this->ui_.buttonLowerRow.fillInUserIDButton, &QPushButton::clicked, - [=]() { - const auto onIdFetched = [=](const QString &userID) { - this->ui_.userIDInput.setText(userID); // - }; - PartialTwitchUser::byName(this->ui_.usernameInput.text()) - .getId(onIdFetched, this); - }); } void AdvancedLoginWidget::refreshButtons() { - this->ui_.buttonLowerRow.fillInUserIDButton.setEnabled( - !this->ui_.usernameInput.text().isEmpty()); - if (this->ui_.userIDInput.text().isEmpty() || this->ui_.usernameInput.text().isEmpty() || this->ui_.clientIDInput.text().isEmpty() || diff --git a/src/widgets/dialogs/LoginDialog.hpp b/src/widgets/dialogs/LoginDialog.hpp index a6819e3af..796b98cac 100644 --- a/src/widgets/dialogs/LoginDialog.hpp +++ b/src/widgets/dialogs/LoginDialog.hpp @@ -57,12 +57,6 @@ public: QPushButton addUserButton; QPushButton clearFieldsButton; } buttonUpperRow; - - struct { - QHBoxLayout layout; - - QPushButton fillInUserIDButton; - } buttonLowerRow; } ui_; }; diff --git a/src/widgets/dialogs/LogsPopup.cpp b/src/widgets/dialogs/LogsPopup.cpp deleted file mode 100644 index 18e1f1a04..000000000 --- a/src/widgets/dialogs/LogsPopup.cpp +++ /dev/null @@ -1,137 +0,0 @@ -#include "LogsPopup.hpp" - -#include "IrcMessage" -#include "common/Channel.hpp" -#include "common/NetworkRequest.hpp" -#include "messages/Message.hpp" -#include "providers/twitch/PartialTwitchUser.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" -#include "util/PostToThread.hpp" -#include "widgets/helper/ChannelView.hpp" - -#include -#include -#include -#include - -namespace chatterino { - -LogsPopup::LogsPopup() - : channel_(Channel::getEmpty()) -{ - this->resize(400, 600); -} - -void LogsPopup::setChannel(const ChannelPtr &channel) -{ - this->channel_ = channel; - this->updateWindowTitle(); -} - -void LogsPopup::setChannelName(const QString &channelName) -{ - this->channelName_ = channelName; - this->updateWindowTitle(); -} - -void LogsPopup::setTargetUserName(const QString &userName) -{ - this->userName_ = userName; - this->updateWindowTitle(); -} - -void LogsPopup::updateWindowTitle() -{ - this->setWindowTitle(this->userName_ + "'s logs in #" + this->channelName_); -} - -void LogsPopup::getLogs() -{ - if (this->channel_ && !this->channel_->isEmpty()) - { - if (auto twitchChannel = - dynamic_cast(this->channel_.get())) - { - this->channelName_ = twitchChannel->getName(); - this->getOverrustleLogs(); - - return; - } - } - - if (!this->channelName_.isEmpty()) - { - this->getOverrustleLogs(); - return; - } - - qDebug() << "Unable to get logs, no channel name or something specified"; -} - -void LogsPopup::setMessages(std::vector &messages) -{ - ChannelPtr logsChannel(new Channel("logs", Channel::Type::Misc)); - - logsChannel->addMessagesAtStart(messages); - SearchPopup::setChannel(logsChannel); -} - -void LogsPopup::getOverrustleLogs() -{ - auto url = - QString("https://overrustlelogs.net/api/v1/stalk/%1/%2.json?limit=500") - .arg(this->channelName_, this->userName_); - - NetworkRequest(url) - .caller(this) - .onError([this](NetworkResult) { - auto box = new QMessageBox( - QMessageBox::Information, "Error getting logs", - "No logs could be found for channel " + this->channelName_); - box->setWindowFlag(Qt::WindowStaysOnTopHint); - box->setAttribute(Qt::WA_DeleteOnClose); - box->show(); - box->raise(); - this->close(); - box->exec(); - }) - .onSuccess([this](auto result) -> Outcome { - auto data = result.parseJson(); - std::vector messages; - if (data.contains("lines")) - { - QJsonArray dataMessages = data.value("lines").toArray(); - for (auto i : dataMessages) - { - QJsonObject singleMessage = i.toObject(); - auto text = singleMessage.value("text").toString(); - QTime timeStamp = - QDateTime::fromSecsSinceEpoch( - singleMessage.value("timestamp").toInt()) - .time(); - - MessageBuilder builder; - builder.emplace(timeStamp); - builder.emplace(this->userName_, - MessageElementFlag::Username, - MessageColor::System); - builder.emplace(text, MessageElementFlag::Text, - MessageColor::Text); - builder.message().messageText = text; - builder.message().displayName = this->userName_; - messages.push_back(builder.release()); - } - } - messages.push_back( - MessageBuilder(systemMessage, - "Logs provided by https://overrustlelogs.net") - .release()); - this->setMessages(messages); - - return Success; - }) - .execute(); -} - -} // namespace chatterino diff --git a/src/widgets/dialogs/LogsPopup.hpp b/src/widgets/dialogs/LogsPopup.hpp deleted file mode 100644 index ab29ae431..000000000 --- a/src/widgets/dialogs/LogsPopup.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "widgets/helper/SearchPopup.hpp" - -namespace chatterino { - -class LogsPopup : public SearchPopup -{ -public: - LogsPopup(); - - void setChannel(const ChannelPtr &channel) override; - void setChannelName(const QString &channelName); - void setTargetUserName(const QString &userName); - - void getLogs(); - -protected: - void updateWindowTitle() override; - -private: - ChannelPtr channel_; - - QString userName_; - QString channelName_; - - void setMessages(std::vector &messages); - void getOverrustleLogs(); -}; - -} // namespace chatterino diff --git a/src/widgets/dialogs/SelectChannelDialog.cpp b/src/widgets/dialogs/SelectChannelDialog.cpp index 25d61ff01..a17c7022a 100644 --- a/src/widgets/dialogs/SelectChannelDialog.cpp +++ b/src/widgets/dialogs/SelectChannelDialog.cpp @@ -152,7 +152,7 @@ SelectChannelDialog::SelectChannelDialog(QWidget *parent) auto editor = new IrcConnectionEditor(unique); if (editor->exec() == QDialog::Accepted) { - Irc::instance().connections.appendItem(editor->data()); + Irc::instance().connections.append(editor->data()); } }); @@ -160,21 +160,20 @@ SelectChannelDialog::SelectChannelDialog(QWidget *parent) view->getTableView(), &QTableView::doubleClicked, [](const QModelIndex &index) { auto editor = new IrcConnectionEditor( - Irc::instance() - .connections.getVector()[size_t(index.row())]); + Irc::instance().connections.raw()[size_t(index.row())]); if (editor->exec() == QDialog::Accepted) { auto data = editor->data(); - auto &&conns = Irc::instance().connections.getVector(); + auto &&conns = Irc::instance().connections.raw(); int i = 0; for (auto &&conn : conns) { if (conn.id == data.id) { - Irc::instance().connections.removeItem( + Irc::instance().connections.removeAt( i, Irc::noEraseCredentialCaller); - Irc::instance().connections.insertItem(data, i); + Irc::instance().connections.insert(data, i); } i++; } @@ -348,7 +347,7 @@ IndirectChannel SelectChannelDialog::getSelectedChannel() const ->currentIndex() .row(); - auto &&vector = Irc::instance().connections.getVector(); + auto &&vector = Irc::instance().connections.raw(); if (row >= 0 && row < int(vector.size())) { diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index 73ad5f756..f64656213 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -3,8 +3,8 @@ #include "Application.hpp" #include "singletons/Resources.hpp" #include "util/LayoutCreator.hpp" +#include "util/Shortcut.hpp" #include "widgets/helper/Button.hpp" -#include "widgets/helper/SettingsDialogTab.hpp" #include "widgets/settingspages/AboutPage.hpp" #include "widgets/settingspages/AccountsPage.hpp" #include "widgets/settingspages/CommandPage.hpp" @@ -21,22 +21,23 @@ namespace chatterino { -SettingsDialog *SettingsDialog::handle = nullptr; - SettingsDialog::SettingsDialog() : BaseWindow(BaseWindow::DisableCustomScaling) { this->setWindowTitle("Chatterino Settings"); + this->resize(815, 600); + this->themeChangedEvent(); + this->scaleChangedEvent(this->scale()); this->initUi(); this->addTabs(); - - this->scaleChangedEvent(this->scale()); - this->overrideBackgroundColor_ = QColor("#111111"); - this->themeChangedEvent(); + this->scaleChangedEvent(this->scale()); // execute twice to width of item - this->resize(815, 600); + createWindowShortcut(this, "CTRL+F", [this] { + this->ui_.search->setFocus(); + this->ui_.search->selectAll(); + }); } void SettingsDialog::initUi() @@ -69,13 +70,9 @@ void SettingsDialog::initUi() .assign(&this->ui_.tabContainer); // right side (pages) - auto right = - centerBox.emplace().withoutMargin().withoutSpacing(); - { - right.emplace() - .assign(&this->ui_.pageStack) - .withoutMargin(); - } + centerBox.emplace() + .assign(&this->ui_.pageStack) + .withoutMargin(); this->ui_.pageStack->setMargin(0); @@ -103,10 +100,11 @@ void SettingsDialog::initUi() void SettingsDialog::filterElements(const QString &text) { // filter elements and hide pages - for (auto &&page : this->pages_) + for (auto &&tab : this->tabs_) { // filterElements returns true if anything on the page matches the search query - page->tab()->setVisible(page->filterElements(text)); + tab->setVisible(tab->page()->filterElements(text) || + tab->name().contains(text, Qt::CaseInsensitive)); } // find next visible page @@ -144,11 +142,6 @@ void SettingsDialog::filterElements(const QString &text) } } -SettingsDialog *SettingsDialog::getHandle() -{ - return SettingsDialog::handle; -} - void SettingsDialog::addTabs() { this->ui_.tabContainer->setMargin(0); @@ -156,38 +149,34 @@ void SettingsDialog::addTabs() this->ui_.tabContainer->setContentsMargins(0, 20, 0, 20); - this->addTab(new GeneralPage); + // Constructors are wrapped in std::function to remove some strain from first time loading. + // clang-format off + this->addTab([]{return new GeneralPage;}, "General", ":/settings/about.svg"); this->ui_.tabContainer->addSpacing(16); - - this->addTab(new AccountsPage); - + this->addTab([]{return new AccountsPage;}, "Accounts", ":/settings/accounts.svg", SettingsTabId::Accounts); this->ui_.tabContainer->addSpacing(16); - - this->addTab(new CommandPage); - this->addTab(new HighlightingPage); - this->addTab(new IgnoresPage); - + this->addTab([]{return new CommandPage;}, "Commands", ":/settings/commands.svg"); + this->addTab([]{return new HighlightingPage;}, "Highlights", ":/settings/notifications.svg"); + this->addTab([]{return new IgnoresPage;}, "Ignores", ":/settings/ignore.svg"); this->ui_.tabContainer->addSpacing(16); - - this->addTab(new KeyboardSettingsPage); - this->addTab(this->ui_.moderationPage = new ModerationPage); - this->addTab(new NotificationPage); - this->addTab(new ExternalToolsPage); - + this->addTab([]{return new KeyboardSettingsPage;}, "Keybindings", ":/settings/keybinds.svg"); + this->addTab([]{return new ModerationPage;}, "Moderation", ":/settings/moderation.svg", SettingsTabId::Moderation); + this->addTab([]{return new NotificationPage;}, "Notifications", ":/settings/notification2.svg"); + this->addTab([]{return new ExternalToolsPage;}, "External tools", ":/settings/externaltools.svg"); this->ui_.tabContainer->addStretch(1); - this->addTab(new AboutPage, Qt::AlignBottom); + this->addTab([]{return new AboutPage;}, "About", ":/settings/about.svg", SettingsTabId(), Qt::AlignBottom); + // clang-format on } -void SettingsDialog::addTab(SettingsPage *page, Qt::Alignment alignment) +void SettingsDialog::addTab(std::function page, + const QString &name, const QString &iconPath, + SettingsTabId id, Qt::Alignment alignment) { - auto tab = new SettingsDialogTab(this, page, page->getIconResource()); - page->setTab(tab); + auto tab = new SettingsDialogTab(this, std::move(page), name, iconPath, id); - this->ui_.pageStack->addWidget(page); this->ui_.tabContainer->addWidget(tab, 0, alignment); this->tabs_.push_back(tab); - this->pages_.push_back(page); if (this->tabs_.size() == 1) { @@ -197,7 +186,16 @@ void SettingsDialog::addTab(SettingsPage *page, Qt::Alignment alignment) void SettingsDialog::selectTab(SettingsDialogTab *tab, bool byUser) { - this->ui_.pageStack->setCurrentWidget(tab->getSettingsPage()); + // add page if it's not been added yet + [&] { + for (int i = 0; i < this->ui_.pageStack->count(); i++) + if (this->ui_.pageStack->itemAt(i)->widget() == tab->page()) + return; + + this->ui_.pageStack->addWidget(tab->page()); + }(); + + this->ui_.pageStack->setCurrentWidget(tab->page()); if (this->selectedTab_ != nullptr) { @@ -215,28 +213,49 @@ void SettingsDialog::selectTab(SettingsDialogTab *tab, bool byUser) } } -void SettingsDialog::selectPage(SettingsPage *page) +void SettingsDialog::selectTab(SettingsTabId id) { - assert(page); - assert(page->tab()); + auto t = this->tab(id); + assert(t); + if (!t) + return; - this->selectTab(page->tab()); + this->selectTab(t); +} + +SettingsDialogTab *SettingsDialog::tab(SettingsTabId id) +{ + for (auto &&tab : this->tabs_) + if (tab->id() == id) + return tab; + + assert(false); + return nullptr; } void SettingsDialog::showDialog(SettingsDialogPreference preferredTab) { static SettingsDialog *instance = new SettingsDialog(); - instance->refresh(); + static bool hasShownBefore = false; + if (hasShownBefore) + instance->refresh(); + hasShownBefore = true; switch (preferredTab) { case SettingsDialogPreference::Accounts: - instance->selectTab(instance->tabs_.at(1)); + instance->selectTab(SettingsTabId::Accounts); break; case SettingsDialogPreference::ModerationActions: - instance->selectPage(instance->ui_.moderationPage); - instance->ui_.moderationPage->selectModerationActions(); + if (auto tab = instance->tab(SettingsTabId::Moderation)) + { + instance->selectTab(tab); + if (auto page = dynamic_cast(tab->page())) + { + page->selectModerationActions(); + } + } break; default:; @@ -250,11 +269,13 @@ void SettingsDialog::showDialog(SettingsDialogPreference preferredTab) void SettingsDialog::refresh() { + // Resets the cancel button. getSettings()->saveSnapshot(); + // Updates tabs. for (auto *tab : this->tabs_) { - tab->getSettingsPage()->onShow(); + tab->page()->onShow(); } } @@ -273,7 +294,8 @@ void SettingsDialog::scaleChangedEvent(float newDpi) this->setStyleSheet(styleSheet); - this->ui_.tabContainerContainer->setFixedWidth(int(150 * newDpi)); + if (this->ui_.tabContainerContainer) + this->ui_.tabContainerContainer->setFixedWidth(int(150 * newDpi)); } void SettingsDialog::themeChangedEvent() @@ -281,7 +303,7 @@ void SettingsDialog::themeChangedEvent() BaseWindow::themeChangedEvent(); QPalette palette; - palette.setColor(QPalette::Background, QColor("#111")); + palette.setColor(QPalette::Window, QColor("#111")); this->setPalette(palette); } @@ -301,7 +323,7 @@ void SettingsDialog::onCancelClicked() { for (auto &tab : this->tabs_) { - tab->getSettingsPage()->cancel(); + tab->page()->cancel(); } getSettings()->restoreSnapshot(); diff --git a/src/widgets/dialogs/SettingsDialog.hpp b/src/widgets/dialogs/SettingsDialog.hpp index 5a064b667..fa7449ab0 100644 --- a/src/widgets/dialogs/SettingsDialog.hpp +++ b/src/widgets/dialogs/SettingsDialog.hpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include "widgets/helper/SettingsDialogTab.hpp" class QLineEdit; @@ -29,10 +31,9 @@ enum class SettingsDialogPreference { class SettingsDialog : public BaseWindow { -public: SettingsDialog(); - static SettingsDialog *getHandle(); // may be NULL +public: static void showDialog(SettingsDialogPreference preferredTab = SettingsDialogPreference::NoPreference); @@ -42,15 +43,16 @@ protected: virtual void showEvent(QShowEvent *) override; private: - static SettingsDialog *handle; - void refresh(); void initUi(); + SettingsDialogTab *tab(SettingsTabId id); void addTabs(); - void addTab(SettingsPage *page, Qt::Alignment alignment = Qt::AlignTop); - void selectTab(SettingsDialogTab *tab, bool byUser = true); - void selectPage(SettingsPage *page); + void addTab(std::function page, const QString &name, + const QString &iconPath, SettingsTabId id = {}, + Qt::Alignment alignment = Qt::AlignTop); + void selectTab(SettingsDialogTab *tab, const bool byUser = true); + void selectTab(SettingsTabId id); void filterElements(const QString &query); void onOkClicked(); @@ -62,11 +64,9 @@ private: QStackedLayout *pageStack{}; QPushButton *okButton{}; QPushButton *cancelButton{}; - ModerationPage *moderationPage{}; QLineEdit *search{}; } ui_; std::vector tabs_; - std::vector pages_; SettingsDialogTab *selectedTab_{}; SettingsDialogTab *lastSelectedByUser_{}; diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index e530a5bff..fd31b1b9e 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -4,14 +4,19 @@ #include "common/Channel.hpp" #include "common/NetworkRequest.hpp" #include "controllers/accounts/AccountController.hpp" -#include "controllers/highlights/HighlightController.hpp" -#include "providers/twitch/PartialTwitchUser.hpp" +#include "controllers/highlights/HighlightBlacklistUser.hpp" +#include "messages/Message.hpp" #include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/api/Kraken.hpp" #include "singletons/Resources.hpp" +#include "singletons/Settings.hpp" #include "util/LayoutCreator.hpp" #include "util/PostToThread.hpp" +#include "util/Shortcut.hpp" +#include "util/StreamerMode.hpp" #include "widgets/Label.hpp" -#include "widgets/dialogs/LogsPopup.hpp" +#include "widgets/helper/ChannelView.hpp" #include "widgets/helper/EffectLabel.hpp" #include "widgets/helper/Line.hpp" @@ -21,9 +26,10 @@ #include #include -#define TEXT_FOLLOWERS "Followers: " -#define TEXT_VIEWS "Views: " -#define TEXT_CREATED "Created: " +const QString TEXT_VIEWS("Views: %1"); +const QString TEXT_FOLLOWERS("Followers: %1"); +const QString TEXT_CREATED("Created: %1"); +const QString TEXT_TITLE("%1's Usercard"); #define TEXT_USER_ID "ID: " #define TEXT_UNAVAILABLE "(not available)" @@ -47,17 +53,49 @@ namespace { return label.getElement(); }; + ChannelPtr filterMessages(const QString &userName, ChannelPtr channel) + { + LimitedQueueSnapshot snapshot = + channel->getMessageSnapshot(); + + ChannelPtr channelPtr( + new Channel(channel->getName(), Channel::Type::None)); + + for (size_t i = 0; i < snapshot.size(); i++) + { + MessagePtr message = snapshot[i]; + + bool isSubscription = + message->flags.has(MessageFlag::Subscription) && + message->loginName == "" && + message->messageText.split(" ").at(0).compare( + userName, Qt::CaseInsensitive) == 0; + + bool isModAction = message->timeoutUser.compare( + userName, Qt::CaseInsensitive) == 0; + bool isSelectedUser = + message->loginName.compare(userName, Qt::CaseInsensitive) == 0; + + if ((isSubscription || isModAction || isSelectedUser) && + !message->flags.has(MessageFlag::Whisper)) + { + channelPtr->addMessage(message); + } + } + + return channelPtr; + }; } // namespace UserInfoPopup::UserInfoPopup() - : BaseWindow({BaseWindow::Frameless, BaseWindow::FramelessDraggable}) + : BaseWindow(BaseWindow::EnableCustomFrame) , hack_(new bool) { + this->setWindowTitle("Usercard"); this->setStayInScreenRect(true); -#ifdef Q_OS_LINUX - this->setWindowFlag(Qt::Popup); -#endif + // Close the popup when Escape is pressed + createWindowShortcut(this, "Escape", [this] { this->deleteLater(); }); auto layout = LayoutCreator(this->getLayoutContainer()) .setLayoutType(); @@ -91,10 +129,11 @@ UserInfoPopup::UserInfoPopup() this->ui_.userIDLabel->setPalette(palette); } - vbox.emplace