diff --git a/CHANGELOG.md b/CHANGELOG.md index a8553d9b..fb1b4b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ - Bugfix: Fixed _Copy message_ copying the channel name in global search. (#5106) - Bugfix: Reply contexts now use the color of the replied-to message. (#5145) - Bugfix: Fixed top-level window getting stuck after opening settings. (#5161, #5166) +- Bugfix: Fixed link info not updating without moving the cursor. (#5178) - Dev: Run miniaudio in a separate thread, and simplify it to not manage the device ourselves. There's a chance the simplification is a bad idea. (#4978) - Dev: Change clang-format from v14 to v16. (#4929) - Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 483cad11..2755b07a 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -221,6 +221,13 @@ public: return nullptr; } + ILinkResolver *getLinkResolver() override + { + assert(false && "EmptyApplication::getLinkResolver was called without " + "being initialized"); + return nullptr; + } + private: Paths paths_; Args args_; diff --git a/src/Application.cpp b/src/Application.cpp index c929484b..02982f95 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -13,6 +13,7 @@ #include "controllers/sound/ISoundController.hpp" #include "providers/bttv/BttvEmotes.hpp" #include "providers/ffz/FfzEmotes.hpp" +#include "providers/links/LinkResolver.hpp" #include "providers/seventv/SeventvAPI.hpp" #include "providers/seventv/SeventvEmotes.hpp" #include "providers/twitch/TwitchBadges.hpp" @@ -142,6 +143,7 @@ Application::Application(Settings &_settings, const Paths &paths, , ffzEmotes(new FfzEmotes) , seventvEmotes(new SeventvEmotes) , logging(new Logging(_settings)) + , linkResolver(new LinkResolver) #ifdef CHATTERINO_HAVE_PLUGINS , plugins(&this->emplace(new PluginController(paths))) #endif @@ -494,6 +496,13 @@ Logging *Application::getChatLogger() return this->logging.get(); } +ILinkResolver *Application::getLinkResolver() +{ + assertInGuiThread(); + + return this->linkResolver.get(); +} + BttvEmotes *Application::getBttvEmotes() { assertInGuiThread(); diff --git a/src/Application.hpp b/src/Application.hpp index eef02be7..991d82ac 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -54,6 +54,7 @@ class CrashHandler; class BttvEmotes; class FfzEmotes; class SeventvEmotes; +class ILinkResolver; class IApplication { @@ -95,6 +96,7 @@ public: virtual BttvEmotes *getBttvEmotes() = 0; virtual FfzEmotes *getFfzEmotes() = 0; virtual SeventvEmotes *getSeventvEmotes() = 0; + virtual ILinkResolver *getLinkResolver() = 0; }; class Application : public IApplication @@ -162,6 +164,7 @@ private: std::unique_ptr ffzEmotes; std::unique_ptr seventvEmotes; const std::unique_ptr logging; + std::unique_ptr linkResolver; #ifdef CHATTERINO_HAVE_PLUGINS PluginController *const plugins{}; #endif @@ -212,6 +215,8 @@ public: FfzEmotes *getFfzEmotes() override; SeventvEmotes *getSeventvEmotes() override; + ILinkResolver *getLinkResolver() override; + pajlada::Signals::NoArgSignal streamerModeChanged; private: diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index faef0c8f..8d1e4d05 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -297,8 +297,6 @@ set(SOURCE_FILES providers/IvrApi.cpp providers/IvrApi.hpp - providers/LinkResolver.cpp - providers/LinkResolver.hpp providers/NetworkConfigurationProvider.cpp providers/NetworkConfigurationProvider.hpp @@ -345,6 +343,11 @@ set(SOURCE_FILES providers/irc/IrcServer.cpp providers/irc/IrcServer.hpp + providers/links/LinkInfo.cpp + providers/links/LinkInfo.hpp + providers/links/LinkResolver.cpp + providers/links/LinkResolver.hpp + providers/liveupdates/BasicPubSubClient.hpp providers/liveupdates/BasicPubSubManager.hpp providers/liveupdates/BasicPubSubWebsocket.hpp diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index d1465cef..af5dadcf 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -8,7 +8,7 @@ #include "messages/Message.hpp" #include "messages/MessageColor.hpp" #include "messages/MessageElement.hpp" -#include "providers/LinkResolver.hpp" +#include "providers/links/LinkResolver.hpp" #include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "singletons/Emotes.hpp" @@ -528,12 +528,12 @@ MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/, this->emplace(); using MEF = MessageElementFlag; - auto addText = [this](QString text, MessageElementFlags mefs = MEF::Text, + auto addText = [this](QString text, MessageColor color = MessageColor::System) -> TextElement * { this->message().searchText += text; this->message().messageText += text; - return this->emplace(text, mefs, color); + return this->emplace(text, MEF::Text, color); }; addText("Your image has been uploaded to"); @@ -541,16 +541,14 @@ MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/, // ASSUMPTION: the user gave this uploader configuration to the program // therefore they trust that the host is not wrong/malicious. This doesn't obey getSettings()->lowercaseDomains. // This also ensures that the LinkResolver doesn't get these links. - addText(imageLink, {MEF::OriginalLink, MEF::LowercaseLink}, - MessageColor::Link) + addText(imageLink, MessageColor::Link) ->setLink({Link::Url, imageLink}) ->setTrailingSpace(false); if (!deletionLink.isEmpty()) { addText("(Deletion link:"); - addText(deletionLink, {MEF::OriginalLink, MEF::LowercaseLink}, - MessageColor::Link) + addText(deletionLink, MessageColor::Link) ->setLink({Link::Url, deletionLink}) ->setTrailingSpace(false); addText(")")->setTrailingSpace(false); @@ -634,46 +632,13 @@ void MessageBuilder::addLink(const ParsedLink &parsedLink) lowercaseLinkString += parsedLink.host.toString().toLower(); lowercaseLinkString += parsedLink.rest; - auto linkElement = Link(Link::Url, matchedLink); - auto textColor = MessageColor(MessageColor::Link); - auto *linkMELowercase = - this->emplace(lowercaseLinkString, - MessageElementFlag::LowercaseLink, textColor) - ->setLink(linkElement); - auto *linkMEOriginal = - this->emplace(origLink, MessageElementFlag::OriginalLink, - textColor) - ->setLink(linkElement); - - LinkResolver::getLinkInfo( - matchedLink, nullptr, - [weakMessage = this->weakOf(), linkMELowercase, linkMEOriginal, - matchedLink](QString tooltipText, Link originalLink, - ImagePtr thumbnail) { - auto shared = weakMessage.lock(); - if (!shared) - { - return; - } - if (!tooltipText.isEmpty()) - { - linkMELowercase->setTooltip(tooltipText); - linkMEOriginal->setTooltip(tooltipText); - } - if (originalLink.value != matchedLink && - !originalLink.value.isEmpty()) - { - linkMELowercase->setLink(originalLink)->updateLink(); - linkMEOriginal->setLink(originalLink)->updateLink(); - } - linkMELowercase->setThumbnail(thumbnail); - linkMELowercase->setThumbnailType( - MessageElement::ThumbnailType::Link_Thumbnail); - linkMEOriginal->setThumbnail(thumbnail); - linkMEOriginal->setThumbnailType( - MessageElement::ThumbnailType::Link_Thumbnail); - }); + auto *el = this->emplace( + LinkElement::Parsed{.lowercase = lowercaseLinkString, + .original = matchedLink}, + MessageElementFlag::Text, textColor); + el->setLink({Link::Url, matchedLink}); + getIApp()->getLinkResolver()->resolve(el->linkInfo()); } void MessageBuilder::addIrcMessageText(const QString &text) diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 28027ba6..56cc5b7b 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -63,18 +63,6 @@ MessageElement *MessageElement::setTooltip(const QString &tooltip) return this; } -MessageElement *MessageElement::setThumbnail(const ImagePtr &thumbnail) -{ - this->thumbnail_ = thumbnail; - return this; -} - -MessageElement *MessageElement::setThumbnailType(const ThumbnailType type) -{ - this->thumbnailType_ = type; - return this; -} - MessageElement *MessageElement::setTrailingSpace(bool value) { this->trailingSpace = value; @@ -86,17 +74,7 @@ const QString &MessageElement::getTooltip() const return this->tooltip_; } -const ImagePtr &MessageElement::getThumbnail() const -{ - return this->thumbnail_; -} - -const MessageElement::ThumbnailType &MessageElement::getThumbnailType() const -{ - return this->thumbnailType_; -} - -const Link &MessageElement::getLink() const +Link MessageElement::getLink() const { return this->link_; } @@ -116,12 +94,6 @@ void MessageElement::addFlags(MessageElementFlags flags) this->flags_.set(flags); } -MessageElement *MessageElement::updateLink() -{ - this->linkChanged.invoke(); - return this; -} - // Empty EmptyElement::EmptyElement() : MessageElement(MessageElementFlag::None) @@ -155,8 +127,8 @@ void ImageElement::addToContainer(MessageLayoutContainer &container, auto size = QSize(this->image_->width() * container.getScale(), this->image_->height() * container.getScale()); - container.addElement((new ImageLayoutElement(*this, this->image_, size)) - ->setLink(this->getLink())); + container.addElement( + (new ImageLayoutElement(*this, this->image_, size))); } } @@ -178,10 +150,8 @@ void CircularImageElement::addToContainer(MessageLayoutContainer &container, auto imgSize = QSize(this->image_->width(), this->image_->height()) * container.getScale(); - container.addElement((new ImageWithCircleBackgroundLayoutElement( - *this, this->image_, imgSize, - this->background_, this->padding_)) - ->setLink(this->getLink())); + container.addElement(new ImageWithCircleBackgroundLayoutElement( + *this, this->image_, imgSize, this->background_, this->padding_)); } } @@ -222,8 +192,7 @@ void EmoteElement::addToContainer(MessageLayoutContainer &container, QSize(int(container.getScale() * image->width() * emoteScale), int(container.getScale() * image->height() * emoteScale)); - container.addElement(this->makeImageLayoutElement(image, size) - ->setLink(this->getLink())); + container.addElement(this->makeImageLayoutElement(image, size)); } else { @@ -284,8 +253,7 @@ void LayeredEmoteElement::addToContainer(MessageLayoutContainer &container, } container.addElement(this->makeImageLayoutElement( - images, individualSizes, largestSize) - ->setLink(this->getLink())); + images, individualSizes, largestSize)); } else { @@ -441,8 +409,7 @@ EmotePtr BadgeElement::getEmote() const MessageLayoutElement *BadgeElement::makeImageLayoutElement( const ImagePtr &image, const QSize &size) { - auto *element = - (new ImageLayoutElement(*this, image, size))->setLink(this->getLink()); + auto *element = new ImageLayoutElement(*this, image, size); return element; } @@ -459,9 +426,8 @@ MessageLayoutElement *ModBadgeElement::makeImageLayoutElement( { static const QColor modBadgeBackgroundColor("#34AE0A"); - auto *element = (new ImageWithBackgroundLayoutElement( - *this, image, size, modBadgeBackgroundColor)) - ->setLink(this->getLink()); + auto *element = new ImageWithBackgroundLayoutElement( + *this, image, size, modBadgeBackgroundColor); return element; } @@ -476,8 +442,7 @@ VipBadgeElement::VipBadgeElement(const EmotePtr &data, MessageLayoutElement *VipBadgeElement::makeImageLayoutElement( const ImagePtr &image, const QSize &size) { - auto *element = - (new ImageLayoutElement(*this, image, size))->setLink(this->getLink()); + auto *element = new ImageLayoutElement(*this, image, size); return element; } @@ -494,8 +459,7 @@ MessageLayoutElement *FfzBadgeElement::makeImageLayoutElement( const ImagePtr &image, const QSize &size) { auto *element = - (new ImageWithBackgroundLayoutElement(*this, image, size, this->color)) - ->setLink(this->getLink()); + new ImageWithBackgroundLayoutElement(*this, image, size, this->color); return element; } @@ -507,11 +471,8 @@ TextElement::TextElement(const QString &text, MessageElementFlags flags, , color_(color) , style_(style) { - for (const auto &word : text.split(' ')) - { - this->words_.push_back({word, -1}); - // fourtf: add logic to store multiple spaces after message - } + this->words_ = text.split(' '); + // fourtf: add logic to store multiple spaces after message } void TextElement::addToContainer(MessageLayoutContainer &container, @@ -524,39 +485,29 @@ void TextElement::addToContainer(MessageLayoutContainer &container, QFontMetrics metrics = app->getFonts()->getFontMetrics(this->style_, container.getScale()); - for (Word &word : this->words_) + for (const auto &word : this->words_) { auto getTextLayoutElement = [&](QString text, int width, bool hasTrailingSpace) { auto color = this->color_.getColor(*app->getThemes()); app->getThemes()->normalizeColor(color); - auto *e = (new TextLayoutElement( - *this, text, QSize(width, metrics.height()), - color, this->style_, container.getScale())) - ->setLink(this->getLink()); + auto *e = new TextLayoutElement( + *this, text, QSize(width, metrics.height()), color, + this->style_, container.getScale()); e->setTrailingSpace(hasTrailingSpace); e->setText(text); - // If URL link was changed, - // Should update it in MessageLayoutElement too! - if (this->getLink().type == Link::Url) - { - static_cast(e)->listenToLinkChanges(); - } return e; }; - // fourtf: add again - // if (word.width == -1) { - word.width = metrics.horizontalAdvance(word.text); - // } + auto width = metrics.horizontalAdvance(word); // see if the text fits in the current line - if (container.fitsInLine(word.width)) + if (container.fitsInLine(width)) { container.addElementNoLineBreak(getTextLayoutElement( - word.text, word.width, this->hasTrailingSpace())); + word, width, this->hasTrailingSpace())); continue; } @@ -565,35 +516,34 @@ void TextElement::addToContainer(MessageLayoutContainer &container, { container.breakLine(); - if (container.fitsInLine(word.width)) + if (container.fitsInLine(width)) { container.addElementNoLineBreak(getTextLayoutElement( - word.text, word.width, this->hasTrailingSpace())); + word, width, this->hasTrailingSpace())); continue; } } // we done goofed, we need to wrap the text - QString text = word.text; - int textLength = text.length(); + auto textLength = word.length(); int wordStart = 0; - int width = 0; + width = 0; // QChar::isHighSurrogate(text[0].unicode()) ? 2 : 1 for (int i = 0; i < textLength; i++) { - auto isSurrogate = text.size() > i + 1 && - QChar::isHighSurrogate(text[i].unicode()); + auto isSurrogate = word.size() > i + 1 && + QChar::isHighSurrogate(word[i].unicode()); auto charWidth = isSurrogate - ? metrics.horizontalAdvance(text.mid(i, 2)) - : metrics.horizontalAdvance(text[i]); + ? metrics.horizontalAdvance(word.mid(i, 2)) + : metrics.horizontalAdvance(word[i]); if (!container.fitsInLine(width + charWidth)) { container.addElementNoLineBreak(getTextLayoutElement( - text.mid(wordStart, i - wordStart), width, false)); + word.mid(wordStart, i - wordStart), width, false)); container.breakLine(); wordStart = i; @@ -615,7 +565,7 @@ void TextElement::addToContainer(MessageLayoutContainer &container, } //add the final piece of wrapped text container.addElementNoLineBreak(getTextLayoutElement( - text.mid(wordStart), width, this->hasTrailingSpace())); + word.mid(wordStart), width, this->hasTrailingSpace())); } } } @@ -649,19 +599,12 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, auto color = this->color_.getColor(*app->getThemes()); app->getThemes()->normalizeColor(color); - auto *e = (new TextLayoutElement( - *this, text, QSize(width, metrics.height()), color, - this->style_, container.getScale())) - ->setLink(this->getLink()); + auto *e = new TextLayoutElement( + *this, text, QSize(width, metrics.height()), color, + this->style_, container.getScale()); e->setTrailingSpace(hasTrailingSpace); e->setText(text); - // If URL link was changed, - // Should update it in MessageLayoutElement too! - if (this->getLink().type == Link::Url) - { - static_cast(e)->listenToLinkChanges(); - } return e; }; @@ -749,6 +692,29 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, } } +LinkElement::LinkElement(const Parsed &parsed, MessageElementFlags flags, + const MessageColor &color, FontStyle style) + : TextElement({}, flags, color, style) + , linkInfo_(parsed.original) + , lowercase_({parsed.lowercase}) + , original_({parsed.original}) +{ + this->setTooltip(parsed.original); +} + +void LinkElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + this->words_ = + getSettings()->lowercaseDomains ? this->lowercase_ : this->original_; + TextElement::addToContainer(container, flags); +} + +Link LinkElement::getLink() const +{ + return {Link::Url, this->linkInfo_.url()}; +} + // TIMESTAMP TimestampElement::TimestampElement(QTime time) : MessageElement(MessageElementFlag::Timestamp) @@ -853,8 +819,7 @@ void ScalingImageElement::addToContainer(MessageLayoutContainer &container, auto size = QSize(image->width() * container.getScale(), image->height() * container.getScale()); - container.addElement((new ImageLayoutElement(*this, image, size)) - ->setLink(this->getLink())); + container.addElement(new ImageLayoutElement(*this, image, size)); } } diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index c35f9616..ee83f679 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -4,6 +4,7 @@ #include "messages/ImageSet.hpp" #include "messages/Link.hpp" #include "messages/MessageColor.hpp" +#include "providers/links/LinkInfo.hpp" #include "singletons/Fonts.hpp" #include @@ -136,10 +137,9 @@ enum class MessageElementFlag : int64_t { BoldUsername = (1LL << 27), NonBoldUsername = (1LL << 28), - // for links - LowercaseLink = (1LL << 29), - OriginalLink = (1LL << 30), - + // used to check if links should be lowercased + LowercaseLinks = (1LL << 29), + // Unused = (1LL << 30) // Unused: (1LL << 31) // for elements of the message reply @@ -166,9 +166,6 @@ public: Update_Images = 4, Update_All = Update_Text | Update_Emotes | Update_Images }; - enum ThumbnailType : char { - Link_Thumbnail = 1, - }; virtual ~MessageElement(); @@ -181,25 +178,18 @@ public: MessageElement *setLink(const Link &link); MessageElement *setText(const QString &text); MessageElement *setTooltip(const QString &tooltip); - MessageElement *setThumbnailType(const ThumbnailType type); - MessageElement *setThumbnail(const ImagePtr &thumbnail); MessageElement *setTrailingSpace(bool value); const QString &getTooltip() const; - const ImagePtr &getThumbnail() const; - const ThumbnailType &getThumbnailType() const; - const Link &getLink() const; + virtual Link getLink() const; bool hasTrailingSpace() const; MessageElementFlags getFlags() const; void addFlags(MessageElementFlags flags); - MessageElement *updateLink(); virtual void addToContainer(MessageLayoutContainer &container, MessageElementFlags flags) = 0; - pajlada::Signals::NoArgSignal linkChanged; - protected: MessageElement(MessageElementFlags flags); bool trailingSpace = true; @@ -208,8 +198,6 @@ private: QString text_; Link link_; QString tooltip_; - ImagePtr thumbnail_; - ThumbnailType thumbnailType_{}; MessageElementFlags flags_; }; @@ -269,15 +257,12 @@ public: void addToContainer(MessageLayoutContainer &container, MessageElementFlags flags) override; +protected: + QStringList words_; + private: MessageColor color_; FontStyle style_; - - struct Word { - QString text; - int width = -1; - }; - std::vector words_; }; // contains a text that will be truncated to one line @@ -303,6 +288,40 @@ private: std::vector words_; }; +class LinkElement : public TextElement +{ +public: + struct Parsed { + QString lowercase; + QString original; + }; + + LinkElement(const Parsed &parsed, MessageElementFlags flags, + const MessageColor &color = MessageColor::Text, + FontStyle style = FontStyle::ChatMedium); + ~LinkElement() override = default; + LinkElement(const LinkElement &) = delete; + LinkElement(LinkElement &&) = delete; + LinkElement &operator=(const LinkElement &) = delete; + LinkElement &operator=(LinkElement &&) = delete; + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + + Link getLink() const override; + + [[nodiscard]] LinkInfo *linkInfo() + { + return &this->linkInfo_; + } + +private: + LinkInfo linkInfo_; + // these are implicitly shared + QStringList lowercase_; + QStringList original_; +}; + // contains emote data and will pick the emote based on : // a) are images for the emote type enabled // b) which size it wants diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index 7bf4a47f..ffa949d7 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -77,9 +77,9 @@ MessageLayoutElement *MessageLayoutElement::setTrailingSpace(bool value) return this; } -MessageLayoutElement *MessageLayoutElement::setLink(const Link &_link) +MessageLayoutElement *MessageLayoutElement::setLink(const Link &link) { - this->link_ = _link; + this->link_ = link; return this; } @@ -89,9 +89,13 @@ MessageLayoutElement *MessageLayoutElement::setText(const QString &_text) return this; } -const Link &MessageLayoutElement::getLink() const +Link MessageLayoutElement::getLink() const { - return this->link_; + if (this->link_) + { + return *this->link_; + } + return this->creator_.getLink(); } const QString &MessageLayoutElement::getText() const @@ -406,14 +410,6 @@ TextLayoutElement::TextLayoutElement(MessageElement &_creator, QString &_text, this->setText(_text); } -void TextLayoutElement::listenToLinkChanges() -{ - this->managedConnections_.managedConnect( - static_cast(this->getCreator()).linkChanged, [this]() { - this->setLink(this->getCreator().getLink()); - }); -} - void TextLayoutElement::addCopyTextToString(QString &str, uint32_t from, uint32_t to) const { diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 894a5829..20cc2ffa 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -44,7 +44,12 @@ public: void setLine(size_t line); MessageLayoutElement *setTrailingSpace(bool value); - MessageLayoutElement *setLink(const Link &link_); + + /// @brief Overwrites the link for this layout element + /// + /// @sa #getLink() + MessageLayoutElement *setLink(const Link &link); + MessageLayoutElement *setText(const QString &text_); virtual void addCopyTextToString(QString &str, uint32_t from = 0, @@ -57,7 +62,12 @@ public: virtual int getMouseOverIndex(const QPoint &abs) const = 0; virtual int getXFromIndex(size_t index) = 0; - const Link &getLink() const; + /// @brief Returns the link this layout element has + /// + /// If there isn't any, an empty link is returned (type: None). + /// The link is sourced from the creator, but can be overwritten with + /// #setLink(). + Link getLink() const; const QString &getText() const; FlagsEnum getFlags() const; @@ -67,7 +77,7 @@ protected: private: QString text_; QRect rect_; - Link link_; + std::optional link_; MessageElement &creator_; /** * The line of the container this element is laid out at diff --git a/src/providers/LinkResolver.cpp b/src/providers/LinkResolver.cpp deleted file mode 100644 index 56b325a5..00000000 --- a/src/providers/LinkResolver.cpp +++ /dev/null @@ -1,64 +0,0 @@ -#include "providers/LinkResolver.hpp" - -#include "common/Env.hpp" -#include "common/network/NetworkRequest.hpp" -#include "common/network/NetworkResult.hpp" -#include "messages/Image.hpp" -#include "messages/Link.hpp" -#include "singletons/Settings.hpp" - -#include - -namespace chatterino { - -void LinkResolver::getLinkInfo( - const QString url, QObject *caller, - std::function successCallback) -{ - if (!getSettings()->linkInfoTooltip) - { - successCallback("No link info loaded", Link(Link::Url, url), nullptr); - return; - } - // Uncomment to test crashes - // QTimer::singleShot(3000, [=]() { - NetworkRequest(Env::get().linkResolverUrl.arg(QString::fromUtf8( - QUrl::toPercentEncoding(url, "", "/:")))) - .caller(caller) - .timeout(30000) - .onSuccess([successCallback, url](NetworkResult result) mutable { - auto root = result.parseJson(); - auto statusCode = root.value("status").toInt(); - QString response; - QString linkString = url; - ImagePtr thumbnail = nullptr; - if (statusCode == 200) - { - response = root.value("tooltip").toString(); - - if (root.contains("thumbnail")) - { - thumbnail = - Image::fromUrl({root.value("thumbnail").toString()}); - } - if (getSettings()->unshortLinks) - { - linkString = root.value("link").toString(); - } - } - else - { - response = root.value("message").toString(); - } - successCallback(QUrl::fromPercentEncoding(response.toUtf8()), - Link(Link::Url, linkString), thumbnail); - }) - .onError([successCallback, url](auto /*result*/) { - successCallback("No link info found", Link(Link::Url, url), - nullptr); - }) - .execute(); - // }); -} - -} // namespace chatterino diff --git a/src/providers/LinkResolver.hpp b/src/providers/LinkResolver.hpp deleted file mode 100644 index df4e7efd..00000000 --- a/src/providers/LinkResolver.hpp +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -namespace chatterino { - -class Image; -struct Link; -using ImagePtr = std::shared_ptr; - -class LinkResolver -{ -public: - static void getLinkInfo( - const QString url, QObject *caller, - std::function callback); -}; - -} // namespace chatterino diff --git a/src/providers/links/LinkInfo.cpp b/src/providers/links/LinkInfo.cpp new file mode 100644 index 00000000..ce03b974 --- /dev/null +++ b/src/providers/links/LinkInfo.cpp @@ -0,0 +1,106 @@ +#include "providers/links/LinkInfo.hpp" + +#include "debug/AssertInGuiThread.hpp" + +#include + +namespace chatterino { + +LinkInfo::LinkInfo(QString url) + : QObject(nullptr) + , originalUrl_(url) + , url_(std::move(url)) + , tooltip_(this->url_) +{ +} + +LinkInfo::~LinkInfo() = default; + +LinkInfo::State LinkInfo::state() const +{ + return this->state_; +} + +QString LinkInfo::url() const +{ + return this->url_; +} + +QString LinkInfo::originalUrl() const +{ + return this->originalUrl_; +} + +bool LinkInfo::isPending() const +{ + return this->state_ == State::Created; +} + +bool LinkInfo::isLoading() const +{ + return this->state_ == State::Loading; +} + +bool LinkInfo::isLoaded() const +{ + return this->state_ > State::Loading; +} + +bool LinkInfo::isResolved() const +{ + return this->state_ == State::Resolved; +} + +bool LinkInfo::hasError() const +{ + return this->state_ == State::Errored; +} + +bool LinkInfo::hasThumbnail() const +{ + return this->thumbnail_ && !this->thumbnail_->url().string.isEmpty(); +} + +QString LinkInfo::tooltip() const +{ + return this->tooltip_; +} + +ImagePtr LinkInfo::thumbnail() const +{ + return this->thumbnail_; +} + +void LinkInfo::setState(State state) +{ + assertInGuiThread(); + assert(state >= this->state_); + + if (this->state_ == state) + { + return; + } + + this->state_ = state; + this->stateChanged(state); +} + +void LinkInfo::setResolvedUrl(QString resolvedUrl) +{ + assertInGuiThread(); + this->url_ = std::move(resolvedUrl); +} + +void LinkInfo::setTooltip(QString tooltip) +{ + assertInGuiThread(); + this->tooltip_ = std::move(tooltip); +} + +void LinkInfo::setThumbnail(ImagePtr thumbnail) +{ + assertInGuiThread(); + this->thumbnail_ = std::move(thumbnail); +} + +} // namespace chatterino diff --git a/src/providers/links/LinkInfo.hpp b/src/providers/links/LinkInfo.hpp new file mode 100644 index 00000000..1d1c24b4 --- /dev/null +++ b/src/providers/links/LinkInfo.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include "messages/Image.hpp" + +namespace chatterino { + +/// @brief Rich info about a URL with tooltip and thumbnail +/// +/// This is only a data class - it doesn't do the resolving. +/// It can only be used from the GUI thread. +class LinkInfo : public QObject +{ + Q_OBJECT + +public: + /// @brief the state of a link info + /// + /// The state of a link can only increase. For example, it's not possible + /// for the link to change from "Resolved" to "Loading". + enum class State { + /// @brief The object was created, no info is resolved + /// + /// This is the initial state + Created, + /// Info is currently loading + Loading, + /// Info has been resolved and the properties have been updated + Resolved, + /// There has been an error resolving the link info (e.g. timeout) + Errored, + }; + + /// @brief Constructs a new link info for a URL + /// + /// This doesn't load any link info. + /// @see #ensureLoadingStarted() + [[nodiscard]] explicit LinkInfo(QString url); + + ~LinkInfo() override; + + LinkInfo(const LinkInfo &) = delete; + LinkInfo(LinkInfo &&) = delete; + LinkInfo &operator=(const LinkInfo &) = delete; + LinkInfo &operator=(LinkInfo &&) = delete; + + /// @brief The URL of this link + /// + /// If the "unshortLinks" setting is enabled, this can change after the + /// link is resolved. + [[nodiscard]] QString url() const; + + /// @brief The URL of this link as seen in the message + /// + /// If the "unshortLinks" setting doesn't affect this URL. + [[nodiscard]] QString originalUrl() const; + + /// Returns the current state + [[nodiscard]] State state() const; + + /// Returns true if this link has not yet been resolved (it's "Created") + [[nodiscard]] bool isPending() const; + + /// Returns true if the info is loading + [[nodiscard]] bool isLoading() const; + + /// Returns true if the info is loaded (resolved or errored) + [[nodiscard]] bool isLoaded() const; + + /// Returns true if this link has been resolved + [[nodiscard]] bool isResolved() const; + + /// Returns true if the info failed to resolve + [[nodiscard]] bool hasError() const; + + /// Returns true if this link has a thumbnail + [[nodiscard]] bool hasThumbnail() const; + + /// @brief Returns the tooltip of this link + /// + /// The tooltip contains the URL of the link and any info added by the + /// resolver. Resolvers must include the URL. + [[nodiscard]] QString tooltip() const; + + /// @brief Returns the thumbnail of this link + /// + /// The thumbnail is provided by the resolver and might not have been + /// loaded yet. + /// + /// @pre The caller must check #hasThumbnail() before calling this method + [[nodiscard]] ImagePtr thumbnail() const; + + /// @brief Updates the state and emits #stateChanged accordingly + /// + /// @pre The caller must be in the GUI thread. + /// @pre @a state must be greater or equal to the current state. + /// @see #state(), #stateChanged + void setState(State state); + + /// @brief Updates the resolved url of this link + /// + /// @pre The caller must be in the GUI thread. + /// @see #url() + void setResolvedUrl(QString resolvedUrl); + + /// @brief Updates the tooltip of this link + /// + /// @pre The caller must be in the GUI thread. + /// @see #tooltip() + void setTooltip(QString tooltip); + + /// @brief Updates the thumbnail of this link + /// + /// The thumbnail is allowed to be empty or nullptr. + /// + /// @pre The caller must be in the GUI thread. + /// @see #hasThumbnail(), #thumbnail() + void setThumbnail(ImagePtr thumbnail); + +signals: + /// @brief Emitted when this link's state changes + /// + /// @param state The new state + void stateChanged(State state); + +private: + const QString originalUrl_; + QString url_; + + QString tooltip_; + ImagePtr thumbnail_; + + State state_ = State::Created; +}; + +} // namespace chatterino diff --git a/src/providers/links/LinkResolver.cpp b/src/providers/links/LinkResolver.cpp new file mode 100644 index 00000000..6026aa6a --- /dev/null +++ b/src/providers/links/LinkResolver.cpp @@ -0,0 +1,72 @@ +#include "providers/links/LinkResolver.hpp" + +#include "common/Env.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" +#include "providers/links/LinkInfo.hpp" +#include "singletons/Settings.hpp" + +#include + +namespace chatterino { + +void LinkResolver::resolve(LinkInfo *info) +{ + using State = LinkInfo::State; + + assert(info); + + if (info->state() != State::Created) + { + // The link is already resolved or is currently loading + return; + } + + if (!getSettings()->linkInfoTooltip) + { + return; + } + + info->setTooltip("Loading..."); + info->setState(State::Loading); + + NetworkRequest(Env::get().linkResolverUrl.arg(QString::fromUtf8( + QUrl::toPercentEncoding(info->originalUrl(), {}, "/:")))) + .caller(info) + .timeout(30000) + .onSuccess([info](const NetworkResult &result) { + const auto root = result.parseJson(); + QString response; + QString url; + ImagePtr thumbnail = nullptr; + if (root["status"].toInt() == 200) + { + response = root["tooltip"].toString(); + + if (root.contains("thumbnail")) + { + info->setThumbnail( + Image::fromUrl({root["thumbnail"].toString()})); + } + if (getSettings()->unshortLinks && root.contains("link")) + { + info->setResolvedUrl(root["link"].toString()); + } + } + else + { + response = root["message"].toString(); + } + + info->setTooltip(QUrl::fromPercentEncoding(response.toUtf8())); + info->setState(State::Resolved); + }) + .onError([info](const auto &result) { + info->setTooltip(u"No link info found (" % result.formatError() % + u')'); + info->setState(State::Errored); + }) + .execute(); +} + +} // namespace chatterino diff --git a/src/providers/links/LinkResolver.hpp b/src/providers/links/LinkResolver.hpp new file mode 100644 index 00000000..c5cee79e --- /dev/null +++ b/src/providers/links/LinkResolver.hpp @@ -0,0 +1,36 @@ +#pragma once + +namespace chatterino { + +class LinkInfo; + +class ILinkResolver +{ +public: + ILinkResolver() = default; + virtual ~ILinkResolver() = default; + ILinkResolver(const ILinkResolver &) = delete; + ILinkResolver(ILinkResolver &&) = delete; + ILinkResolver &operator=(const ILinkResolver &) = delete; + ILinkResolver &operator=(ILinkResolver &&) = delete; + + virtual void resolve(LinkInfo *info) = 0; +}; + +class LinkResolver : public ILinkResolver +{ +public: + LinkResolver() = default; + + /// @brief Loads and updates the link info + /// + /// Calling this with an already resolved or currently loading info is a + /// no-op. Loading can be blocked by disabling the "linkInfoTooltip" + /// setting. URLs will be unshortened if the "unshortLinks" setting is + /// enabled. The resolver is set through Env::linkResolverUrl. + /// + /// @pre @a info must not be nullptr + void resolve(LinkInfo *info) override; +}; + +} // namespace chatterino diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index bf75f0ed..71154706 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -185,8 +185,7 @@ void WindowManager::updateWordTypeMask() flags.set(MEF::Collapsed); flags.set(settings->boldUsernames ? MEF::BoldUsername : MEF::NonBoldUsername); - flags.set(settings->lowercaseDomains ? MEF::LowercaseLink - : MEF::OriginalLink); + flags.set(MEF::LowercaseLinks, settings->lowercaseDomains); flags.set(MEF::ChannelPointReward); // update flags diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 1f494834..fe779174 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -19,7 +19,8 @@ #include "messages/MessageElement.hpp" #include "messages/MessageThread.hpp" #include "providers/colors/ColorProvider.hpp" -#include "providers/LinkResolver.hpp" +#include "providers/links/LinkInfo.hpp" +#include "providers/links/LinkResolver.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -1937,55 +1938,16 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) } else { - if (element->getTooltip() == "No link info loaded") - { - std::weak_ptr weakLayout = layout; - LinkResolver::getLinkInfo( - element->getLink().value, nullptr, - [weakLayout, element](QString tooltipText, - Link originalLink, - ImagePtr thumbnail) { - auto shared = weakLayout.lock(); - if (!shared) - { - return; - } - element->setTooltip(tooltipText); - element->setThumbnail(thumbnail); - }); - } auto thumbnailSize = getSettings()->thumbnailSize; - if (thumbnailSize == 0) + auto *linkElement = dynamic_cast(element); + if (linkElement) { - // "Show thumbnails" is set to "Off", show text only - this->tooltipWidget_->setOne({nullptr, element->getTooltip()}); - } - else - { - const auto shouldHideThumbnail = - isInStreamerMode() && - getSettings()->streamerModeHideLinkThumbnails && - element->getThumbnail() != nullptr && - !element->getThumbnail()->url().string.isEmpty(); - auto thumb = - shouldHideThumbnail - ? Image::fromResourcePixmap(getResources().streamerMode) - : element->getThumbnail(); - - if (element->getThumbnailType() == - MessageElement::ThumbnailType::Link_Thumbnail) + if (linkElement->linkInfo()->isPending()) { - this->tooltipWidget_->setOne({ - std::move(thumb), - element->getTooltip(), - thumbnailSize, - thumbnailSize, - }); - } - else - { - this->tooltipWidget_->setOne({std::move(thumb), ""}); + getIApp()->getLinkResolver()->resolve( + linkElement->linkInfo()); } + this->setLinkInfoTooltip(linkElement->linkInfo()); } } @@ -3103,4 +3065,62 @@ bool ChannelView::canReplyToMessages() const return true; } +void ChannelView::setLinkInfoTooltip(LinkInfo *info) +{ + assert(info); + + auto thumbnailSize = getSettings()->thumbnailSize; + + ImagePtr thumbnail; + if (info->hasThumbnail() && thumbnailSize > 0) + { + if (isInStreamerMode() && getSettings()->streamerModeHideLinkThumbnails) + { + thumbnail = Image::fromResourcePixmap(getResources().streamerMode); + } + else + { + thumbnail = info->thumbnail(); + } + } + + this->tooltipWidget_->setOne({ + .image = thumbnail, + .text = info->tooltip(), + .customWidth = thumbnailSize, + .customHeight = thumbnailSize, + }); + + if (info->isLoaded()) + { + this->pendingLinkInfo_.clear(); + return; // Either resolved or errored (can't change anymore) + } + + // listen to changes + + if (this->pendingLinkInfo_.data() == info) + { + return; // same info - already registered + } + + if (this->pendingLinkInfo_) + { + QObject::disconnect(this->pendingLinkInfo_.data(), + &LinkInfo::stateChanged, this, nullptr); + } + QObject::connect(info, &LinkInfo::stateChanged, this, + &ChannelView::pendingLinkInfoStateChanged); + this->pendingLinkInfo_ = info; +} + +void ChannelView::pendingLinkInfoStateChanged() +{ + if (!this->pendingLinkInfo_) + { + return; + } + this->setLinkInfoTooltip(this->pendingLinkInfo_.data()); +} + } // namespace chatterino diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index e55e2cfe..50156c5e 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +48,8 @@ class Split; class FilterSet; using FilterSetPtr = std::shared_ptr; +class LinkInfo; + enum class PauseReason { Mouse, Selection, @@ -366,6 +369,17 @@ private: void scrollUpdateRequested(); TooltipWidget *const tooltipWidget_{}; + + /// Pointer to a link info that hasn't loaded yet + QPointer pendingLinkInfo_; + + /// @brief Sets the tooltip to contain the link info + /// + /// If the info isn't loaded yet, it's tracked until it's resolved or errored. + void setLinkInfoTooltip(LinkInfo *info); + + /// Slot for the LinkInfo::stateChanged signal. + void pendingLinkInfoStateChanged(); }; } // namespace chatterino diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 564d6ad4..53ffd5e1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,6 +38,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/Selection.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NotebookTab.cpp ${CMAKE_CURRENT_LIST_DIR}/src/SplitInput.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/LinkInfo.cpp # Add your new file above this line! ) diff --git a/tests/src/LinkInfo.cpp b/tests/src/LinkInfo.cpp new file mode 100644 index 00000000..91f06503 --- /dev/null +++ b/tests/src/LinkInfo.cpp @@ -0,0 +1,102 @@ +#include "providers/links/LinkInfo.hpp" + +#include "common/Literals.hpp" +#include "SignalSpy.hpp" + +#include + +using namespace chatterino; +using namespace literals; + +using State = LinkInfo::State; + +TEST(LinkInfo, initialState) +{ + LinkInfo info(u"https://chatterino.com"_s); + ASSERT_EQ(info.url(), u"https://chatterino.com"_s); + ASSERT_EQ(info.originalUrl(), u"https://chatterino.com"_s); + ASSERT_EQ(info.state(), State::Created); + ASSERT_FALSE(info.hasThumbnail()); + ASSERT_EQ(info.tooltip(), u"https://chatterino.com"_s); +} + +TEST(LinkInfo, states) +{ + LinkInfo info(u"https://chatterino.com"_s); + SignalSpy spy(&info, &LinkInfo::stateChanged); + + info.setState(State::Created); + ASSERT_TRUE(spy.empty()); + ASSERT_TRUE(info.isPending()); + ASSERT_FALSE(info.isLoading()); + ASSERT_FALSE(info.isLoaded()); + ASSERT_FALSE(info.isResolved()); + ASSERT_FALSE(info.hasError()); + + info.setState(State::Loading); + ASSERT_EQ(spy.size(), 1); + ASSERT_EQ(spy.last(), State::Loading); + ASSERT_EQ(info.state(), State::Loading); + ASSERT_FALSE(info.isPending()); + ASSERT_TRUE(info.isLoading()); + ASSERT_FALSE(info.isLoaded()); + ASSERT_FALSE(info.isResolved()); + ASSERT_FALSE(info.hasError()); + + info.setState(State::Loading); + ASSERT_EQ(spy.size(), 1); + + info.setState(State::Resolved); + ASSERT_EQ(spy.size(), 2); + ASSERT_EQ(spy.last(), State::Resolved); + ASSERT_EQ(info.state(), State::Resolved); + ASSERT_FALSE(info.isPending()); + ASSERT_FALSE(info.isLoading()); + ASSERT_TRUE(info.isLoaded()); + ASSERT_TRUE(info.isResolved()); + ASSERT_FALSE(info.hasError()); + + info.setState(State::Errored); + ASSERT_EQ(spy.size(), 3); + ASSERT_EQ(spy.last(), State::Errored); + ASSERT_EQ(info.state(), State::Errored); + ASSERT_FALSE(info.isPending()); + ASSERT_FALSE(info.isLoading()); + ASSERT_TRUE(info.isLoaded()); + ASSERT_FALSE(info.isResolved()); + ASSERT_TRUE(info.hasError()); +} + +TEST(LinkInfo, setters) +{ + LinkInfo info(u"https://chatterino.com"_s); + SignalSpy spy(&info, &LinkInfo::stateChanged); + + ASSERT_EQ(info.url(), u"https://chatterino.com"_s); + ASSERT_EQ(info.originalUrl(), u"https://chatterino.com"_s); + ASSERT_FALSE(info.hasThumbnail()); + ASSERT_EQ(info.tooltip(), u"https://chatterino.com"_s); + + info.setTooltip(u"tooltip"_s); + ASSERT_EQ(info.tooltip(), u"tooltip"_s); + + info.setResolvedUrl(u"https://www.chatterino.com"_s); + ASSERT_EQ(info.url(), u"https://www.chatterino.com"_s); + ASSERT_EQ(info.originalUrl(), u"https://chatterino.com"_s); + + info.setThumbnail(nullptr); + ASSERT_FALSE(info.hasThumbnail()); + + info.setThumbnail(Image::getEmpty()); + ASSERT_FALSE(info.hasThumbnail()); + + info.setThumbnail(Image::fromUrl(Url{""})); + ASSERT_FALSE(info.hasThumbnail()); + + auto image = Image::fromUrl(Url{"https://www.chatterino.com"}); + info.setThumbnail(image); + ASSERT_TRUE(info.hasThumbnail()); + ASSERT_EQ(info.thumbnail(), image); + + ASSERT_TRUE(spy.empty()); +} diff --git a/tests/src/SignalSpy.hpp b/tests/src/SignalSpy.hpp new file mode 100644 index 00000000..6662c853 --- /dev/null +++ b/tests/src/SignalSpy.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include + +#include +#include + +template +class SignalSpy : public QObject +{ + // Simplify the storage if there's only one argument to the signal handler. + using Call = + std::conditional_t>, + std::tuple>; + +public: + SignalSpy(auto *sender, auto method) + { + auto conn = + QObject::connect(sender, method, this, &SignalSpy::slot, + Qt::DirectConnection); + assert(conn && "Failed to connect"); + } + + bool empty() const + { + return this->calls_.empty(); + } + + size_t size() const + { + return this->calls_.size(); + } + + const std::vector> &calls() const + { + return this->calls_; + } + + void clear() + { + this->calls_.clear(); + } + + const Call &last() const + { + assert(!this->calls_.empty()); + + return this->calls_.back(); + } + +private: + void slot(Args... args) + { + this->calls_.emplace_back(std::move(args)...); + } + + std::vector calls_; +};