Show participants in a shared chat session in the split header (#6948)

Reviewed-by: Nerixyz <nerixdev@outlook.de>
Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
invisibl3cat
2026-07-12 12:11:48 +00:00
committed by GitHub
parent 5e73ba7e24
commit 91cf6a4a57
20 changed files with 412 additions and 11 deletions
+7
View File
@@ -534,6 +534,13 @@ public:
(FailureCallback<HelixUnpinMessageError, QString>)failureCallback),
(override));
MOCK_METHOD(void, getSharedChatSession,
(QString broadcasterID,
ResultCallback<HelixSharedChatSession> successCallback,
(FailureCallback<HelixGetSharedChatSessionError, QString>
failureCallback)),
(override));
MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
(override));
@@ -1224,6 +1224,11 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message,
MessageContext::Original);
}
if (msg->flags.has(MessageFlag::SharedMessage))
{
chan->probeSharedChatSession();
}
sink.addMessage(msg, MessageContext::Original);
chan->addRecentChatter(msg->displayName);
}
+152
View File
@@ -120,6 +120,7 @@ TwitchChannel::TwitchChannel(const QString &name)
, bttvEmotes_(std::make_shared<EmoteMap>())
, ffzEmotes_(std::make_shared<EmoteMap>())
, seventvEmotes_(std::make_shared<EmoteMap>())
, nextSharedChatSessionProbe_(QDateTime::currentDateTime())
{
qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened";
@@ -179,6 +180,11 @@ TwitchChannel::TwitchChannel(const QString &name)
});
this->threadClearTimer_.start(5 * 60 * 1000);
QObject::connect(&this->nextSharedChatSessionUpdateTimer_, &QTimer::timeout,
&this->lifetimeGuard_, [this] {
this->refreshSharedChatSessionState();
});
this->signalHolder_.managedConnect(
getApp()->getAccounts()->twitch.emotesReloaded,
[this](auto *caller, const auto &result) {
@@ -2528,6 +2534,152 @@ bool TwitchChannel::isLoadingRecentMessages() const
return this->loadingRecentMessages_.test();
}
const QStringList &TwitchChannel::getSharedChatSessionParticipants() const
{
return this->sharedChatSessionParticipants_;
}
void TwitchChannel::probeSharedChatSession()
{
auto now = QDateTime::currentDateTime();
if (!this->nextSharedChatSessionUpdateTimer_.isActive() &&
now >= this->nextSharedChatSessionProbe_)
{
this->nextSharedChatSessionProbe_ = now.addSecs(30);
this->refreshSharedChatSessionState();
}
}
void TwitchChannel::refreshSharedChatSessionState()
{
getHelix()->getSharedChatSession(
this->roomId(),
[this,
weak = this->weakFromThis()](const HelixSharedChatSession &session) {
const auto self = weak.lock();
if (!self)
{
return;
}
auto intervalSecs = std::clamp(
getSettings()->sharedChatSessionRefreshInterval.getValue(), 5,
999);
this->nextSharedChatSessionUpdateTimer_.setInterval(intervalSecs *
1000);
if (session.participantIds.empty())
{
// Allow immediate re-probe
this->nextSharedChatSessionProbe_ =
QDateTime::currentDateTime();
this->nextSharedChatSessionUpdateTimer_.stop();
this->sharedChatSessionParticipants_.clear();
this->sharedChatSessionParticipantIds_.clear();
this->sharedChatStatusChanged.invoke({});
return;
}
bool participantsDiffer =
session.participantIds.size() - 1 !=
this->sharedChatSessionParticipantIds_.size();
if (!participantsDiffer)
{
for (const auto &broadcasterID : session.participantIds)
{
if (this->roomId() == broadcasterID)
{
continue;
}
if (!this->sharedChatSessionParticipantIds_.contains(
broadcasterID))
{
participantsDiffer = true;
break;
}
}
}
if (!participantsDiffer)
{
return;
}
getHelix()->fetchUsers(
session.participantIds, {},
[this, weak = this->weakFromThis()](const auto &users) {
const auto self = weak.lock();
if (!self)
{
return;
}
this->sharedChatSessionParticipants_.clear();
this->sharedChatSessionParticipantIds_.clear();
for (const auto &user : users)
{
if (user.id != this->roomId())
{
this->sharedChatSessionParticipantIds_.insert(
user.id);
this->sharedChatSessionParticipants_.push_back(
user.displayName);
}
}
this->nextSharedChatSessionUpdateTimer_.start();
this->sharedChatStatusChanged.invoke(
this->sharedChatSessionParticipants_);
},
[] {
qCWarning(chatterinoTwitch) << "Failed to get user info";
});
},
[](HelixGetSharedChatSessionError error, const QString &message) {
QString errorMessage = "Failed to get shared chat session state: ";
switch (error)
{
case HelixGetSharedChatSessionError::InvalidBroadcasterId: {
errorMessage += "Invalid broadcaster ID";
}
break;
case HelixGetSharedChatSessionError::UserMissingScope: {
errorMessage +=
"Missing required scope. Re-login with your "
"account and try again.";
}
break;
case HelixGetSharedChatSessionError::UserNotAuthorized: {
errorMessage +=
"you don't have permission to perform that action.";
}
break;
case HelixGetSharedChatSessionError::Unknown: {
errorMessage += "Unknown error";
}
break;
case HelixGetSharedChatSessionError::Forwarded: {
errorMessage += message;
}
break;
}
qCWarning(chatterinoTwitch) << errorMessage;
});
}
void TwitchChannel::refreshPinnedMessage()
{
auto currentAccount = getApp()->getAccounts()->twitch.getCurrent();
+35
View File
@@ -356,6 +356,8 @@ public:
pajlada::Signals::Signal<const QString &> sendWaitUpdate;
pajlada::Signals::Signal<const QStringList &> sharedChatStatusChanged;
// Channel point rewards
void addQueuedRedemption(const QString &rewardId,
const QString &originalContent,
@@ -401,6 +403,7 @@ public:
bool isLoadingRecentMessages() const;
const QStringList &getSharedChatSessionParticipants() const;
// Pinned message
/**
* Fetches the currently pinned message for this channel via the Helix API.
@@ -459,6 +462,9 @@ private:
/// This should only happen once per channel, whenever the ID goes from unset to set
void roomIdChanged();
void probeSharedChatSession();
void refreshSharedChatSessionState();
/** Joins (subscribes to) a Twitch channel for updates on BTTV. */
void joinBttvChannel() const;
@@ -616,6 +622,35 @@ private:
/** A list of the emotes listed in the lat live emote update message. */
std::vector<QString> lastLiveUpdateEmoteNames_;
/**
* List of display names of broadcasters participating in a
* shared chat session on this channel. The list does not include
* the broadcaster who owns the channel.
* This list is passed to the UI for display.
*/
QStringList sharedChatSessionParticipants_;
/**
* Set of broadcasterIDs of broadcasters participating in a
* shared chat session on this channel. The set does not include
* the broadcaster who owns the channel.
* This set is used to quickly determine if the participants have
* changed since the last query of the shared chat session state.
*/
QSet<QString> sharedChatSessionParticipantIds_;
/**
* Timer scheduling the next check of the shared chat session state.
*/
QTimer nextSharedChatSessionUpdateTimer_;
/**
* Time when the next probe of shared chat session state triggered
* by reception of a shared chat message is allowed.
* Used to rate-limit Twitch API queries.
*/
QDateTime nextSharedChatSessionProbe_;
pajlada::Signals::SignalHolder signalHolder_;
eventsub::SubscriptionHandle eventSubChannelModerateHandle;
+76
View File
@@ -3753,6 +3753,82 @@ void Helix::createEventSubSubscription(
.execute();
}
void Helix::getSharedChatSession(
QString broadcasterID,
ResultCallback<HelixSharedChatSession> successCallback,
FailureCallback<HelixGetSharedChatSessionError, QString> failureCallback)
{
using Error = HelixGetSharedChatSessionError;
this->makeGet("shared_chat/session", {{u"broadcaster_id"_s, broadcasterID}})
.onSuccess([successCallback](const NetworkResult &result) {
if (result.status() != 200)
{
qCWarning(chatterinoTwitch)
<< "Success result for getting shared chat session was "
<< result.formatError() << " but we expected it to be 200";
}
const auto response = result.parseJson();
const auto session = response["data"_L1].toArray().at(0);
successCallback(HelixSharedChatSession(session.toObject()));
})
.onError([failureCallback](const NetworkResult &result) -> void {
if (!result.status())
{
failureCallback(Error::Unknown, result.formatError());
return;
}
const auto obj = result.parseJson();
auto message = obj["message"].toString();
switch (*result.status())
{
case 400: {
failureCallback(Error::InvalidBroadcasterId, message);
}
break;
case 401: {
if (message.startsWith("Missing scope",
Qt::CaseInsensitive))
{
failureCallback(Error::UserMissingScope, message);
}
else
{
failureCallback(Error::UserNotAuthorized, message);
}
}
break;
case 500: {
if (message.isEmpty())
{
failureCallback(Error::Unknown,
"Twitch internal server error");
}
else
{
failureCallback(Error::Unknown, message);
}
}
break;
default: {
qCWarning(chatterinoTwitch)
<< "Helix get shared chat session, unhandled error "
"data:"
<< result.formatError() << result.getData() << obj;
failureCallback(Error::Forwarded, message);
}
}
})
.execute();
}
QDebug &operator<<(QDebug &dbg,
const HelixCreateEventSubSubscriptionResponse &data)
{
+29
View File
@@ -608,6 +608,21 @@ struct HelixPredictions {
}
};
struct HelixSharedChatSession {
QStringList participantIds;
explicit HelixSharedChatSession(const QJsonObject &jsonObject)
{
const auto &participants = jsonObject.value("participants").toArray();
for (const auto p : participants)
{
const auto broadcasterId =
p.toObject().value("broadcaster_id").toString();
this->participantIds.push_back(broadcasterId);
}
}
};
struct HelixStartCommercialResponse {
// Length of the triggered commercial
int length;
@@ -1182,6 +1197,13 @@ public:
const QString &messageID, ResultCallback<> successCallback,
FailureCallback<HelixUnpinMessageError, QString> failureCallback) = 0;
// https://dev.twitch.tv/docs/api/reference/#get-shared-chat-session
virtual void getSharedChatSession(
QString broadcasterID,
ResultCallback<HelixSharedChatSession> successCallback,
FailureCallback<HelixGetSharedChatSessionError, QString>
failureCallback) = 0;
virtual void update(QString clientId, QString oauthToken) = 0;
protected:
@@ -1611,6 +1633,13 @@ public:
const QString &messageID, ResultCallback<> successCallback,
FailureCallback<HelixUnpinMessageError, QString> failureCallback) final;
// https://dev.twitch.tv/docs/api/reference/#get-shared-chat-session
void getSharedChatSession(
QString broadcasterID,
ResultCallback<HelixSharedChatSession> successCallback,
FailureCallback<HelixGetSharedChatSessionError, QString>
failureCallback) final;
void update(QString clientId, QString oauthToken) final;
static void initialize();
+10
View File
@@ -327,4 +327,14 @@ enum class HelixUnpinMessageError : std::uint8_t {
Forwarded,
};
enum class HelixGetSharedChatSessionError : std::uint8_t {
Unknown,
InvalidBroadcasterId,
UserMissingScope,
UserNotAuthorized,
// The error message is forwarded directly from the Twitch API
Forwarded,
};
} // namespace chatterino
+3
View File
@@ -411,6 +411,9 @@ public:
false,
};
IntSetting sharedChatSessionRefreshInterval = {
"/behaviour/sharedChatSessionRefreshInterval", 60};
/// Emotes
BoolSetting scaleEmotesByLineHeight = {"/emotes/scaleEmotesByLineHeight",
false};
@@ -1632,6 +1632,14 @@ void GeneralPage::initLayout(GeneralPageView &layout)
"double-clicked")
->addTo(layout);
SettingWidget::intInput(
"Shared chat session status refresh interval",
s.sharedChatSessionRefreshInterval,
{.min = 5, .max = 999, .singleStep = 1, .suffix = "s"})
->setTooltip("How often Chatterino polls the Twitch API for the "
"shared chat session status.")
->addTo(layout);
layout.addStretch();
// invisible element for width
+4
View File
@@ -864,6 +864,10 @@ void Split::setChannel(IndirectChannel newChannel)
this->getInput().setSendWaitStatus(text);
});
this->channelSignalHolder_.managedConnect(
tc->sharedChatStatusChanged, [this](const QStringList &) {
this->header_->updateChannelText();
});
this->pinnedBanner_->setChannel(tc);
}
else
+14 -3
View File
@@ -185,7 +185,8 @@ auto formatOfflineTooltip(const TwitchChannel::StreamStatus &s)
.arg(s.title.toHtmlEscaped());
}
auto formatTitle(const TwitchChannel::StreamStatus &s, Settings &settings)
auto formatTitle(const TwitchChannel::StreamStatus &s, Settings &settings,
const QStringList &sharedChatParticipants)
{
auto title = QString();
@@ -200,7 +201,14 @@ auto formatTitle(const TwitchChannel::StreamStatus &s, Settings &settings)
}
else
{
title += " (live)";
if (sharedChatParticipants.isEmpty())
{
title += " (live)";
}
else
{
title += " (live with " + sharedChatParticipants.join(", ") + ")";
}
}
// description
@@ -978,7 +986,10 @@ void SplitHeader::updateChannelText()
this->lastThumbnail_.restart();
}
this->tooltipText_ = formatTooltip(*streamStatus, this->thumbnail_);
title += formatTitle(*streamStatus, *getSettings());
title +=
formatTitle(*streamStatus, *getSettings(),
twitchChannel->getSharedChatSessionParticipants());
}
else
{
@@ -274,5 +274,10 @@
"userID": "",
"usernameColor": "#ff000000"
}
]
],
"params": {
"helixExpectations": {
"getSharedChatSession": 1
}
}
}
@@ -307,5 +307,10 @@
"userID": "129546453",
"usernameColor": "#ffff0000"
}
]
],
"params": {
"helixExpectations": {
"getSharedChatSession": 1
}
}
}
@@ -200,5 +200,10 @@
"userID": "106940612",
"usernameColor": "#ff00ff00"
}
]
],
"params": {
"helixExpectations": {
"getSharedChatSession": 1
}
}
}
@@ -178,5 +178,10 @@
"userID": "100229878",
"usernameColor": "#ff00ff7f"
}
]
],
"params": {
"helixExpectations": {
"getSharedChatSession": 1
}
}
}
@@ -186,5 +186,10 @@
"userID": "100229878",
"usernameColor": "#ff00ff7f"
}
]
],
"params": {
"helixExpectations": {
"getSharedChatSession": 1
}
}
}
@@ -200,5 +200,10 @@
"userID": "106940612",
"usernameColor": "#ff00ff00"
}
]
],
"params": {
"helixExpectations": {
"getSharedChatSession": 1
}
}
}
@@ -189,5 +189,10 @@
"userID": "100229878",
"usernameColor": "#ff00ff7f"
}
]
],
"params": {
"helixExpectations": {
"getSharedChatSession": 1
}
}
}
@@ -187,5 +187,10 @@
"userID": "100229878",
"usernameColor": "#ff00ff7f"
}
]
],
"params": {
"helixExpectations": {
"getSharedChatSession": 1
}
}
}
+21
View File
@@ -16,6 +16,7 @@
#include "mocks/ChatterinoBadges.hpp"
#include "mocks/DisabledStreamerMode.hpp"
#include "mocks/EmoteController.hpp"
#include "mocks/Helix.hpp"
#include "mocks/LinkResolver.hpp"
#include "mocks/Logging.hpp"
#include "mocks/TwitchIrcServer.hpp"
@@ -160,6 +161,7 @@ public:
mock::EmptyLogging logging;
AccountController accounts;
mock::EmoteController emotes;
mock::Helix helix;
mock::UserDataController userData;
mock::MockTwitchIrcServer twitch;
mock::ChatterinoBadges chatterinoBadges;
@@ -561,6 +563,21 @@ public:
this->mockApplication->twitch.mockChannels.emplace(
"twitchdev", this->twitchdevChannel);
const auto helixExpectations =
this->snapshot->param("helixExpectations").toObject();
if (!helixExpectations.isEmpty())
{
initializeHelix(&this->mockHelix);
int nCalls =
helixExpectations.value("getSharedChatSession").toInt();
if (nCalls > 0)
{
EXPECT_CALL(this->mockHelix, getSharedChatSession)
.Times(nCalls);
}
}
}
void TearDown() override
@@ -573,6 +590,7 @@ public:
std::shared_ptr<TwitchChannel> twitchdevChannel;
std::unique_ptr<MockApplication> mockApplication;
std::unique_ptr<testlib::Snapshot> snapshot;
testing::StrictMock<mock::Helix> mockHelix;
};
/// This tests the process of parsing IRC messages and emitting `MessagePtr`s.
@@ -593,6 +611,9 @@ public:
/// - `findAllUsernames`: A boolean controlling the equally named setting
/// (default: false)
/// - `nAdditional`: Include n additional built messages (from `prevMessages`)
/// - `helixExpectations`: An object with names of Helix API methods that will
/// be called during the test and the expected call count. Name of the method
/// is the key and the number of calls its value.
TEST_P(TestIrcMessageHandlerP, Run)
{
auto channel = makeMockTwitchChannel(u"pajlada"_s, *snapshot);