From f4036b269bc9dfa789d6bb97278e6a3e5ddc680e Mon Sep 17 00:00:00 2001 From: iProdigy Date: Sat, 19 Oct 2024 03:40:06 -0700 Subject: [PATCH 1/5] feat: add shared chat badge --- lib/lrucache/lrucache/lrucache.hpp | 11 ++++++++ resources/twitch/sharedChat.png | Bin 0 -> 2006 bytes src/messages/MessageBuilder.cpp | 30 +++++++++++++++++++-- src/messages/MessageBuilder.hpp | 1 + src/messages/MessageElement.hpp | 6 ++++- src/providers/twitch/TwitchIrcServer.cpp | 33 +++++++++++++++++++++++ src/providers/twitch/TwitchIrcServer.hpp | 11 ++++++++ src/singletons/WindowManager.cpp | 1 + 8 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 resources/twitch/sharedChat.png diff --git a/lib/lrucache/lrucache/lrucache.hpp b/lib/lrucache/lrucache/lrucache.hpp index ef7d031db..183bc3b02 100644 --- a/lib/lrucache/lrucache/lrucache.hpp +++ b/lib/lrucache/lrucache/lrucache.hpp @@ -71,6 +71,17 @@ public: } } + void remove(const key_t &key) + { + auto it = _cache_items_map.find(key); + if (it == _cache_items_map.end()) + { + throw std::range_error("There is no such key in cache"); + } + _cache_items_list.erase(it->second); + _cache_items_map.erase(it); + } + const value_t &get(const key_t &key) { auto it = _cache_items_map.find(key); diff --git a/resources/twitch/sharedChat.png b/resources/twitch/sharedChat.png new file mode 100644 index 0000000000000000000000000000000000000000..116108c77032a75551cea742eae343bc3423c151 GIT binary patch literal 2006 zcmV;{2Pyc8P)Ev-aA1uf5Jb za5U$itial5t^fM=f7b2$*4i~mbdZfh4z>a9j*dVD=m=DRjz9(I2vmTMKn3UsRDg~^ z1(@0b&MsgH@GIa{;CP@9*ahqW-U0pr{1e#Qg7zy#t-_sjFK`oZJ#ctH`LBUhz#YKu zs*I=_RSC2l_#4m@QTI#WT3~$@hE$Da=b##J8}KA?;(+zMwZO_cUt6KsIp|j4mW1n^WI>PWf4Qd zoiqLwIkepLqQkq%Q^q&v?3A=jQq3t_lXQ`!Ay1tRp?1Z*$abf(2P+cjA%}OH^ZQRS z#3`D52RtH8NIEUVu9!Kf&<K;P7F{$sg_HmwD=32Ygt-`vLG<=UFX-FNGL^TtQsH zV&Mwn3bxqE4+0wkxz_?)3;4W<5oo)UABf=aVO~Kyvz`3&K{1AV}m&a-ztWs-1vSP{N(@@|jyB1WK%PJSA&B7!H0>qtAWZaePKKBiNjFRC&8XKW>5obetA^pf&S~?h43U=0zmvYi zBaS4k3w_UKW6~?x9|?4^kNpwFFEM{>9=2L#MKW&z{*u$)$(S$J?fGphwv-QHE7UJZ z4=b=Z)9Jv+IqmF66FsQ$S6Z3TlgeO1(w{uNz?aUGv@f!qeUi=%Xrq!LmP||KcM3x= zr2Jv!OH1Sr+2&!lN{XWj1wztMvPhu21<1mfq}%16kVDdsWnOtv@12m%Idfv#sq)~k zftgsat8ub=0{i2{+p!(;M&Mb}OL+`V!l17#Uk|($OWq2Beo*LKUMN6E zpaOIRDnLh|0(1l_K!`wx0XG8w#_sg!0H!hE)q4AfC2Z;i{u9YLTIamM;q;?mqtq|y z?ZAy+tFc9sPj8o2z`0okC&GFApu_7*&o5N@z+nq;2JpXn{uSWoO>(|j=lsU^W8M(E zmi7_{Y}b5EA|HCHk%t8i=OpA^pt{V-3-~*x_`$^IiT|%9F!-Lp3D|X+BE(+u#e#!M(qpKO(DOEAC0R z0zU#S#qL?J#-f#Fa0nK-@5iQzPp~QD24JH8uOB%}oQqrYmctgNg_;~3dn|(E!9@Mx zI%ngznxs`(b=zH_g-YKf8<#Y-1V>Hj8*w8Zk-YWu<{)?Wo>H<~H;aYX=L+s+sX@vO zF_FHwaAci(VWNx|c>=l7zF8%VY8F}Ii~?6i@Lf&%eO!_J$myEzINkE4gE*`tX;jkL zP4F&~|154)(pg#c@(9;BMBhj{{(CA;v`BemmwSlTe2>M65-E>py8LHdH@85XpJs@2 zpfMt|B}gD7{Vs#(5yhWp`O+IzzGaixtb}bv$39bl7qBmMTzm%H13V1u30q7uF^vDb z{a|32#ErS4h~l@HL+A6u`Z0k%!@TONFqa_ao3BSB>a+^0lbO(i!(*`P|M`0c!(Qz4 zbQ`df$zSaphk4SouvkvxCdV}331F#*Zw&jIv8?}V+2!yC{}f4&XOvy1a_&V|nj-V%eApm+$KX{gE6tH~pQ>4Pc1SwKd4X1m zR9YNG3e-qll2%B1R@NJ0v0befDNmgB$`D5Zt5_%w#e(NQ!@d@ri`}&7#y)N8!senq z^)!rqLhwFzob(!#zoDN7JdXd8=H0;WOWOBW?C8ivdd_js&bK?VMA}LE&T}4Wn4{`O z4GYDA_C8r1$D)Yt_w?=!{33|-XAME(SncX|!>vkFCEX))dtQ<>H-cl4q>p5`-&W<# oIjx$5ez0igA=Vb?2vmUY0DcCkx0}UhmH+?%07*qoM6N<$g1u_(00000 literal 0 HcmV?d00001 diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index e470b01ce..9b6cfe6fa 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -384,6 +384,17 @@ EmotePtr makeAutoModBadge() Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); } +EmotePtr makeSharedChatBadge(const QString &sourceName) +{ + return std::make_shared( + Emote{"SharedChat_" + sourceName, + ImageSet{Image::fromResourcePixmap( + getResources().twitch.sharedChat, 0.25)}, + Tooltip{"Shared Message" + + (sourceName.isEmpty() ? "" : " from " + sourceName)}, + Url{"https://link.twitch.tv/SharedChatViewer"}}); +} + } // namespace namespace chatterino { @@ -1156,6 +1167,14 @@ MessagePtr MessageBuilder::build() this->emplace(); } + if (this->sourceName.has_value()) + { + this->emplace( + makeSharedChatBadge(this->sourceName.value()), + MessageElementFlag::BadgeSharedChannel) + ->setLink({Link::UserInfo, sourceName.value()}); + } + this->appendTwitchBadges(); this->appendChatterinoBadges(); @@ -2242,16 +2261,23 @@ void MessageBuilder::parseRoomID() { this->message().flags.set(MessageFlag::SharedMessage); - auto sourceChan = - getApp()->getTwitch()->getChannelOrEmptyByID(sourceRoom); + auto *twitch = getApp()->getTwitch(); + auto sourceChan = twitch->getChannelOrEmptyByID(sourceRoom); if (sourceChan && !sourceChan->isEmpty()) { + this->sourceName = sourceChan->getName(); this->sourceChannel = dynamic_cast(sourceChan.get()); + // avoid duplicate pings this->message().flags.set( MessageFlag::DoNotTriggerNotification); } + else + { + this->sourceName = + twitch->getOrPopulateChannelCache(sourceRoom); + } } } } diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index aa2933b64..18e0bb286 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -157,6 +157,7 @@ public: TwitchChannel *twitchChannel = nullptr; /// The Twitch Channel the message was sent in, according to the Shared Chat feature TwitchChannel *sourceChannel = nullptr; + std::optional sourceName = std::nullopt; Message *operator->(); Message &message(); diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 0e6c07b93..c19ed5c0c 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -66,6 +66,10 @@ enum class MessageElementFlag : int64_t { BitsStatic = (1LL << 11), BitsAnimated = (1LL << 12), + // Slot 0: Twitch + // - Shared Channel indicator badge + BadgeSharedChannel = (1LL << 37), + // Slot 1: Twitch // - Staff badge // - Admin badge @@ -119,7 +123,7 @@ enum class MessageElementFlag : int64_t { Badges = BadgeGlobalAuthority | BadgePredictions | BadgeChannelAuthority | BadgeSubscription | BadgeVanity | BadgeChatterino | BadgeSevenTV | - BadgeFfz, + BadgeFfz | BadgeSharedChannel, ChannelName = (1LL << 20), diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 9ca93f6fd..f738125da 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -153,6 +153,7 @@ TwitchIrcServer::TwitchIrcServer() , liveChannel(new Channel("/live", Channel::Type::TwitchLive)) , automodChannel(new Channel("/automod", Channel::Type::TwitchAutomod)) , watchingChannel(Channel::getEmpty(), Channel::Type::TwitchWatching) + , channelNamesById_(512) { // Initialize the connections // XXX: don't create write connection if there is no separate write connection. @@ -1128,6 +1129,38 @@ std::shared_ptr TwitchIrcServer::getChannelOrEmptyByID( return Channel::getEmpty(); } +std::optional TwitchIrcServer::getOrPopulateChannelCache( + const QString &channelId) +{ + { + const auto cache = this->channelNamesById_.access(); + if (cache->exists(channelId)) + { + return cache->get(channelId); + } + + // prevent multiple helix requests for single user + cache->put(channelId, ""); + } + + getHelix()->getUserById( + channelId, + [this](const HelixUser &user) { + const auto cache = this->channelNamesById_.access(); + cache->put(user.id, user.login); + }, + [this, &channelId] { + const auto cache = this->channelNamesById_.access(); + if (cache->exists(channelId) && cache->get(channelId).isEmpty()) + { + // invalidate cache so another helix request can be attempted + cache->remove(channelId); + } + }); + + return {}; +} + QString TwitchIrcServer::cleanChannelName(const QString &dirtyChannelName) { if (dirtyChannelName.startsWith('#')) diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index fc3b888ef..532e0afe2 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -3,10 +3,12 @@ #include "common/Atomic.hpp" #include "common/Channel.hpp" #include "common/Common.hpp" +#include "common/UniqueAccess.hpp" #include "providers/irc/IrcConnection2.hpp" #include "util/RatelimitBucket.hpp" #include +#include #include #include @@ -43,6 +45,9 @@ public: virtual ChannelPtr getOrAddChannel(const QString &dirtyChannelName) = 0; virtual ChannelPtr getChannelOrEmpty(const QString &dirtyChannelName) = 0; + virtual std::optional getOrPopulateChannelCache( + const QString &channelId) = 0; + virtual void addFakeMessage(const QString &data) = 0; virtual void addGlobalSystemMessage(const QString &messageText) = 0; @@ -95,6 +100,9 @@ public: std::shared_ptr getChannelOrEmptyByID( const QString &channelID) override; + std::optional getOrPopulateChannelCache( + const QString &channelId) override; + void reloadAllBTTVChannelEmotes(); void reloadAllFFZChannelEmotes(); void reloadAllSevenTVChannelEmotes(); @@ -190,6 +198,9 @@ private: // https://dev.twitch.tv/docs/irc/guide#rate-limits QObjectPtr joinBucket_; + // cached channel id => name for resolving Shared Chat members + UniqueAccess> channelNamesById_; + QTimer reconnectTimer_; int falloffCounter_ = 1; diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index f64eb47e7..88d5ec565 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -195,6 +195,7 @@ void WindowManager::updateWordTypeMask() flags.set(settings->animateEmotes ? MEF::BitsAnimated : MEF::BitsStatic); // badges + flags.set(MEF::BadgeSharedChannel); flags.set(settings->showBadgesGlobalAuthority ? MEF::BadgeGlobalAuthority : MEF::None); flags.set(settings->showBadgesPredictions ? MEF::BadgePredictions From 7d0c4738286fef7ec7dfd7ea277454953aae8ad9 Mon Sep 17 00:00:00 2001 From: iProdigy <8106344+iProdigy@users.noreply.github.com> Date: Sat, 19 Oct 2024 03:48:56 -0700 Subject: [PATCH 2/5] chore: update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa0e5a5a..137a7126c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Major: Improve high-DPI support on Windows. (#4868, #5391) - Major: Added transparent overlay window (default keybind: CTRL + ALT + N). (#4746, #5643, #5659) - Minor: Removed the Ctrl+Shift+L hotkey for toggling the "live only" tab visibility state. (#5530) -- Minor: Add support for Shared Chat messages. Shared chat messages can be filtered with the `flags.shared` filter variable, or with search using `is:shared`. Some messages like subscriptions are filtered on purpose to avoid confusion for the broadcaster. If you have both channels participating in Shared Chat open, only one of the message triggering your highlight will trigger. (#5606, #5625) +- Minor: Add support for Shared Chat messages. Shared chat messages can be filtered with the `flags.shared` filter variable, or with search using `is:shared`. Some messages like subscriptions are filtered on purpose to avoid confusion for the broadcaster. If you have both channels participating in Shared Chat open, only one of the message triggering your highlight will trigger. (#5606, #5625, #5661) - Minor: Moved tab visibility control to a submenu, without any toggle actions. (#5530) - Minor: Add option to customise Moderation buttons with images. (#5369) - Minor: Colored usernames now update on the fly when changing the "Color @usernames" setting. (#5300) From c4faca50e0c8ffd54f5a125aff653c572690bed9 Mon Sep 17 00:00:00 2001 From: iProdigy Date: Sat, 19 Oct 2024 04:06:20 -0700 Subject: [PATCH 3/5] chore: update mocks --- mocks/include/mocks/TwitchIrcServer.hpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mocks/include/mocks/TwitchIrcServer.hpp b/mocks/include/mocks/TwitchIrcServer.hpp index d218192b3..8f8de58a8 100644 --- a/mocks/include/mocks/TwitchIrcServer.hpp +++ b/mocks/include/mocks/TwitchIrcServer.hpp @@ -26,6 +26,7 @@ public: , mentionsChannel(std::shared_ptr(new MockChannel("forsen3"))) , liveChannel(std::shared_ptr(new MockChannel("forsen"))) , automodChannel(std::shared_ptr(new MockChannel("forsen2"))) + , channelNamesById_(1) { } @@ -49,6 +50,14 @@ public: return {}; } + std::optional getOrPopulateChannelCache( + const QString &channelId) override + { + assert(false && + "unimplemented getOrPopulateChannelCache in mock irc server"); + return {}; + } + void addFakeMessage(const QString &data) override { } @@ -148,6 +157,7 @@ public: ChannelPtr mentionsChannel; ChannelPtr liveChannel; ChannelPtr automodChannel; + UniqueAccess> channelNamesById_; QString lastUserThatWhisperedMe{"forsen"}; std::unordered_map> mockChannels; From c64523f23cdd50c6fce62a4ef8e5df41e3eb84b9 Mon Sep 17 00:00:00 2001 From: iProdigy Date: Sat, 19 Oct 2024 16:12:25 -0700 Subject: [PATCH 4/5] chore: fix snapshot tests --- mocks/include/mocks/TwitchIrcServer.hpp | 8 ++++++-- .../shared-chat-announcement.json | 18 ++++++++++++++++++ .../IrcMessageHandler/shared-chat-emotes.json | 18 ++++++++++++++++++ .../IrcMessageHandler/shared-chat-known.json | 18 ++++++++++++++++++ .../IrcMessageHandler/shared-chat-unknown.json | 18 ++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/mocks/include/mocks/TwitchIrcServer.hpp b/mocks/include/mocks/TwitchIrcServer.hpp index 8f8de58a8..6b305e5ba 100644 --- a/mocks/include/mocks/TwitchIrcServer.hpp +++ b/mocks/include/mocks/TwitchIrcServer.hpp @@ -53,8 +53,12 @@ public: std::optional getOrPopulateChannelCache( const QString &channelId) override { - assert(false && - "unimplemented getOrPopulateChannelCache in mock irc server"); + if (channelId == "11148817") + return "pajlada"; + if (channelId == "141981764") + return "twitchdev"; + if (channelId == "1025594235") + return "shared_chat_test_01"; return {}; } diff --git a/tests/snapshots/IrcMessageHandler/shared-chat-announcement.json b/tests/snapshots/IrcMessageHandler/shared-chat-announcement.json index 9c6fa2f65..4aa99b775 100644 --- a/tests/snapshots/IrcMessageHandler/shared-chat-announcement.json +++ b/tests/snapshots/IrcMessageHandler/shared-chat-announcement.json @@ -64,6 +64,24 @@ "trailingSpace": true, "type": "TwitchModerationElement" }, + { + "emote": { + "homePage": "https://link.twitch.tv/SharedChatViewer", + "images": { + "1x": "" + }, + "name": "SharedChat_shared_chat_test_01", + "tooltip": "Shared Message from shared_chat_test_01" + }, + "flags": "BadgeSharedChannel", + "link": { + "type": "UserInfo", + "value": "shared_chat_test_01" + }, + "tooltip": "Shared Message from shared_chat_test_01", + "trailingSpace": true, + "type": "BadgeElement" + }, { "emote": { "homePage": "https://www.twitch.tv/jobs?ref=chat_badge", diff --git a/tests/snapshots/IrcMessageHandler/shared-chat-emotes.json b/tests/snapshots/IrcMessageHandler/shared-chat-emotes.json index b7f9256d4..ffac89ba1 100644 --- a/tests/snapshots/IrcMessageHandler/shared-chat-emotes.json +++ b/tests/snapshots/IrcMessageHandler/shared-chat-emotes.json @@ -64,6 +64,24 @@ "trailingSpace": true, "type": "TwitchModerationElement" }, + { + "emote": { + "homePage": "https://link.twitch.tv/SharedChatViewer", + "images": { + "1x": "" + }, + "name": "SharedChat_twitchdev", + "tooltip": "Shared Message from twitchdev" + }, + "flags": "BadgeSharedChannel", + "link": { + "type": "UserInfo", + "value": "twitchdev" + }, + "tooltip": "Shared Message from twitchdev", + "trailingSpace": true, + "type": "BadgeElement" + }, { "color": "#ffff0000", "flags": "Username", diff --git a/tests/snapshots/IrcMessageHandler/shared-chat-known.json b/tests/snapshots/IrcMessageHandler/shared-chat-known.json index 6a13adcb4..c01c4a8ae 100644 --- a/tests/snapshots/IrcMessageHandler/shared-chat-known.json +++ b/tests/snapshots/IrcMessageHandler/shared-chat-known.json @@ -64,6 +64,24 @@ "trailingSpace": true, "type": "TwitchModerationElement" }, + { + "emote": { + "homePage": "https://link.twitch.tv/SharedChatViewer", + "images": { + "1x": "" + }, + "name": "SharedChat_twitchdev", + "tooltip": "Shared Message from twitchdev" + }, + "flags": "BadgeSharedChannel", + "link": { + "type": "UserInfo", + "value": "twitchdev" + }, + "tooltip": "Shared Message from twitchdev", + "trailingSpace": true, + "type": "BadgeElement" + }, { "emote": { "homePage": "https://www.twitch.tv/jobs?ref=chat_badge", diff --git a/tests/snapshots/IrcMessageHandler/shared-chat-unknown.json b/tests/snapshots/IrcMessageHandler/shared-chat-unknown.json index accbb76b0..94914f72e 100644 --- a/tests/snapshots/IrcMessageHandler/shared-chat-unknown.json +++ b/tests/snapshots/IrcMessageHandler/shared-chat-unknown.json @@ -64,6 +64,24 @@ "trailingSpace": true, "type": "TwitchModerationElement" }, + { + "emote": { + "homePage": "https://link.twitch.tv/SharedChatViewer", + "images": { + "1x": "" + }, + "name": "SharedChat_shared_chat_test_01", + "tooltip": "Shared Message from shared_chat_test_01" + }, + "flags": "BadgeSharedChannel", + "link": { + "type": "UserInfo", + "value": "shared_chat_test_01" + }, + "tooltip": "Shared Message from shared_chat_test_01", + "trailingSpace": true, + "type": "BadgeElement" + }, { "emote": { "homePage": "https://www.twitch.tv/jobs?ref=chat_badge", From 5638346d7c9c879675437e15b8f98f62ed01a6fe Mon Sep 17 00:00:00 2001 From: iProdigy Date: Sat, 19 Oct 2024 17:27:09 -0700 Subject: [PATCH 5/5] chore: update shared chat icon --- resources/twitch/sharedChat.png | Bin 2006 -> 2407 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/resources/twitch/sharedChat.png b/resources/twitch/sharedChat.png index 116108c77032a75551cea742eae343bc3423c151..ec9f3f51269cdfdc1939cd30315c11396a960fe4 100644 GIT binary patch delta 2397 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 bRDkaQeg>(xo5g3A00000NkvXXu0mjfe7o_D