diff --git a/resources/buttons/pinnedMessage-chat.svg b/resources/buttons/pinnedMessage-chat.svg
new file mode 100644
index 000000000..83d75a025
--- /dev/null
+++ b/resources/buttons/pinnedMessage-chat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a5f2eff2f..a67bd84e6 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -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)
diff --git a/src/common/enums/UsernameDisplayMode.hpp b/src/common/enums/UsernameDisplayMode.hpp
new file mode 100644
index 000000000..71bac7097
--- /dev/null
+++ b/src/common/enums/UsernameDisplayMode.hpp
@@ -0,0 +1,15 @@
+// SPDX-FileCopyrightText: 2026 Contributors to Chatterino
+//
+// 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
diff --git a/src/providers/twitch/PubSubClient.cpp b/src/providers/twitch/PubSubClient.cpp
index 51ac3fd22..9c9a974d5 100644
--- a/src/providers/twitch/PubSubClient.cpp
+++ b/src/providers/twitch/PubSubClient.cpp
@@ -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();
+ 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(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;
diff --git a/src/providers/twitch/PubSubManager.cpp b/src/providers/twitch/PubSubManager.cpp
index c545b9395..591632c53 100644
--- a/src/providers/twitch/PubSubManager.cpp
+++ b/src/providers/twitch/PubSubManager.cpp
@@ -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
diff --git a/src/providers/twitch/PubSubManager.hpp b/src/providers/twitch/PubSubManager.hpp
index e29fd3f8a..8a7cd22fc 100644
--- a/src/providers/twitch/PubSubManager.hpp
+++ b/src/providers/twitch/PubSubManager.hpp
@@ -49,6 +49,15 @@ public:
Signal redeemed;
} pointReward;
+ struct {
+ /// Emitted when a message is pinned or its pin is updated.
+ /// The argument is the channel name.
+ Signal pinned;
+ /// Emitted when the pinned message is removed.
+ /// The argument is the channel name.
+ Signal 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 messagesReceived{0};
std::atomic messagesFailedToParse{0};
diff --git a/src/providers/twitch/PubSubMessages.hpp b/src/providers/twitch/PubSubMessages.hpp
index 33ba6aebd..a6248865f 100644
--- a/src/providers/twitch/PubSubMessages.hpp
+++ b/src/providers/twitch/PubSubMessages.hpp
@@ -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
diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp
index 5e05d4bbb..e52998dde 100644
--- a/src/providers/twitch/TwitchChannel.cpp
+++ b/src/providers/twitch/TwitchChannel.cpp
@@ -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 msg) {
+ auto self = weak.lock();
+ if (!self || self->pinnedMessageRequestId_ != requestId)
+ {
+ return;
+ }
+ if (msg)
+ {
+ self->pinnedMessage_ =
+ std::make_unique(
+ 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
diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp
index 7c2d5e0d4..94a4914bf 100644
--- a/src/providers/twitch/TwitchChannel.hpp
+++ b/src/providers/twitch/TwitchChannel.hpp
@@ -25,6 +25,7 @@
#include
#include
+#include
#include
#include
#include
@@ -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 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;
diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp
index 19f2705ba..d81a7ece1 100644
--- a/src/providers/twitch/TwitchIrcServer.cpp
+++ b/src/providers/twitch/TwitchIrcServer.cpp
@@ -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(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(chan.get()))
+ {
+ channel->clearPinnedMessage();
+ }
+ });
+ });
}
void TwitchIrcServer::aboutToQuit()
diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp
index 3daf65c66..af43ffc3e 100644
--- a/src/providers/twitch/api/Helix.hpp
+++ b/src/providers/twitch/api/Helix.hpp
@@ -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 {
diff --git a/src/providers/twitch/pubsubmessages/PinnedChatUpdates.cpp b/src/providers/twitch/pubsubmessages/PinnedChatUpdates.cpp
new file mode 100644
index 000000000..dc48c7a66
--- /dev/null
+++ b/src/providers/twitch/pubsubmessages/PinnedChatUpdates.cpp
@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: 2026 Contributors to Chatterino
+//
+// 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(this->typeString);
+ if (oType.has_value())
+ {
+ this->type = oType.value();
+ }
+}
+
+} // namespace chatterino
diff --git a/src/providers/twitch/pubsubmessages/PinnedChatUpdates.hpp b/src/providers/twitch/pubsubmessages/PinnedChatUpdates.hpp
new file mode 100644
index 000000000..f451f8152
--- /dev/null
+++ b/src/providers/twitch/pubsubmessages/PinnedChatUpdates.hpp
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: 2026 Contributors to Chatterino
+//
+// SPDX-License-Identifier: MIT
+
+#pragma once
+
+#include
+#include
+#include
+
+#include
+
+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)
+ }
+}
diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp
index a68856f45..a5c947f28 100644
--- a/src/singletons/Settings.hpp
+++ b/src/singletons/Settings.hpp
@@ -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 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};
diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp
index 3707fd1a2..577a5b355 100644
--- a/src/widgets/settingspages/GeneralPage.cpp
+++ b/src/widgets/settingspages/GeneralPage.cpp
@@ -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(
diff --git a/src/widgets/splits/PinnedMessageWidget.cpp b/src/widgets/splits/PinnedMessageWidget.cpp
new file mode 100644
index 000000000..50c424a74
--- /dev/null
+++ b/src/widgets/splits/PinnedMessageWidget.cpp
@@ -0,0 +1,401 @@
+// SPDX-FileCopyrightText: 2026 Contributors to Chatterino
+//
+// 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
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace std::chrono_literals;
+using namespace Qt::Literals;
+
+#include
+#include
+#include
+
+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 " [⋮]
+ 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 PinnedMessageWidget::buildModMenu()
+{
+ auto menu = std::make_unique(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 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(
+ getSettings()->usernameDisplayMode.getValue());
+ this->pinnedByLabel_->setText(u"Pinned by %1"_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
diff --git a/src/widgets/splits/PinnedMessageWidget.hpp b/src/widgets/splits/PinnedMessageWidget.hpp
new file mode 100644
index 000000000..2d3341b61
--- /dev/null
+++ b/src/widgets/splits/PinnedMessageWidget.hpp
@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: 2026 Contributors to Chatterino
+//
+// SPDX-License-Identifier: MIT
+
+#pragma once
+
+#include "widgets/BaseWidget.hpp"
+
+#include
+#include
+#include
+
+#include
+
+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 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
diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp
index ab633af73..226e3e3ff 100644
--- a/src/widgets/splits/Split.cpp
+++ b/src/widgets/splits/Split.cpp
@@ -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 &&
diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp
index be2ba52be..3a5b807fe 100644
--- a/src/widgets/splits/Split.hpp
+++ b/src/widgets/splits/Split.hpp
@@ -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
diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp
index e4b3ce7d2..1a87d93c9 100644
--- a/src/widgets/splits/SplitHeader.cpp
+++ b/src/widgets/splits/SplitHeader.cpp
@@ -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(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,
diff --git a/src/widgets/splits/SplitHeader.hpp b/src/widgets/splits/SplitHeader.hpp
index dc36ddd79..ec3572107 100644
--- a/src/widgets/splits/SplitHeader.hpp
+++ b/src/widgets/splits/SplitHeader.hpp
@@ -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_{};