diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3555762ce..f8e602862 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@
- Major: Improve high-DPI support on Windows. (#4868, #5391, #5664, #5666)
- 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)
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/mocks/include/mocks/TwitchIrcServer.hpp b/mocks/include/mocks/TwitchIrcServer.hpp
index d218192b3..6b305e5ba 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,18 @@ public:
return {};
}
+ std::optional getOrPopulateChannelCache(
+ const QString &channelId) override
+ {
+ if (channelId == "11148817")
+ return "pajlada";
+ if (channelId == "141981764")
+ return "twitchdev";
+ if (channelId == "1025594235")
+ return "shared_chat_test_01";
+ return {};
+ }
+
void addFakeMessage(const QString &data) override
{
}
@@ -148,6 +161,7 @@ public:
ChannelPtr mentionsChannel;
ChannelPtr liveChannel;
ChannelPtr automodChannel;
+ UniqueAccess> channelNamesById_;
QString lastUserThatWhisperedMe{"forsen"};
std::unordered_map> mockChannels;
diff --git a/resources/twitch/sharedChat.png b/resources/twitch/sharedChat.png
new file mode 100644
index 000000000..f9a66b17c
Binary files /dev/null and b/resources/twitch/sharedChat.png differ
diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp
index db479adf3..efc885a49 100644
--- a/src/messages/MessageBuilder.cpp
+++ b/src/messages/MessageBuilder.cpp
@@ -380,6 +380,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"}});
+}
+
std::tuple, MessageElementFlags, bool> parseEmote(
TwitchChannel *twitchChannel, const EmoteName &name)
{
@@ -2746,6 +2757,28 @@ void MessageBuilder::appendTwitchBadges(const QVariantMap &tags,
return;
}
+ if (this->message().flags.has(MessageFlag::SharedMessage))
+ {
+ QString sourceId = tags["source-room-id"].toString();
+ std::optional sourceName;
+ if (twitchChannel->roomId() == sourceId)
+ {
+ sourceName = twitchChannel->getName();
+ }
+ else
+ {
+ sourceName =
+ getApp()->getTwitch()->getOrPopulateChannelCache(sourceId);
+ }
+
+ if (sourceName.has_value())
+ {
+ this->emplace(makeSharedChatBadge(sourceName.value()),
+ MessageElementFlag::BadgeSharedChannel)
+ ->setLink({Link::UserInfo, sourceName.value()});
+ }
+ }
+
auto badgeInfos = parseBadgeInfoTag(tags);
auto badges = parseBadgeTag(tags);
appendBadges(this, badges, badgeInfos, twitchChannel);
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
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",