From be72d73c3d9980e320ab16d678a9542e3fe5837c Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 11 Sep 2022 16:37:13 +0200 Subject: [PATCH] feat: add `Go to message` action in various places (#3953) * feat: add `Go to message` action in search popup * chore: add changelog entry * fix: only scroll if the scrollbar is shown * fix: go to message when view isn't focused * feat: animate highlighted message * fix: missing includes * fix: order of initialization * fix: add `ChannelView::mayContainMessage` to filter messages * feat: add `Go to message` action in `/mentions` * fix: ignore any mentions channel when searching for split * feat: add `Go to message` action in reply-threads * fix: remove redundant `source` parameter * feat: add `Go to message` action in user-cards * feat: add link to deleted message * fix: set current time to 0 when starting animation * chore: update changelog * fix: add default case (unreachable) * chore: removed unused variable * fix: search in mentions * fix: always attempt to focus split * fix: rename `Link::MessageId` to `Link::JumpToMessage` * fix: rename `selectAndScrollToMessage` to `scrollToMessage` * fix: rename internal `scrollToMessage` to `scrollToMessageLayout` * fix: deleted message link in search popup * chore: reword explanation * fix: use for-loop instead of `std::find_if` * refactor: define highlight colors in `BaseTheme` * core: replace `iff` with `if` * fix: only return if the message found * Reword/phrase/dot changelog entries Co-authored-by: pajlada --- CHANGELOG.md | 2 + src/BaseTheme.cpp | 6 + src/BaseTheme.hpp | 3 + src/messages/Link.hpp | 1 + src/messages/layouts/MessageLayout.cpp | 5 + src/messages/layouts/MessageLayout.hpp | 1 + src/providers/twitch/TwitchMessageBuilder.cpp | 27 ++- src/singletons/WindowManager.cpp | 5 + src/singletons/WindowManager.hpp | 9 + src/widgets/Notebook.cpp | 24 +++ src/widgets/helper/ChannelView.cpp | 203 +++++++++++++++++- src/widgets/helper/ChannelView.hpp | 33 +++ src/widgets/helper/SearchPopup.cpp | 29 +++ src/widgets/helper/SearchPopup.hpp | 8 + 14 files changed, 344 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 311eb76c8..c87b8d12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ - Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935) - Minor: Add AutoMod message flag filter. (#3938) - Minor: Added whitespace trim to username field in nicknames (#3946) +- Minor: Added `Go to message` context menu action to search popup, mentions, usercard and reply threads. (#3953) +- Minor: Added link back to original message that was deleted. (#3953) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/BaseTheme.cpp b/src/BaseTheme.cpp index 4c58ccba2..98f8a2df1 100644 --- a/src/BaseTheme.cpp +++ b/src/BaseTheme.cpp @@ -168,6 +168,12 @@ void AB_THEME_CLASS::actuallyUpdate(double hue, double multiplier) // this->messages.seperator = // this->messages.seperatorInner = + int complementaryGray = this->isLightTheme() ? 20 : 230; + this->messages.highlightAnimationStart = + QColor(complementaryGray, complementaryGray, complementaryGray, 110); + this->messages.highlightAnimationEnd = + QColor(complementaryGray, complementaryGray, complementaryGray, 0); + // Scrollbar this->scrollbars.background = QColor(0, 0, 0, 0); // this->scrollbars.background = splits.background; diff --git a/src/BaseTheme.hpp b/src/BaseTheme.hpp index 2d6ee5cdc..e04fce285 100644 --- a/src/BaseTheme.hpp +++ b/src/BaseTheme.hpp @@ -74,6 +74,9 @@ public: // QColor seperator; // QColor seperatorInner; QColor selection; + + QColor highlightAnimationStart; + QColor highlightAnimationEnd; } messages; /// SCROLLBAR diff --git a/src/messages/Link.hpp b/src/messages/Link.hpp index f6a48a7d3..2692ace69 100644 --- a/src/messages/Link.hpp +++ b/src/messages/Link.hpp @@ -25,6 +25,7 @@ public: CopyToClipboard, ReplyToMessage, ViewThread, + JumpToMessage, }; Link(); diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index f0f01f933..23917f2a5 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -66,6 +66,11 @@ int MessageLayout::getHeight() const return container_->getHeight(); } +int MessageLayout::getWidth() const +{ + return this->container_->getWidth(); +} + // Layout // return true if redraw is required bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index 33c1fad72..2de8ed987 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -40,6 +40,7 @@ public: const MessagePtr &getMessagePtr() const; int getHeight() const; + int getWidth() const; MessageLayoutFlags flags; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 38572b6e2..d04462d7c 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1471,15 +1471,17 @@ void TwitchMessageBuilder::deletionMessage(const MessagePtr originalMessage, MessageColor::System); if (originalMessage->messageText.length() > 50) { - builder->emplace( - originalMessage->messageText.left(50) + "…", - MessageElementFlag::Text, MessageColor::Text); + builder + ->emplace(originalMessage->messageText.left(50) + "…", + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, originalMessage->id}); } else { - builder->emplace(originalMessage->messageText, - MessageElementFlag::Text, - MessageColor::Text); + builder + ->emplace(originalMessage->messageText, + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, originalMessage->id}); } builder->message().timeoutUser = "msg:" + originalMessage->id; } @@ -1511,14 +1513,17 @@ void TwitchMessageBuilder::deletionMessage(const DeleteAction &action, MessageColor::System); if (action.messageText.length() > 50) { - builder->emplace(action.messageText.left(50) + "…", - MessageElementFlag::Text, - MessageColor::Text); + builder + ->emplace(action.messageText.left(50) + "…", + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, action.messageId}); } else { - builder->emplace( - action.messageText, MessageElementFlag::Text, MessageColor::Text); + builder + ->emplace(action.messageText, MessageElementFlag::Text, + MessageColor::Text) + ->setLink({Link::JumpToMessage, action.messageId}); } builder->message().timeoutUser = "msg:" + action.messageId; } diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index f7f555d66..64d6cf7f4 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -320,6 +320,11 @@ void WindowManager::select(SplitContainer *container) this->selectSplitContainer.invoke(container); } +void WindowManager::scrollToMessage(const MessagePtr &message) +{ + this->scrollToMessageSignal.invoke(message); +} + QPoint WindowManager::emotePopupPos() { return this->emotePopupPos_; diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 5ad9cb890..5b3ee7cc9 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -15,6 +15,7 @@ class Settings; class Paths; class Window; class SplitContainer; +class ChannelView; enum class MessageElementFlag : int64_t; using MessageElementFlags = FlagsEnum; @@ -66,6 +67,13 @@ public: void select(Split *split); void select(SplitContainer *container); + /** + * Scrolls to the message in a split that's not + * a mentions view and focuses the split. + * + * @param message Message to scroll to. + */ + void scrollToMessage(const MessagePtr &message); QPoint emotePopupPos(); void setEmotePopupPos(QPoint pos); @@ -105,6 +113,7 @@ public: pajlada::Signals::Signal selectSplit; pajlada::Signals::Signal selectSplitContainer; + pajlada::Signals::Signal scrollToMessageSignal; private: static void encodeNodeRecursively(SplitContainer::Node *node, diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index 4ed2e2c19..b1ccf4a74 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -8,6 +8,7 @@ #include "util/InitUpdateButton.hpp" #include "widgets/Window.hpp" #include "widgets/dialogs/SettingsDialog.hpp" +#include "widgets/helper/ChannelView.hpp" #include "widgets/helper/NotebookButton.hpp" #include "widgets/helper/NotebookTab.hpp" #include "widgets/splits/Split.hpp" @@ -1006,6 +1007,29 @@ SplitNotebook::SplitNotebook(Window *parent) [this](SplitContainer *sc) { this->select(sc); }); + + this->signalHolder_.managedConnect( + getApp()->windows->scrollToMessageSignal, + [this](const MessagePtr &message) { + for (auto &&item : this->items()) + { + if (auto sc = dynamic_cast(item.page)) + { + for (auto *split : sc->getSplits()) + { + if (split->getChannel()->getType() != + Channel::Type::TwitchMentions) + { + if (split->getChannelView().scrollToMessage( + message)) + { + return; + } + } + } + } + } + }); } void SplitNotebook::showEvent(QShowEvent *) diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 837df651a..6042c18d6 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -1,13 +1,16 @@ #include "ChannelView.hpp" #include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -118,12 +121,23 @@ namespace { addPageLink("FFZ"); } } + + // Current function: https://www.desmos.com/calculator/vdyamchjwh + qreal highlightEasingFunction(qreal progress) + { + if (progress <= 0.1) + { + return 1.0 - pow(10.0 * progress, 3.0); + } + return 1.0 + pow((20.0 / 9.0) * (0.5 * progress - 0.5), 3.0); + } } // namespace ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context) : BaseWidget(parent) , split_(split) , scrollBar_(new Scrollbar(this)) + , highlightAnimation_(this) , context_(context) { this->setMouseTracking(true); @@ -164,6 +178,12 @@ ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context) // of any place where you can, or where it would make sense, // to tab to a ChannelVieChannelView this->setFocusPolicy(Qt::FocusPolicy::ClickFocus); + + this->setupHighlightAnimationColors(); + this->highlightAnimation_.setDuration(1500); + auto curve = QEasingCurve(); + curve.setCustomType(highlightEasingFunction); + this->highlightAnimation_.setEasingCurve(curve); } void ChannelView::initializeLayout() @@ -339,9 +359,18 @@ void ChannelView::themeChangedEvent() { BaseWidget::themeChangedEvent(); + this->setupHighlightAnimationColors(); this->queueLayout(); } +void ChannelView::setupHighlightAnimationColors() +{ + this->highlightAnimation_.setStartValue( + this->theme->messages.highlightAnimationStart); + this->highlightAnimation_.setEndValue( + this->theme->messages.highlightAnimationEnd); +} + void ChannelView::scaleChangedEvent(float scale) { BaseWidget::scaleChangedEvent(scale); @@ -392,7 +421,8 @@ void ChannelView::performLayout(bool causedByScrollbar) auto &messages = this->getMessagesSnapshot(); this->showingLatestMessages_ = - this->scrollBar_->isAtBottom() || !this->scrollBar_->isVisible(); + this->scrollBar_->isAtBottom() || + (!this->scrollBar_->isVisible() && !causedByScrollbar); /// Layout visible messages this->layoutVisibleMessages(messages); @@ -475,6 +505,7 @@ void ChannelView::updateScrollbar( { this->scrollBar_->setDesiredValue(0); } + this->showScrollBar_ = showScrollbar; this->scrollBar_->setMaximum(messages.size()); @@ -1088,6 +1119,86 @@ MessageElementFlags ChannelView::getFlags() const return flags; } +bool ChannelView::scrollToMessage(const MessagePtr &message) +{ + if (!this->mayContainMessage(message)) + { + return false; + } + + auto &messagesSnapshot = this->getMessagesSnapshot(); + if (messagesSnapshot.size() == 0) + { + return false; + } + + // TODO: Figure out if we can somehow binary-search here. + // Currently, a message only sometimes stores a QDateTime, + // but always a QTime (inaccurate on midnight). + // + // We're searching from the bottom since it's more likely for a user + // wanting to go to a message that recently scrolled out of view. + size_t messageIdx = messagesSnapshot.size() - 1; + for (; messageIdx < SIZE_MAX; messageIdx--) + { + if (messagesSnapshot[messageIdx]->getMessagePtr() == message) + { + break; + } + } + + if (messageIdx == SIZE_MAX) + { + return false; + } + + this->scrollToMessageLayout(messagesSnapshot[messageIdx].get(), messageIdx); + getApp()->windows->select(this->split_); + return true; +} + +bool ChannelView::scrollToMessageId(const QString &messageId) +{ + auto &messagesSnapshot = this->getMessagesSnapshot(); + if (messagesSnapshot.size() == 0) + { + return false; + } + + // We're searching from the bottom since it's more likely for a user + // wanting to go to a message that recently scrolled out of view. + size_t messageIdx = messagesSnapshot.size() - 1; + for (; messageIdx < SIZE_MAX; messageIdx--) + { + if (messagesSnapshot[messageIdx]->getMessagePtr()->id == messageId) + { + break; + } + } + + if (messageIdx == SIZE_MAX) + { + return false; + } + + this->scrollToMessageLayout(messagesSnapshot[messageIdx].get(), messageIdx); + getApp()->windows->select(this->split_); + return true; +} + +void ChannelView::scrollToMessageLayout(MessageLayout *layout, + size_t messageIdx) +{ + this->highlightedMessage_ = layout; + this->highlightAnimation_.setCurrentTime(0); + this->highlightAnimation_.start(QAbstractAnimation::KeepWhenStopped); + + if (this->showScrollBar_) + { + this->getScrollBar().setDesiredValue(messageIdx); + } +} + void ChannelView::paintEvent(QPaintEvent * /*event*/) { // BenchmarkGuard benchmark("paint"); @@ -1144,6 +1255,17 @@ void ChannelView::drawMessages(QPainter &painter) layout->paint(painter, DRAW_WIDTH, y, i, this->selection_, isLastMessage, windowFocused, isMentions); + if (this->highlightedMessage_ == layout) + { + painter.fillRect( + 0, y, layout->getWidth(), layout->getHeight(), + this->highlightAnimation_.currentValue().value()); + if (this->highlightAnimation_.state() == QVariantAnimation::Stopped) + { + this->highlightedMessage_ = nullptr; + } + } + y += layout->getHeight(); end = layout; @@ -2070,6 +2192,46 @@ void ChannelView::addMessageContextMenuItems( }); } } + + bool isSearch = this->context_ == Context::Search; + bool isReplyOrUserCard = (this->context_ == Context::ReplyThread || + this->context_ == Context::UserCard) && + this->split_; + bool isMentions = + this->channel()->getType() == Channel::Type::TwitchMentions; + if (isSearch || isMentions || isReplyOrUserCard) + { + const auto &messagePtr = layout->getMessagePtr(); + menu.addAction("Go to message", [this, &messagePtr, isSearch, + isMentions, isReplyOrUserCard] { + if (isSearch) + { + if (const auto &search = + dynamic_cast(this->parentWidget())) + { + search->goToMessage(messagePtr); + } + } + else if (isMentions) + { + getApp()->windows->scrollToMessage(messagePtr); + } + else if (isReplyOrUserCard) + { + // If the thread is in the mentions channel, + // we need to find the original split. + if (this->split_->getChannel()->getType() == + Channel::Type::TwitchMentions) + { + getApp()->windows->scrollToMessage(messagePtr); + } + else + { + this->split_->getChannelView().scrollToMessage(messagePtr); + } + } + }); + } } void ChannelView::addTwitchLinkContextMenuItems( @@ -2321,6 +2483,30 @@ void ChannelView::showUserInfoPopup(const QString &userName, userPopup->show(); } +bool ChannelView::mayContainMessage(const MessagePtr &message) +{ + switch (this->channel()->getType()) + { + case Channel::Type::Direct: + case Channel::Type::Twitch: + case Channel::Type::TwitchWatching: + case Channel::Type::Irc: + return this->channel()->getName() == message->channelName; + case Channel::Type::TwitchWhispers: + return message->flags.has(MessageFlag::Whisper); + case Channel::Type::TwitchMentions: + return message->flags.has(MessageFlag::Highlighted); + case Channel::Type::TwitchLive: + return message->flags.has(MessageFlag::System); + case Channel::Type::TwitchEnd: // TODO: not used? + case Channel::Type::None: // Unspecific + case Channel::Type::Misc: // Unspecific + return true; + default: + return true; // unreachable + } +} + void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, MessageLayout *layout) { @@ -2442,6 +2628,21 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, this->showReplyThreadPopup(layout->getMessagePtr()); } break; + case Link::JumpToMessage: { + if (this->context_ == Context::Search) + { + if (auto search = + dynamic_cast(this->parentWidget())) + { + search->goToMessageId(link.value); + } + } + else + { + this->scrollToMessageId(link.value); + } + } + break; default:; } diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index 44679e7b0..123e5cc7c 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -83,6 +84,17 @@ public: const boost::optional &getOverrideFlags() const; void updateLastReadMessage(); + /** + * Attempts to scroll to a message in this channel. + * @return true if the message was found and highlighted. + */ + bool scrollToMessage(const MessagePtr &message); + /** + * Attempts to scroll to a message id in this channel. + * @return true if the message was found and highlighted. + */ + bool scrollToMessageId(const QString &id); + /// Pausing bool pausable() const; void setPausable(bool value); @@ -119,6 +131,13 @@ public: void showUserInfoPopup(const QString &userName, QString alternativePopoutChannel = QString()); + /** + * @brief This method is meant to be used when filtering out channels. + * It must return true if a message belongs in this channel. + * It might return true if a message doesn't belong in this channel. + */ + bool mayContainMessage(const MessagePtr &message); + pajlada::Signals::Signal mouseDown; pajlada::Signals::NoArgSignal selectionChanged; pajlada::Signals::Signal tabHighlightRequested; @@ -208,6 +227,14 @@ private: void enableScrolling(const QPointF &scrollStart); void disableScrolling(); + /** + * Scrolls to a message layout that must be from this view. + * + * @param layout Must be from this channel. + * @param messageIdx Must be an index into this channel. + */ + void scrollToMessageLayout(MessageLayout *layout, size_t messageIdx); + void setInputReply(const MessagePtr &message); void showReplyThreadPopup(const MessagePtr &message); bool canReplyToMessages() const; @@ -241,6 +268,7 @@ private: Scrollbar *scrollBar_; EffectLabel *goToBottom_; + bool showScrollBar_ = false; FilterSetPtr channelFilters_; @@ -272,6 +300,11 @@ private: QPointF currentMousePosition_; QTimer scrollTimer_; + // We're only interested in the pointer, not the contents + MessageLayout *highlightedMessage_; + QVariantAnimation highlightAnimation_; + void setupHighlightAnimationColors(); + struct { QCursor neutral; QCursor up; diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index d33465499..85d2e21f0 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -12,6 +12,7 @@ #include "messages/search/MessageFlagsPredicate.hpp" #include "messages/search/RegexPredicate.hpp" #include "messages/search/SubstringPredicate.hpp" +#include "singletons/WindowManager.hpp" #include "widgets/helper/ChannelView.hpp" namespace chatterino { @@ -106,6 +107,34 @@ void SearchPopup::addChannel(ChannelView &channel) this->updateWindowTitle(); } +void SearchPopup::goToMessage(const MessagePtr &message) +{ + for (const auto &view : this->searchChannels_) + { + if (view.get().channel()->getType() == Channel::Type::TwitchMentions) + { + getApp()->windows->scrollToMessage(message); + return; + } + + if (view.get().scrollToMessage(message)) + { + return; + } + } +} + +void SearchPopup::goToMessageId(const QString &messageId) +{ + for (const auto &view : this->searchChannels_) + { + if (view.get().scrollToMessageId(messageId)) + { + return; + } + } +} + void SearchPopup::updateWindowTitle() { QString historyName; diff --git a/src/widgets/helper/SearchPopup.hpp b/src/widgets/helper/SearchPopup.hpp index c927ff5b1..7a0b3677c 100644 --- a/src/widgets/helper/SearchPopup.hpp +++ b/src/widgets/helper/SearchPopup.hpp @@ -19,6 +19,14 @@ public: SearchPopup(QWidget *parent, Split *split = nullptr); virtual void addChannel(ChannelView &channel); + void goToMessage(const MessagePtr &message); + /** + * This method should only be used for searches that + * don't include a mentions channel, + * since it will only search in the opened channels (not globally). + * @param messageId + */ + void goToMessageId(const QString &messageId); protected: virtual void updateWindowTitle();