feat: add pinned message banner (#7014)

Reviewed-by: Mm2PL <mm2pl+gh@kotmisia.pl>
Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
Reviewed-by: Nerixyz <nerixdev@outlook.de>
This commit is contained in:
tlos
2026-07-12 11:17:19 +02:00
committed by GitHub
parent 597a2c57dd
commit 7d4f10e60f
21 changed files with 925 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="108 77 202 214"><path fill="#424242" d="M274.369 82.725C270.046 82.058 269.586 82.587 268.344 82.909 264.504 84.828 262.4 86.614 262.4 87.105 261.838 87.8363 261.276 88.5677 259.771 92.381 259.38 93.899 259.393 97.838 260.134 98.564 260.258 98.9 260.436 99.253 260.655 99.619 260.724 99.746 260.768 99.746 261.221 100.189 262.326 100.84 261.343 102.372 256.235 107.556 253.284 110.55 250.27 113.675 249.538 114.5 248.218 115.986 247.314 116.335 246.58 115.641 245.334 114.464 235.066 114.464 233.788 115.425 224.522 120.382 222.86 122.401 222.445 122.761 218.674 126.031 219.33 127.588 228.207 136.44L235.299 143.512 233.988 145.418C230.065 150.746 226.528 155.414 225.7 156.287 215.233 172.391 214.639 173.579 214.113 174.6 211.455 179.756 211.331 180.8 213.376 180.8 214.133 180.8 214.864 180.62 215 180.4 223.329 176.787 233.447 169.236 236.575 166.322 246.102 158.379 248.368 156.639 248.591 156.844 248.685 156.93 251.824 160.015 255.568 163.7 259.736 167.803 262.68 170.4 263.164 170.4 264.987 172.073 268.663 170.239 270.592 167.611 272.803 164.748 274.308 161.702 274.8 160.2 276.44 155.627 276.059 145.825 275.632 145.277 275.2 143.687 274.98 143.193 274.712 143.104 274.134 142.911 287.887 128.8 288.652 128.8 288.94 128.8 289.817 129.16 290.6 129.6 291.383 130.04 292.559 130.4 293.212 130.4 297.136 130.58 297.676 130.4 298.2 130.4 301.299 129.6 305.116 125.694 305.878 123.957 307.325 120.972 307.3 116.587 306.8 115.595 305.6 112.534 304.88 111.624 304 110.753 303.12 109.882 302.4 109.064 302.4 108.935 302.4 108.807 297.855 104.172 292.3 98.636 286.745 93.099 281.431 87.721 280.492 86.685 277.592 84.02 275.54 83.2 274.323 82.725M271.918 89.297C273.832 90.4 279.941 96.203 287.059 103.296L300 116.191C300.473 117 300.76 117.806 300.545 119.06 300 121.965 298.168 124 296.635 124.436 295.739 124.505 295.417 124.268 295.005 123.809 295.273 124.2 289.384 118.71 282.861 112.245 276.337 105.78 270.145 99.671 269.1 98.669 266.323 95.91 265.965 95.534 266.111 94.129 266.753 91.459 271.731 88.594 271.918 89.274M275.495 114.9C280.337 119.724 283.183 122.85 283.112 123.268 283.051 123.636 279.445 127.55 275.1 131.966L267.2 139.996C267.0763 140.076 266.238 140.693 267.151 141.616 268.821 145.085 269.503 149.175 269.2 152.886 269.086 155.348 266.852 161.255 266.216 162.143L265.233 163.286 247.017 145.155C236.997 135.183 228.803 126.749 228.807 126.412L230.06 125.608C233.52 123.629 238.153 122.272 243.023 122.31 246.296 122.415 247.969 122.973 249.274 124.147 249.655 124.556 249.999 124.563 250.172 124.355 250.501 123.915 266.319 107.2 267.178 107.2 267.501 107.2 271.243 110.665 275.495 114.9M137 124.4C132.421 125.6 130.544 126.05 130.107 126.487 127.317 127.219 117.6 137.149 117.6 139.57 117.3223 140.215 117.0447 140.86 116.767 141.505 114.591 146.449 115.43 239.2 116.444 239.2 117.6 242.477 117.96 243.22 118.4 243.692 121.326 248.41 126.752 253.002 129.1 254.028 136.957 256.8 138.463 257.013 139.593 257.274L141.647 257.748 141.523 268.474C141.438 275.862 141.532 279.201 141.825 279.2 143 283 146.957 285.6 148.22 285.6 149.722 286.521 153.478 286.521 153.8 286 161.401 281.72 180.616 268.8 180.989 268.8 184.312 266.4 187.111 264.6 190.4 262.4 193.689 260.2 196.565 258.4 196.79 258.4 197.2 257.765 206.843 257.563 223.5 257.46 248.95 257.304 252.377 257.183 254.3 256.377 255.475 255.885 256.85 255.33 257.6 255.045 259.114 254.47 265.59 249.863 265.926 249.122 271 243 271 242 272.364 238 273 237 273 184 273.03 181.808 273 179 273 178 269 178 265 178 264 179 264 182 263.746 232.091 263 234 262.755 236 260.785 241.032 256.507 244.825 254.28 245.924 250.642 247.718 250.883 247.704 221.66 247.866L194.72 248.016 192.988 249.208 152 276.4 151 277C151 277 151 272 151.105 262.5L151 248.2 145.4 247.947C139.701 247.69 137.707 247.438 136.1 246.773 134.017 245.91 132.97 245.372 132.485 244.914 129.568 242.995 127.576 240.438 126.382 238 125.897 237.01 125.342 235.887 125.15 235.505 124.644 234.502 124.694 146.4 125.2 146.4 126.4 142.734 130.648 137.6 131.302 137.302 132 136.481 137.102 134.315 138.085 134.267 139.949 133.666 143.696 133.6 174.769 133.6 210.76 133.6 211.165 133.583 211.755 132.227 213.486 130.053 213.017 126.506 212.02 124.199L210.2 124.025 173.724 124.013C149.824 124.004 137.162 124.138 137 124.4M241.479 149.391C243.454 151.114 243.58 151.57 240.735 153.694 233.55 160.006 232 160.289 232 159.662 233.6 157.297 234.68 155.796 236 154.168 238.4 150.642 238.951 150.053 240.284 148.8 240.622 148.637 241.117 149.074 241.475 149.383 M 163 204 A 1 1 0 0 0 172 204 A 1 1 0 0 0 163 204 M 193 204 A 1 1 0 0 0 202 204 A 1 1 0 0 0 193 204 M 221 204 A 1 1 0 0 0 230 204 A 1 1 0 0 0 221 204"/></svg>
+4
View File
@@ -506,6 +506,8 @@ set(SOURCE_FILES
providers/twitch/pubsubmessages/Listen.cpp
providers/twitch/pubsubmessages/Listen.hpp
providers/twitch/pubsubmessages/Message.hpp
providers/twitch/pubsubmessages/PinnedChatUpdates.cpp
providers/twitch/pubsubmessages/PinnedChatUpdates.hpp
providers/twitch/pubsubmessages/Unlisten.cpp
providers/twitch/pubsubmessages/Unlisten.hpp
@@ -868,6 +870,8 @@ set(SOURCE_FILES
widgets/splits/SplitInput.hpp
widgets/splits/SplitOverlay.cpp
widgets/splits/SplitOverlay.hpp
widgets/splits/PinnedMessageWidget.cpp
widgets/splits/PinnedMessageWidget.hpp
)
if (APPLE)
+15
View File
@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#pragma once
namespace chatterino {
enum UsernameDisplayMode : int {
Username = 1, // Username
LocalizedName = 2, // Localized name
UsernameAndLocalizedName = 3, // Username (Localized name)
};
} // namespace chatterino
+40
View File
@@ -162,6 +162,46 @@ void PubSubClient::handleResponse(const PubSubMessage &message)
void PubSubClient::handleMessageResponse(const PubSubMessageMessage &message)
{
if (message.topic.startsWith("pinned-chat-updates-v1."))
{
auto oInnerMessage =
message.toInner<PubSubPinnedChatUpdatesV1Message>();
if (!oInnerMessage)
{
qCDebug(chatterinoPubSub)
<< "Malformed pinned-chat-updates-v1 message";
return;
}
const auto &innerMessage = *oInnerMessage;
// strip the "pinned-chat-updates-v1." prefix
const auto channelId = message.topic.sliced(
static_cast<qsizetype>(sizeof("pinned-chat-updates-v1.") - 1));
switch (innerMessage.type)
{
case PubSubPinnedChatUpdatesV1Message::Type::PinMessage:
case PubSubPinnedChatUpdatesV1Message::Type::UpdateMessage: {
this->manager_.pinnedChatUpdates.pinned.invoke(channelId);
}
break;
case PubSubPinnedChatUpdatesV1Message::Type::UnpinMessage: {
this->manager_.pinnedChatUpdates.unpinned.invoke(channelId);
}
break;
case PubSubPinnedChatUpdatesV1Message::Type::INVALID:
default: {
qCDebug(chatterinoPubSub) << "Invalid pinned-chat-updates-v1 "
"event type:"
<< innerMessage.typeString;
}
break;
}
return;
}
if (!message.topic.startsWith("community-points-channel-v1."))
{
return;
+11
View File
@@ -102,4 +102,15 @@ void PubSub::listenToChannelPointRewards(const QString &channelID)
this->private_->subscribe(TopicData{.topic = std::move(topic)});
}
void PubSub::listenToPinnedChatUpdates(const QString &channelID)
{
static const QString topicFormat("pinned-chat-updates-v1.%1");
assert(!channelID.isEmpty());
auto topic = topicFormat.arg(channelID);
qCDebug(chatterinoPubSub) << "Listen to topic" << topic;
this->private_->subscribe(TopicData{.topic = std::move(topic)});
}
} // namespace chatterino
+17
View File
@@ -49,6 +49,15 @@ public:
Signal<const QJsonObject &> redeemed;
} pointReward;
struct {
/// Emitted when a message is pinned or its pin is updated.
/// The argument is the channel name.
Signal<const QString &> pinned;
/// Emitted when the pinned message is removed.
/// The argument is the channel name.
Signal<const QString &> unpinned;
} pinnedChatUpdates;
/**
* Listen to incoming channel point redemptions in the given channel.
* This topic is relevant for everyone.
@@ -57,6 +66,14 @@ public:
*/
void listenToChannelPointRewards(const QString &channelID);
/**
* Listen to real time pin/unpin events in the given channel.
* This topic is relevant for everyone.
*
* PubSub topic: pinned-chat-updates-v1.{channelID}
*/
void listenToPinnedChatUpdates(const QString &channelID);
struct {
std::atomic<uint32_t> messagesReceived{0};
std::atomic<uint32_t> messagesFailedToParse{0};
+3 -2
View File
@@ -6,6 +6,7 @@
#include "providers/twitch/pubsubmessages/Base.hpp" // IWYU pragma: export
#include "providers/twitch/pubsubmessages/ChannelPoints.hpp" // IWYU pragma: export
#include "providers/twitch/pubsubmessages/Listen.hpp" // IWYU pragma: export
#include "providers/twitch/pubsubmessages/Message.hpp" // IWYU pragma: export
#include "providers/twitch/pubsubmessages/Listen.hpp" // IWYU pragma: export
#include "providers/twitch/pubsubmessages/Message.hpp" // IWYU pragma: export
#include "providers/twitch/pubsubmessages/PinnedChatUpdates.hpp" // IWYU pragma: export
#include "providers/twitch/pubsubmessages/Unlisten.hpp" // IWYU pragma: export
+90
View File
@@ -769,6 +769,7 @@ void TwitchChannel::roomIdChanged()
this->joinBttvChannel();
this->listenSevenTVCosmetics();
getApp()->getTwitchLiveController()->add(this->sharedFromThis());
this->refreshPinnedMessage();
}
QString TwitchChannel::prepareMessage(const QString &message) const
@@ -922,6 +923,12 @@ void TwitchChannel::setMod(bool value)
this->mod_ = value;
this->userStateChanged.invoke();
if (value)
{
// Gained mod privileges - fetch the current pin
this->refreshPinnedMessage();
}
}
}
@@ -1559,6 +1566,7 @@ void TwitchChannel::refreshPubSub()
auto currentAccount = getApp()->getAccounts()->twitch.getCurrent();
getApp()->getTwitchPubSub()->listenToChannelPointRewards(roomId);
getApp()->getTwitchPubSub()->listenToPinnedChatUpdates(roomId);
if (currentAccount->isAnon())
{
@@ -2520,4 +2528,86 @@ bool TwitchChannel::isLoadingRecentMessages() const
return this->loadingRecentMessages_.test();
}
void TwitchChannel::refreshPinnedMessage()
{
auto currentAccount = getApp()->getAccounts()->twitch.getCurrent();
if (!currentAccount || currentAccount->isAnon())
{
return;
}
const auto requestId = ++this->pinnedMessageRequestId_;
getHelix()->getPinnedChatMessage(
this->roomId(), currentAccount->getUserId(),
[weak = this->weakFromThis(),
requestId](std::optional<HelixPinnedChatMessage> msg) {
auto self = weak.lock();
if (!self || self->pinnedMessageRequestId_ != requestId)
{
return;
}
if (msg)
{
self->pinnedMessage_ =
std::make_unique<const HelixPinnedChatMessage>(
std::move(*msg));
}
else
{
self->pinnedMessage_ = nullptr;
}
self->pinnedMessageChanged.invoke();
},
[](const QString &error) {
qCWarning(chatterinoTwitch)
<< "Failed to fetch pinned message:" << error;
});
}
const HelixPinnedChatMessage *TwitchChannel::getPinnedMessage() const
{
return this->pinnedMessage_.get();
}
void TwitchChannel::clearPinnedMessage()
{
if (!this->pinnedMessage_)
{
return;
}
this->pinnedMessage_.reset();
this->pinnedMessageChanged.invoke();
}
void TwitchChannel::unpinCurrentMessage()
{
if (!this->pinnedMessage_)
{
return;
}
auto currentAccount = getApp()->getAccounts()->twitch.getCurrent();
if (!currentAccount || currentAccount->isAnon())
{
return;
}
const auto msgId = this->pinnedMessage_->messageID;
getHelix()->unpinChatMessage(
this->roomId(), currentAccount->getUserId(), msgId,
[weak = this->weakFromThis()] {
auto self = weak.lock();
if (!self)
{
return;
}
self->pinnedMessage_.reset();
self->pinnedMessageChanged.invoke();
},
[](HelixUnpinMessageError /*error*/, const QString &message) {
qCWarning(chatterinoTwitch)
<< "Failed to unpin message:" << message;
});
}
} // namespace chatterino
+32
View File
@@ -25,6 +25,7 @@
#include <QRegularExpression>
#include <atomic>
#include <memory>
#include <mutex>
#include <optional>
#include <unordered_map>
@@ -61,6 +62,7 @@ struct HelixStream;
struct HelixCheermoteSet;
struct HelixGlobalBadges;
using HelixChannelBadges = HelixGlobalBadges;
struct HelixPinnedChatMessage;
class TwitchIrcServer;
class TwitchAccount;
@@ -399,6 +401,30 @@ public:
bool isLoadingRecentMessages() const;
// Pinned message
/**
* Fetches the currently pinned message for this channel via the Helix API.
* Only has effect when the local user has moderator privileges.
*/
void refreshPinnedMessage();
/**
* Clears the pinned message for this channel immediately (e.g. on unpin
* PubSub event).
*/
void clearPinnedMessage();
/// Returns the currently pinned message, or null if none is pinned.
const HelixPinnedChatMessage *getPinnedMessage() const;
/**
* Unpin the currently pinned message. Only valid for moderators.
*/
void unpinCurrentMessage();
/// Fires when the pinned message changes (set, cleared, or updated).
pajlada::Signals::NoArgSignal pinnedMessageChanged;
private:
struct NameOptions {
// displayName is the non-CJK-display name for this user
@@ -600,6 +626,12 @@ private:
eventsub::SubscriptionHandle eventSubChannelChatUserMessageHoldHandle;
eventsub::SubscriptionHandle eventSubChannelChatUserMessageUpdateHandle;
/// May be null if no message is currently pinned.
std::unique_ptr<const HelixPinnedChatMessage> pinnedMessage_;
/// Incremented before each getPinnedChatMessage request so that stale
/// responses from earlier requests are discarded.
uint64_t pinnedMessageRequestId_ = 0;
friend class TwitchIrcServer;
friend class MessageBuilder;
friend class IrcMessageHandler;
+32
View File
@@ -254,6 +254,38 @@ void TwitchIrcServer::initialize()
}
});
});
this->signalHolder.managedConnect(
getApp()->getTwitchPubSub()->pinnedChatUpdates.pinned,
[this](const QString &channelId) {
auto chan = this->getChannelOrEmptyByID(channelId);
postToThread([chan] {
if (isAppAboutToQuit())
{
return;
}
if (auto *channel = dynamic_cast<TwitchChannel *>(chan.get()))
{
channel->refreshPinnedMessage();
}
});
});
this->signalHolder.managedConnect(
getApp()->getTwitchPubSub()->pinnedChatUpdates.unpinned,
[this](const QString &channelId) {
auto chan = this->getChannelOrEmptyByID(channelId);
postToThread([chan] {
if (isAppAboutToQuit())
{
return;
}
if (auto *channel = dynamic_cast<TwitchChannel *>(chan.get()))
{
channel->clearPinnedMessage();
}
});
});
}
void TwitchIrcServer::aboutToQuit()
+26
View File
@@ -5,6 +5,7 @@
#pragma once
#include "common/Aliases.hpp"
#include "common/enums/UsernameDisplayMode.hpp"
#include "common/network/NetworkRequest.hpp"
#include "providers/twitch/api/HelixEnums.hpp"
#include "providers/twitch/eventsub/SubscriptionRequest.hpp"
@@ -58,6 +59,31 @@ struct HelixMinimalUser {
QString id;
QString login;
QString displayName;
/// Returns the display name formatted according to @a mode.
[[nodiscard]] QString formatted(UsernameDisplayMode mode) const
{
const bool hasLocalizedName =
this->displayName.compare(this->login, Qt::CaseInsensitive) != 0;
switch (mode)
{
case UsernameDisplayMode::Username:
return this->login;
case UsernameDisplayMode::LocalizedName:
return hasLocalizedName ? this->displayName : this->login;
default:
case UsernameDisplayMode::UsernameAndLocalizedName:
if (hasLocalizedName)
{
return this->login + QStringLiteral(" (") +
this->displayName + QStringLiteral(")");
}
return this->login;
}
}
};
struct HelixGetChannelFollowersResponse {
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#include "providers/twitch/pubsubmessages/PinnedChatUpdates.hpp"
#include "util/QMagicEnum.hpp"
namespace chatterino {
PubSubPinnedChatUpdatesV1Message::PubSubPinnedChatUpdatesV1Message(
const QJsonObject &root)
: typeString(root.value("type").toString())
, data(root.value("data").toObject())
{
auto oType = qmagicenum::enumCast<Type>(this->typeString);
if (oType.has_value())
{
this->type = oType.value();
}
}
} // namespace chatterino
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#pragma once
#include <magic_enum/magic_enum.hpp>
#include <QJsonObject>
#include <QString>
#include <cstdint>
namespace chatterino {
struct PubSubPinnedChatUpdatesV1Message {
enum class Type : std::uint8_t {
PinMessage,
UnpinMessage,
UpdateMessage,
INVALID,
};
QString typeString;
Type type = Type::INVALID;
QJsonObject data;
PubSubPinnedChatUpdatesV1Message(const QJsonObject &root);
};
} // namespace chatterino
template <>
constexpr magic_enum::customize::customize_t
magic_enum::customize::enum_name< // NOLINT(readability-identifier-naming)
chatterino::PubSubPinnedChatUpdatesV1Message::Type>(
chatterino::PubSubPinnedChatUpdatesV1Message::Type value) noexcept
{
switch (value)
{
case chatterino::PubSubPinnedChatUpdatesV1Message::Type::PinMessage:
return "pin-message";
case chatterino::PubSubPinnedChatUpdatesV1Message::Type::UnpinMessage:
return "unpin-message";
case chatterino::PubSubPinnedChatUpdatesV1Message::Type::UpdateMessage:
return "update-message";
default:
return default_tag; // NOLINT(clazy-rule-of-two-soft)
}
}
+3 -6
View File
@@ -6,6 +6,7 @@
#include "common/ChatterinoSetting.hpp"
#include "common/enums/MessageOverflow.hpp"
#include "common/enums/UsernameDisplayMode.hpp"
#include "common/LastMessageLineStyle.hpp"
#include "common/SignalVector.hpp"
#include "common/StreamerModeSetting.hpp"
@@ -58,12 +59,6 @@ class Modes;
void _actuallyRegisterSetting(
std::weak_ptr<pajlada::Settings::SettingData> setting);
enum UsernameDisplayMode : int {
Username = 1, // Username
LocalizedName = 2, // Localized name
UsernameAndLocalizedName = 3, // Username (Localized name)
};
enum UsernameRightClickBehavior : int {
Reply = 0,
Mention = 1,
@@ -311,6 +306,8 @@ public:
};
/// Behaviour
BoolSetting alwaysShowPinnedMessage = {"/behaviour/alwaysShowPinnedMessage",
false};
BoolSetting allowDuplicateMessages = {"/behaviour/allowDuplicateMessages",
true};
BoolSetting mentionUsersWithAt = {"/behaviour/mentionUsersWithAt", false};
@@ -1411,6 +1411,13 @@ void GeneralPage::initLayout(GeneralPageView &layout)
s.autoCloseThreadPopup)
->addTo(layout);
SettingWidget::checkbox("Always show pinned channel message",
s.alwaysShowPinnedMessage)
->setTooltip(
"When enabled, pinned messages will stay visible instead of "
"automatically hiding after a few seconds.")
->addTo(layout);
SettingWidget::checkbox("Lowercase domains (anti-phishing)",
s.lowercaseDomains)
->setTooltip(
+401
View File
@@ -0,0 +1,401 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#include "widgets/splits/PinnedMessageWidget.hpp"
#include "Application.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "singletons/Settings.hpp"
#include "singletons/Theme.hpp"
#include "widgets/buttons/DrawnButton.hpp"
#include <QHBoxLayout>
#include <QLabel>
#include <QMenu>
#include <QPainter>
#include <QPaintEvent>
#include <QScrollArea>
#include <QShowEvent>
#include <QTimer>
#include <QVBoxLayout>
using namespace std::chrono_literals;
using namespace Qt::Literals;
#include <chrono>
#include <memory>
#include <optional>
namespace chatterino {
namespace {
constexpr auto MUTED_STYLE = "color: #adadb8;";
} // namespace
PinnedMessageWidget::PinnedMessageWidget(QWidget *parent)
: BaseWidget(parent)
, pinnedByLabel_(new QLabel(this))
, countdownLabel_(new QLabel(this))
, menuButton_(new DrawnButton(DrawnButton::Symbol::Kebab, {}, this))
, messageScrollArea_(new QScrollArea(this))
, messageLabel_(new QLabel(this))
, footerLabel_(new QLabel(this))
, progressTimer_(new QTimer(this))
, autoHideTimer_(new QTimer(this))
{
this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum);
auto *outerBox = new QVBoxLayout(this);
outerBox->setContentsMargins(0, 0, 0, 0);
outerBox->setSpacing(0);
auto *contentBox = new QVBoxLayout();
contentBox->setContentsMargins(8, 6, 8, 6);
contentBox->setSpacing(3);
// Header row: "Pinned by <user>" [⋮]
auto *headerRow = new QHBoxLayout();
headerRow->setSpacing(4);
headerRow->addWidget(this->pinnedByLabel_);
headerRow->addStretch(1);
this->menuButton_->setScaleIndependentSize(28, 28);
this->menuButton_->setToolTip(u"Mod options"_s);
this->menuButton_->setMenu(this->buildModMenu());
this->menuButton_->hide();
headerRow->addWidget(this->menuButton_);
contentBox->addLayout(headerRow);
// Message body
this->messageLabel_->setWordWrap(true);
this->messageLabel_->setTextFormat(Qt::PlainText);
this->messageLabel_->setAlignment(Qt::AlignTop | Qt::AlignLeft);
this->messageLabel_->setStyleSheet("background: transparent;");
this->messageLabel_->setSizePolicy(QSizePolicy::Expanding,
QSizePolicy::Preferred);
this->messageScrollArea_->setWidgetResizable(true);
this->messageScrollArea_->setHorizontalScrollBarPolicy(
Qt::ScrollBarAlwaysOff);
this->messageScrollArea_->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
this->messageScrollArea_->setFrameShape(QFrame::NoFrame);
this->messageScrollArea_->setFocusPolicy(Qt::NoFocus);
this->messageScrollArea_->setStyleSheet(
"QScrollArea { background: transparent; } "
"QScrollArea > QWidget > QWidget { background: transparent; }");
this->messageScrollArea_->viewport()->setAutoFillBackground(false);
this->messageScrollArea_->setSizePolicy(QSizePolicy::Expanding,
QSizePolicy::Fixed);
this->messageScrollArea_->setWidget(this->messageLabel_);
contentBox->addWidget(this->messageScrollArea_);
// Footer: [sender · time] ... [countdown]
auto *footerRow = new QHBoxLayout();
footerRow->setContentsMargins(0, 2, 0, 0);
footerRow->setSpacing(4);
this->footerLabel_->setStyleSheet(MUTED_STYLE);
footerRow->addWidget(this->footerLabel_);
footerRow->addStretch(1);
this->countdownLabel_->setStyleSheet(MUTED_STYLE);
this->countdownLabel_->hide();
footerRow->addWidget(this->countdownLabel_);
contentBox->addLayout(footerRow);
outerBox->addLayout(contentBox);
// 1px bottom border - separates pin widget from the chat view below
auto *bottomBorder = new QWidget(this);
bottomBorder->setFixedHeight(1);
bottomBorder->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
bottomBorder->setAutoFillBackground(true);
{
QPalette pal = bottomBorder->palette();
pal.setColor(QPalette::Window, pal.color(QPalette::Mid));
bottomBorder->setPalette(pal);
}
outerBox->addWidget(bottomBorder);
// Countdown timer (fires every second)
this->progressTimer_->setInterval(1s);
QObject::connect(this->progressTimer_, &QTimer::timeout, this, [this] {
this->tickProgress();
});
// auto-hide timer
this->autoHideTimer_->setSingleShot(true);
QObject::connect(this->autoHideTimer_, &QTimer::timeout, this, [this] {
if (!this->userToggled_)
{
this->hide();
}
});
this->scaleChangedEvent(this->scale());
this->hide();
}
void PinnedMessageWidget::tickProgress()
{
const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
const qint64 endsMs = this->pinEndsAt_.toMSecsSinceEpoch();
if (nowMs >= endsMs)
{
this->progressTimer_->stop();
this->countdownLabel_->hide();
if (this->channel_)
{
this->channel_->clearPinnedMessage();
}
return;
}
const qint64 remainingMs = endsMs - nowMs;
const qint64 totalSecs = (remainingMs + 999) / 1000; // round up
const qint64 hours = totalSecs / 3600;
const qint64 mins = (totalSecs % 3600) / 60;
const qint64 secs = totalSecs % 60;
QString timeStr;
if (hours > 0)
{
timeStr = u"\u23F1 %1:%2:%3"_s.arg(hours)
.arg(mins, 2, 10, QChar(u'0'))
.arg(secs, 2, 10, QChar(u'0'));
}
else
{
timeStr = u"\u23F1 %1:%2"_s.arg(mins, 2, 10, QChar(u'0'))
.arg(secs, 2, 10, QChar(u'0'));
}
this->countdownLabel_->setText(timeStr);
this->countdownLabel_->show();
}
void PinnedMessageWidget::paintEvent(QPaintEvent *event)
{
QPainter painter(this);
auto *theme = getTheme();
// Fill background (same color as the split header above)
painter.fillRect(event->rect(), theme->splits.header.background);
// Draw 1px top border
painter.setPen(theme->splits.header.border);
painter.drawLine(0, 0, this->width() - 1, 0);
}
void PinnedMessageWidget::setChannel(TwitchChannel *channel)
{
this->signalHolder_.clear();
this->channel_ = channel;
this->userToggled_ = false;
this->autoHideTimer_->stop();
if (channel)
{
this->signalHolder_.managedConnect(channel->pinnedMessageChanged,
[this] {
this->userToggled_ = false;
this->refresh();
});
this->signalHolder_.managedConnect(channel->userStateChanged, [this] {
this->refresh();
});
}
this->refresh();
}
std::unique_ptr<QMenu> PinnedMessageWidget::buildModMenu()
{
auto menu = std::make_unique<QMenu>(this);
menu->addAction(u"Unpin this Message"_s, this, [this] {
if (this->channel_)
{
this->channel_->unpinCurrentMessage();
}
});
auto *unpinAfterMenu = menu->addMenu(u"Unpin After"_s);
const auto addDuration = [&](const QString &label,
std::optional<std::chrono::seconds> duration) {
unpinAfterMenu->addAction(label, this, [this, duration] {
if (!this->channel_)
{
return;
}
const auto *pin = this->channel_->getPinnedMessage();
if (!pin)
{
return;
}
auto currentAccount = getApp()->getAccounts()->twitch.getCurrent();
if (!currentAccount || currentAccount->isAnon())
{
return;
}
this->channel_->updatePinnedMessageAs(
pin->messageID, duration, *currentAccount, pin->messageText);
});
};
addDuration(u"1 minute"_s, 1min);
addDuration(u"5 minutes"_s, 5min);
addDuration(u"10 minutes"_s, 10min);
addDuration(u"20 minutes"_s, 20min);
addDuration(u"30 minutes"_s, 30min);
unpinAfterMenu->addSeparator();
addDuration(u"End of stream"_s, std::nullopt);
menu->addSeparator();
menu->addAction(u"Hide for Yourself"_s, this, [this] {
this->hide();
});
return menu;
}
void PinnedMessageWidget::refresh()
{
if (!this->channel_)
{
this->progressTimer_->stop();
this->autoHideTimer_->stop();
this->userToggled_ = false;
this->hide();
return;
}
const auto *pin = this->channel_->getPinnedMessage();
if (!pin)
{
this->progressTimer_->stop();
this->autoHideTimer_->stop();
this->userToggled_ = false;
this->hide();
return;
}
const auto mode = static_cast<UsernameDisplayMode>(
getSettings()->usernameDisplayMode.getValue());
this->pinnedByLabel_->setText(u"Pinned by <b>%1</b>"_s.arg(
pin->pinnedBy.formatted(mode).toHtmlEscaped()));
this->messageLabel_->setText(pin->messageText);
this->updateMessageHeight();
{
const QString sentAt = pin->startsAt.toLocalTime().toString(
getSettings()->timestampFormat);
this->footerLabel_->setText(u"Sent by %1 \u00B7 %2"_s.arg(
pin->sender.formatted(mode).toHtmlEscaped(), sentAt));
}
this->progressTimer_->stop();
this->countdownLabel_->hide();
if (pin->endsAt.has_value() && pin->endsAt->isValid())
{
this->pinEndsAt_ = *pin->endsAt;
this->tickProgress(); // set initial text immediately
this->progressTimer_->start();
}
const bool isMod = this->channel_->hasModRights();
this->menuButton_->setVisible(isMod);
this->show();
this->autoHideTimer_->stop();
if (!getSettings()->alwaysShowPinnedMessage && !this->userToggled_)
{
this->autoHideTimer_->start(30s);
}
}
void PinnedMessageWidget::toggleUserPinned()
{
if (this->isVisible())
{
this->userToggled_ = false;
this->autoHideTimer_->stop();
this->hide();
}
else
{
this->userToggled_ = true;
this->autoHideTimer_->stop();
this->show();
}
}
void PinnedMessageWidget::updateMessageHeight()
{
if (!this->messageLabel_ || !this->messageScrollArea_)
{
return;
}
// Wrapped height of the label at the current viewport width.
const int width = this->messageScrollArea_->viewport()->width();
int contentH = this->messageLabel_->heightForWidth(width);
if (contentH <= 0)
{
contentH = this->messageLabel_->sizeHint().height();
}
// Size to content, but never taller than the cap.
this->messageScrollArea_->setFixedHeight(
qBound(1, contentH, this->messageMaxHeight_));
}
void PinnedMessageWidget::resizeEvent(QResizeEvent *event)
{
BaseWidget::resizeEvent(event);
this->updateMessageHeight();
}
void PinnedMessageWidget::showEvent(QShowEvent *event)
{
BaseWidget::showEvent(event);
this->visibilityChanged.invoke();
}
void PinnedMessageWidget::hideEvent(QHideEvent *event)
{
BaseWidget::hideEvent(event);
this->visibilityChanged.invoke();
}
void PinnedMessageWidget::scaleChangedEvent(float newScale)
{
QFont headerFont = this->pinnedByLabel_->font();
headerFont.setPointSizeF(11.0F * newScale);
this->pinnedByLabel_->setFont(headerFont);
this->countdownLabel_->setFont(headerFont);
QFont bodyFont = this->messageLabel_->font();
bodyFont.setPointSizeF(13.0F * newScale);
this->messageLabel_->setFont(bodyFont);
this->messageMaxHeight_ = int(110 * newScale);
this->updateMessageHeight();
QFont footerFont = this->footerLabel_->font();
footerFont.setPointSizeF(10.0F * newScale);
this->footerLabel_->setFont(footerFont);
}
} // namespace chatterino
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#pragma once
#include "widgets/BaseWidget.hpp"
#include <pajlada/signals/signalholder.hpp>
#include <QDateTime>
#include <QTimer>
#include <memory>
class QLabel;
class QScrollArea;
class QMenu;
namespace chatterino {
class TwitchChannel;
class DrawnButton;
/**
* Banner shown between the split header and the chat view that
* displays the channel's currently pinned message.
*/
class PinnedMessageWidget final : public BaseWidget
{
Q_OBJECT
public:
explicit PinnedMessageWidget(QWidget *parent = nullptr);
// Pass nullptr to detach from any channel.
void setChannel(TwitchChannel *channel);
// Called by the header pin button to toggle manual visibility.
void toggleUserPinned();
/// Emitted whenever this widget becomes shown or hidden.
pajlada::Signals::NoArgSignal visibilityChanged;
protected:
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void scaleChangedEvent(float newScale) override;
private:
void paintEvent(QPaintEvent *event) override;
void refresh();
/// Builds the moderator menu shown when clicking the menu button.
std::unique_ptr<QMenu> buildModMenu();
void tickProgress();
/// Sizes the message scroll area to its wrapped content, capped at the
/// (scaled) maximum height. A vertical scrollbar appears past the cap.
void updateMessageHeight();
TwitchChannel *channel_ = nullptr;
pajlada::Signals::SignalHolder signalHolder_;
// Header row
QLabel *pinnedByLabel_ = nullptr;
QLabel *countdownLabel_ = nullptr;
/// Mod Menu.
DrawnButton *menuButton_ = nullptr;
// Body
QScrollArea *messageScrollArea_ = nullptr;
QLabel *messageLabel_ = nullptr;
QLabel *footerLabel_ = nullptr;
QTimer *progressTimer_ = nullptr;
QTimer *autoHideTimer_ = nullptr;
/// Scaled cap for the message body.
int messageMaxHeight_ = 110;
/// True while user manually pinned the widget.
bool userToggled_ = false;
/// Invalid when no end time.
QDateTime pinEndsAt_;
};
} // namespace chatterino
+19
View File
@@ -35,6 +35,7 @@
#include "widgets/OverlayWindow.hpp"
#include "widgets/Scrollbar.hpp"
#include "widgets/splits/DraggedSplit.hpp"
#include "widgets/splits/PinnedMessageWidget.hpp"
#include "widgets/splits/SplitContainer.hpp"
#include "widgets/splits/SplitHeader.hpp"
#include "widgets/splits/SplitInput.hpp"
@@ -89,6 +90,7 @@ Split::Split(QWidget *parent)
, channel_(Channel::getEmpty())
, vbox_(new QVBoxLayout(this))
, header_(new SplitHeader(this))
, pinnedBanner_(new PinnedMessageWidget(this))
, view_(new ChannelView(this, this, ChannelView::Context::None,
getSettings()->scrollbackSplitLimit))
, input_(new SplitInput(this))
@@ -103,6 +105,7 @@ Split::Split(QWidget *parent)
this->vbox_->setContentsMargins(1, 1, 1, 1);
this->vbox_->addWidget(this->header_);
this->vbox_->addWidget(this->pinnedBanner_);
this->vbox_->addWidget(this->view_, 1);
this->vbox_->addWidget(this->input_);
@@ -726,6 +729,11 @@ SplitInput &Split::getInput()
return *this->input_;
}
PinnedMessageWidget *Split::getPinnedBanner() const
{
return this->pinnedBanner_;
}
void Split::updateInputPlaceholder()
{
if (!this->getChannel()->isTwitchChannel())
@@ -855,6 +863,12 @@ void Split::setChannel(IndirectChannel newChannel)
tc->sendWaitUpdate, [this](const QString &text) {
this->getInput().setSendWaitStatus(text);
});
this->pinnedBanner_->setChannel(tc);
}
else
{
this->pinnedBanner_->setChannel(nullptr);
}
this->indirectChannelChangedConnection_ =
@@ -1273,6 +1287,11 @@ void Split::reconnect()
this->getChannel()->reconnect();
}
void Split::togglePinnedBanner()
{
this->pinnedBanner_->toggleUserPinned();
}
void Split::dragEnterEvent(QDragEnterEvent *event)
{
if (getSettings()->imageUploaderEnabled &&
+4
View File
@@ -23,6 +23,7 @@ class SplitHeader;
class SplitInput;
class SplitContainer;
class SplitOverlay;
class PinnedMessageWidget;
class SelectChannelDialog;
class OverlayWindow;
@@ -54,6 +55,7 @@ public:
ChannelView &getChannelView();
SplitInput &getInput();
[[nodiscard]] PinnedMessageWidget *getPinnedBanner() const;
IndirectChannel getIndirectChannel();
ChannelPtr getChannel() const;
@@ -165,6 +167,7 @@ private:
QVBoxLayout *const vbox_;
SplitHeader *const header_;
PinnedMessageWidget *const pinnedBanner_;
ChannelView *const view_;
SplitInput *const input_;
SplitOverlay *const overlay_;
@@ -204,6 +207,7 @@ public Q_SLOTS:
void openChatterList();
void openSubPage();
void reconnect();
void togglePinnedBanner();
};
} // namespace chatterino
+59
View File
@@ -30,6 +30,7 @@
#include "widgets/dialogs/SettingsDialog.hpp"
#include "widgets/helper/CommonTexts.hpp"
#include "widgets/Label.hpp"
#include "widgets/splits/PinnedMessageWidget.hpp"
#include "widgets/splits/Split.hpp"
#include "widgets/splits/SplitContainer.hpp"
#include "widgets/TooltipWidget.hpp"
@@ -308,6 +309,18 @@ void SplitHeader::initializeLayout()
},
this, {4, 4});
this->pinButton_ = new SvgButton(
{
.dark = ":/buttons/pinnedMessage-chat.svg",
.light = ":/buttons/pinnedMessage-chat.svg",
},
this, {4, 4});
this->pinButton_->setToolTip(QStringLiteral("Toggle pinned message"));
this->pinButton_->setColor(this->theme->isLightTheme()
? QColor(0x42, 0x42, 0x42)
: QColor(0xc0, 0xc0, 0xc0));
this->pinButton_->hide();
this->addButton_ = new DrawnButton(DrawnButton::Symbol::Plus,
{
.padding = 3,
@@ -346,6 +359,8 @@ void SplitHeader::initializeLayout()
w->hide();
w->setMenu(this->createChatModeMenu());
}),
// pin indicator
this->pinButton_,
// moderator
this->moderationButton_,
// chatter list
@@ -394,6 +409,10 @@ void SplitHeader::initializeLayout()
this->split_->openChatterList();
});
QObject::connect(this->pinButton_, &Button::leftClicked, this, [this]() {
this->split_->togglePinnedBanner();
});
QObject::connect(this->addButton_, &Button::leftClicked, this, [this]() {
this->split_->addSibling();
});
@@ -837,6 +856,22 @@ void SplitHeader::handleChannelChanged()
twitchChannel->streamStatusChanged, [this]() {
this->updateChannelText();
});
this->channelConnections_.managedConnect(
twitchChannel->pinnedMessageChanged, [this]() {
this->updatePinButton();
});
this->channelConnections_.managedConnect(
this->split_->getPinnedBanner()->visibilityChanged, [this]() {
this->updatePinButton();
});
this->updatePinButton();
}
else
{
this->updatePinButton();
}
}
@@ -849,6 +884,7 @@ void SplitHeader::scaleChangedEvent(float scale)
this->dropdownButton_->setFixedWidth(w);
this->moderationButton_->setFixedWidth(w);
this->chattersButton_->setFixedWidth(w);
this->pinButton_->setFixedWidth(w);
this->addButton_->setFixedWidth(addSplitWidth);
}
@@ -858,6 +894,26 @@ void SplitHeader::setAddButtonVisible(bool value)
this->addButton_->setVisible(value);
}
void SplitHeader::updatePinButton()
{
auto channel = this->split_->getChannel();
auto *twitchChannel = dynamic_cast<TwitchChannel *>(channel.get());
const bool hasPinnedMessage = twitchChannel != nullptr &&
twitchChannel->getPinnedMessage() != nullptr;
this->pinButton_->setVisible(hasPinnedMessage);
if (hasPinnedMessage && this->split_->getPinnedBanner()->isVisible())
{
this->pinButton_->setColor(this->theme->accent);
}
else
{
this->pinButton_->setColor(this->theme->isLightTheme()
? QColor(0x42, 0x42, 0x42)
: QColor(0xc0, 0xc0, 0xc0));
}
}
void SplitHeader::updateChannelText()
{
auto indirectChannel = this->split_->getIndirectChannel();
@@ -1121,6 +1177,9 @@ void SplitHeader::themeChangedEvent()
}
this->titleLabel_->setPalette(palette);
// Re-apply pin button color to respect updated theme
this->updatePinButton();
auto bg = this->theme->splits.header.background;
this->addButton_->setOptions({
.background = bg,
+3
View File
@@ -36,6 +36,7 @@ public:
void updateChannelText();
void updateIcons();
void updatePinButton();
// Invoked when SplitHeader should update anything refering to a TwitchChannel's mode
// has changed (e.g. sub mode toggled)
void updateRoomModes();
@@ -85,6 +86,8 @@ private:
QAction *modeActionSetR9k{};
QAction *modeActionSetFollowers{};
SvgButton *pinButton_{};
SvgButton *moderationButton_{};
SvgButton *chattersButton_{};
DrawnButton *addButton_{};