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_{};