refactor: Turn link-info into its own element and class (#5178)

This commit is contained in:
nerix 2024-02-18 13:34:00 +01:00 committed by GitHub
parent 42e4559910
commit e130c48f76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 752 additions and 314 deletions

View file

@ -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)

View file

@ -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_;

View file

@ -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();

View file

@ -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> ffzEmotes;
std::unique_ptr<SeventvEmotes> seventvEmotes;
const std::unique_ptr<Logging> logging;
std::unique_ptr<ILinkResolver> 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:

View file

@ -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

View file

@ -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<TimestampElement>();
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<TextElement>(text, mefs, color);
return this->emplace<TextElement>(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<TextElement>(lowercaseLinkString,
MessageElementFlag::LowercaseLink, textColor)
->setLink(linkElement);
auto *linkMEOriginal =
this->emplace<TextElement>(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>(
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)

View file

@ -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<TextLayoutElement *>(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<TextLayoutElement *>(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));
}
}

View file

@ -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 <pajlada/signals/signalholder.hpp>
@ -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<Word> words_;
};
// contains a text that will be truncated to one line
@ -303,6 +288,40 @@ private:
std::vector<Word> 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

View file

@ -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<TextElement &>(this->getCreator()).linkChanged, [this]() {
this->setLink(this->getCreator().getLink());
});
}
void TextLayoutElement::addCopyTextToString(QString &str, uint32_t from,
uint32_t to) const
{

View file

@ -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<MessageElementFlag> getFlags() const;
@ -67,7 +77,7 @@ protected:
private:
QString text_;
QRect rect_;
Link link_;
std::optional<Link> link_;
MessageElement &creator_;
/**
* The line of the container this element is laid out at

View file

@ -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 <QString>
namespace chatterino {
void LinkResolver::getLinkInfo(
const QString url, QObject *caller,
std::function<void(QString, Link, ImagePtr)> 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

View file

@ -1,23 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <functional>
#include <memory>
namespace chatterino {
class Image;
struct Link;
using ImagePtr = std::shared_ptr<Image>;
class LinkResolver
{
public:
static void getLinkInfo(
const QString url, QObject *caller,
std::function<void(QString, Link, ImagePtr)> callback);
};
} // namespace chatterino

View file

@ -0,0 +1,106 @@
#include "providers/links/LinkInfo.hpp"
#include "debug/AssertInGuiThread.hpp"
#include <QString>
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

View file

@ -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

View file

@ -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 <QStringBuilder>
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

View file

@ -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

View file

@ -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

View file

@ -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<MessageLayout> 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<LinkElement *>(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

View file

@ -12,6 +12,7 @@
#include <pajlada/signals/signal.hpp>
#include <QMenu>
#include <QPaintEvent>
#include <QPointer>
#include <QScroller>
#include <QTimer>
#include <QVariantAnimation>
@ -47,6 +48,8 @@ class Split;
class FilterSet;
using FilterSetPtr = std::shared_ptr<FilterSet>;
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<LinkInfo> 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

View file

@ -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!
)

102
tests/src/LinkInfo.cpp Normal file
View file

@ -0,0 +1,102 @@
#include "providers/links/LinkInfo.hpp"
#include "common/Literals.hpp"
#include "SignalSpy.hpp"
#include <gtest/gtest.h>
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<State> 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<State> 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());
}

60
tests/src/SignalSpy.hpp Normal file
View file

@ -0,0 +1,60 @@
#pragma once
#include <QObject>
#include <tuple>
#include <vector>
template <typename... Args>
class SignalSpy : public QObject
{
// Simplify the storage if there's only one argument to the signal handler.
using Call =
std::conditional_t<sizeof...(Args) == 1,
std::tuple_element_t<0, std::tuple<Args...>>,
std::tuple<Args...>>;
public:
SignalSpy(auto *sender, auto method)
{
auto conn =
QObject::connect(sender, method, this, &SignalSpy<Args...>::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<std::tuple<Args...>> &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<Call> calls_;
};