Added support for Twitch's Chat Replies (#3722)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Daniel Sage
2022-07-31 06:45:25 -04:00
committed by GitHub
parent a280089693
commit 20c974fdab
53 changed files with 2022 additions and 310 deletions
+2
View File
@@ -23,6 +23,8 @@ public:
JumpToChannel,
Reconnect,
CopyToClipboard,
ReplyToMessage,
ViewThread,
};
Link();
+6
View File
@@ -13,6 +13,7 @@
namespace chatterino {
class MessageElement;
class MessageThread;
enum class MessageFlag : uint32_t {
None = 0,
@@ -40,6 +41,7 @@ enum class MessageFlag : uint32_t {
RedeemedChannelPointReward = (1 << 21),
ShowInMentions = (1 << 22),
FirstMessage = (1 << 23),
ReplyMessage = (1 << 24),
};
using MessageFlags = FlagsEnum<MessageFlag>;
@@ -68,6 +70,10 @@ struct Message : boost::noncopyable {
std::vector<Badge> badges;
std::unordered_map<QString, QString> badgeInfos;
std::shared_ptr<QColor> highlightColor;
// Each reply holds a reference to the thread. When every reply is dropped,
// the reply thread will be cleaned up by the TwitchChannel.
// The root of the thread does not have replyThread set.
std::shared_ptr<MessageThread> replyThread;
uint32_t count = 1;
std::vector<std::unique_ptr<MessageElement>> elements;
+178
View File
@@ -5,6 +5,8 @@
#include "messages/Emote.hpp"
#include "messages/layouts/MessageLayoutContainer.hpp"
#include "messages/layouts/MessageLayoutElement.hpp"
#include "providers/emoji/Emojis.hpp"
#include "singletons/Emotes.hpp"
#include "singletons/Settings.hpp"
#include "singletons/Theme.hpp"
#include "util/DebugCount.hpp"
@@ -132,6 +134,31 @@ void ImageElement::addToContainer(MessageLayoutContainer &container,
}
}
CircularImageElement::CircularImageElement(ImagePtr image, int padding,
QColor background,
MessageElementFlags flags)
: MessageElement(flags)
, image_(image)
, padding_(padding)
, background_(background)
{
}
void CircularImageElement::addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags)
{
if (flags.hasAny(this->getFlags()))
{
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()));
}
}
// EMOTE
EmoteElement::EmoteElement(const EmotePtr &emote, MessageElementFlags flags,
const MessageColor &textElementColor)
@@ -395,6 +422,137 @@ void TextElement::addToContainer(MessageLayoutContainer &container,
}
}
SingleLineTextElement::SingleLineTextElement(const QString &text,
MessageElementFlags flags,
const MessageColor &color,
FontStyle style)
: MessageElement(flags)
, color_(color)
, style_(style)
{
for (const auto &word : text.split(' '))
{
this->words_.push_back({word, -1});
}
}
void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags)
{
auto app = getApp();
if (flags.hasAny(this->getFlags()))
{
QFontMetrics metrics =
app->fonts->getFontMetrics(this->style_, container.getScale());
auto getTextLayoutElement = [&](QString text, int width,
bool hasTrailingSpace) {
auto color = this->color_.getColor(*app->themes);
app->themes->normalizeColor(color);
auto e = (new TextLayoutElement(
*this, text, QSize(width, metrics.height()), color,
this->style_, container.getScale()))
->setLink(this->getLink());
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;
};
static const auto ellipsis = QStringLiteral("...");
auto addEllipsis = [&]() {
int ellipsisSize = metrics.horizontalAdvance(ellipsis);
container.addElementNoLineBreak(
getTextLayoutElement(ellipsis, ellipsisSize, false));
};
for (Word &word : this->words_)
{
auto parsedWords = app->emotes->emojis.parse(word.text);
if (parsedWords.size() == 0)
{
continue; // sanity check
}
auto &parsedWord = parsedWords[0];
if (parsedWord.type() == typeid(EmotePtr))
{
auto emote = boost::get<EmotePtr>(parsedWord);
auto image =
emote->images.getImageOrLoaded(container.getScale());
if (!image->isEmpty())
{
auto emoteScale = getSettings()->emoteScale.getValue();
auto size = QSize(image->width(), image->height()) *
(emoteScale * container.getScale());
if (!container.fitsInLine(size.width()))
{
addEllipsis();
break;
}
container.addElementNoLineBreak(
(new ImageLayoutElement(*this, image, size))
->setLink(this->getLink()));
}
}
else if (parsedWord.type() == typeid(QString))
{
word.width = metrics.horizontalAdvance(word.text);
// see if the text fits in the current line
if (container.fitsInLine(word.width))
{
container.addElementNoLineBreak(getTextLayoutElement(
word.text, word.width, this->hasTrailingSpace()));
}
else
{
// word overflows, try minimum truncation
bool cutSuccess = false;
for (size_t cut = 1; cut < word.text.length(); ++cut)
{
// Cut off n characters and append the ellipsis.
// Try removing characters one by one until the word fits.
QString truncatedWord =
word.text.chopped(cut) + ellipsis;
int newSize = metrics.horizontalAdvance(truncatedWord);
if (container.fitsInLine(newSize))
{
container.addElementNoLineBreak(
getTextLayoutElement(truncatedWord, newSize,
false));
cutSuccess = true;
break;
}
}
if (!cutSuccess)
{
// We weren't able to show any part of the current word, so
// just append the ellipsis.
addEllipsis();
}
break;
}
}
}
container.breakLine();
}
}
// TIMESTAMP
TimestampElement::TimestampElement(QTime time)
: MessageElement(MessageElementFlag::Timestamp)
@@ -502,4 +660,24 @@ void ScalingImageElement::addToContainer(MessageLayoutContainer &container,
}
}
ReplyCurveElement::ReplyCurveElement()
: MessageElement(MessageElementFlag::RepliedMessage)
// these values nicely align with a single badge
, neededMargin_(3)
, size_(18, 14)
{
}
void ReplyCurveElement::addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags)
{
if (flags.hasAny(this->getFlags()))
{
QSize boxSize = this->size_ * container.getScale();
container.addElement(new ReplyCurveLayoutElement(
*this, boxSize, 1.5 * container.getScale(),
this->neededMargin_ * container.getScale()));
}
}
} // namespace chatterino
+59
View File
@@ -126,6 +126,12 @@ enum class MessageElementFlag : int64_t {
// e.g. BTTV's SoSnowy during christmas season
ZeroWidthEmote = (1LL << 31),
// for elements of the message reply
RepliedMessage = (1LL << 32),
// for the reply button element
ReplyButton = (1LL << 33),
Default = Timestamp | Badges | Username | BitsStatic | FfzEmoteImage |
BttvEmoteImage | TwitchEmoteImage | BitsAmount | Text |
AlwaysShow,
@@ -209,6 +215,22 @@ private:
ImagePtr image_;
};
// contains a image with a circular background color
class CircularImageElement : public MessageElement
{
public:
CircularImageElement(ImagePtr image, int padding, QColor background,
MessageElementFlags flags);
void addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags) override;
private:
ImagePtr image_;
int padding_;
QColor background_;
};
// contains a text, it will split it into words
class TextElement : public MessageElement
{
@@ -232,6 +254,29 @@ private:
std::vector<Word> words_;
};
// contains a text that will be truncated to one line
class SingleLineTextElement : public MessageElement
{
public:
SingleLineTextElement(const QString &text, MessageElementFlags flags,
const MessageColor &color = MessageColor::Text,
FontStyle style = FontStyle::ChatMedium);
~SingleLineTextElement() override = default;
void addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags) override;
private:
MessageColor color_;
FontStyle style_;
struct Word {
QString text;
int width = -1;
};
std::vector<Word> words_;
};
// contains emote data and will pick the emote based on :
// a) are images for the emote type enabled
// b) which size it wants
@@ -355,4 +400,18 @@ public:
private:
ImageSet images_;
};
class ReplyCurveElement : public MessageElement
{
public:
ReplyCurveElement();
void addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags) override;
private:
int neededMargin_;
QSize size_;
};
} // namespace chatterino
+61
View File
@@ -0,0 +1,61 @@
#include "MessageThread.hpp"
#include "messages/Message.hpp"
#include "util/DebugCount.hpp"
#include <utility>
namespace chatterino {
MessageThread::MessageThread(std::shared_ptr<const Message> rootMessage)
: rootMessageId_(rootMessage->id)
, rootMessage_(std::move(rootMessage))
{
DebugCount::increase("message threads");
}
MessageThread::~MessageThread()
{
DebugCount::decrease("message threads");
}
void MessageThread::addToThread(const std::shared_ptr<const Message> &message)
{
this->replies_.emplace_back(message);
}
void MessageThread::addToThread(const std::weak_ptr<const Message> &message)
{
this->replies_.push_back(message);
}
size_t MessageThread::liveCount() const
{
size_t count = 0;
for (auto reply : this->replies_)
{
if (!reply.expired())
{
++count;
}
}
return count;
}
size_t MessageThread::liveCount(
const std::shared_ptr<const Message> &exclude) const
{
size_t count = 0;
for (auto reply : this->replies_)
{
if (!reply.expired() && reply.lock() != exclude)
{
++count;
}
}
return count;
}
} // namespace chatterino
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <QString>
#include <memory>
#include <vector>
namespace chatterino {
struct Message;
class MessageThread
{
public:
MessageThread(std::shared_ptr<const Message> rootMessage);
~MessageThread();
void addToThread(const std::shared_ptr<const Message> &message);
void addToThread(const std::weak_ptr<const Message> &message);
/// Returns the number of live reply references
size_t liveCount() const;
/// Returns the number of live reply references
size_t liveCount(const std::shared_ptr<const Message> &exclude) const;
const QString &rootId() const
{
return rootMessageId_;
}
const std::shared_ptr<const Message> &root() const
{
return rootMessage_;
}
const std::vector<std::weak_ptr<const Message>> &replies() const
{
return replies_;
}
private:
const QString rootMessageId_;
const std::shared_ptr<const Message> rootMessage_;
std::vector<std::weak_ptr<const Message>> replies_;
};
} // namespace chatterino
+33
View File
@@ -55,6 +55,11 @@ const Message *MessageLayout::getMessage()
return this->message_.get();
}
const MessagePtr &MessageLayout::getMessagePtr() const
{
return this->message_;
}
// Height
int MessageLayout::getHeight() const
{
@@ -147,6 +152,12 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags)
continue;
}
if (!this->renderReplies_ &&
element->getFlags().has(MessageElementFlag::RepliedMessage))
{
continue;
}
element->addToContainer(*this->container_, flags);
}
@@ -414,4 +425,26 @@ void MessageLayout::addSelectionText(QString &str, int from, int to,
this->container_->addSelectionText(str, from, to, copymode);
}
bool MessageLayout::isReplyable() const
{
if (this->message_->loginName.isEmpty())
{
return false;
}
if (this->message_->flags.hasAny(
{MessageFlag::System, MessageFlag::Subscription,
MessageFlag::Timeout, MessageFlag::Whisper}))
{
return false;
}
return true;
}
void MessageLayout::setRenderReplies(bool render)
{
this->renderReplies_ = render;
}
} // namespace chatterino
+4
View File
@@ -37,6 +37,7 @@ public:
~MessageLayout();
const Message *getMessage();
const MessagePtr &getMessagePtr() const;
int getHeight() const;
@@ -62,6 +63,8 @@ public:
// Misc
bool isDisabled() const;
bool isReplyable() const;
void setRenderReplies(bool render);
private:
// variables
@@ -69,6 +72,7 @@ private:
std::shared_ptr<MessageLayoutContainer> container_;
std::shared_ptr<QPixmap> buffer_{};
bool bufferValid_ = false;
bool renderReplies_ = true;
int height_ = 0;
@@ -661,6 +661,14 @@ void MessageLayoutContainer::addSelectionText(QString &str, int from, int to,
for (auto &element : this->elements_)
{
if (copymode != CopyMode::Everything &&
element->getCreator().getFlags().has(
MessageElementFlag::RepliedMessage))
{
// Don't include the message being replied to
continue;
}
if (copymode == CopyMode::OnlyTextAndEmotes)
{
if (element->getCreator().getFlags().hasAny(
@@ -10,6 +10,7 @@
#include <QDebug>
#include <QPainter>
#include <QPainterPath>
namespace chatterino {
@@ -205,6 +206,44 @@ void ImageWithBackgroundLayoutElement::paint(QPainter &painter)
}
}
//
// IMAGE WITH CIRCLE BACKGROUND
//
ImageWithCircleBackgroundLayoutElement::ImageWithCircleBackgroundLayoutElement(
MessageElement &creator, ImagePtr image, const QSize &imageSize,
QColor color, int padding)
: ImageLayoutElement(creator, image,
imageSize + QSize(padding, padding) * 2)
, color_(color)
, imageSize_(imageSize)
, padding_(padding)
{
}
void ImageWithCircleBackgroundLayoutElement::paint(QPainter &painter)
{
if (this->image_ == nullptr)
{
return;
}
auto pixmap = this->image_->pixmapOrLoad();
if (pixmap && !this->image_->animated())
{
QRectF boxRect(this->getRect());
painter.setPen(Qt::NoPen);
painter.setBrush(QBrush(this->color_, Qt::SolidPattern));
painter.drawEllipse(boxRect);
QRectF imgRect;
imgRect.setTopLeft(boxRect.topLeft());
imgRect.setSize(this->imageSize_);
imgRect.translate(this->padding_, this->padding_);
painter.drawPixmap(imgRect, *pixmap, QRectF());
}
}
//
// TEXT
//
@@ -402,4 +441,67 @@ int TextIconLayoutElement::getXFromIndex(int index)
}
}
ReplyCurveLayoutElement::ReplyCurveLayoutElement(MessageElement &creator,
const QSize &size,
float thickness,
float neededMargin)
: MessageLayoutElement(creator, size)
, pen_(QColor("#888"), thickness, Qt::SolidLine, Qt::RoundCap)
, neededMargin_(neededMargin)
{
}
void ReplyCurveLayoutElement::paint(QPainter &painter)
{
QRectF paintRect(this->getRect());
QPainterPath bezierPath;
qreal top = paintRect.top() + paintRect.height() * 0.25; // 25% from top
qreal left = paintRect.left() + this->neededMargin_;
qreal bottom = paintRect.bottom() - this->neededMargin_;
QPointF startPoint(left, bottom);
QPointF controlPoint(left, top);
QPointF endPoint(paintRect.right(), top);
// Create curve path
bezierPath.moveTo(startPoint);
bezierPath.quadTo(controlPoint, endPoint);
// Render curve
painter.setPen(this->pen_);
painter.setRenderHint(QPainter::Antialiasing);
painter.drawPath(bezierPath);
}
void ReplyCurveLayoutElement::paintAnimated(QPainter &painter, int yOffset)
{
}
int ReplyCurveLayoutElement::getMouseOverIndex(const QPoint &abs) const
{
return 0;
}
int ReplyCurveLayoutElement::getXFromIndex(int index)
{
if (index <= 0)
{
return this->getRect().left();
}
else
{
return this->getRect().right();
}
}
void ReplyCurveLayoutElement::addCopyTextToString(QString &str, int from,
int to) const
{
}
int ReplyCurveLayoutElement::getSelectionIndexCount() const
{
return 1;
}
} // namespace chatterino
@@ -1,5 +1,6 @@
#pragma once
#include <QPen>
#include <QPoint>
#include <QRect>
#include <QString>
@@ -93,6 +94,23 @@ private:
QColor color_;
};
class ImageWithCircleBackgroundLayoutElement : public ImageLayoutElement
{
public:
ImageWithCircleBackgroundLayoutElement(MessageElement &creator,
ImagePtr image,
const QSize &imageSize, QColor color,
int padding);
protected:
void paint(QPainter &painter) override;
private:
const QColor color_;
const QSize imageSize_;
const int padding_;
};
// TEXT
class TextLayoutElement : public MessageLayoutElement
{
@@ -142,4 +160,24 @@ private:
QString line2;
};
class ReplyCurveLayoutElement : public MessageLayoutElement
{
public:
ReplyCurveLayoutElement(MessageElement &creator, const QSize &size,
float thickness, float lMargin);
protected:
void paint(QPainter &painter) override;
void paintAnimated(QPainter &painter, int yOffset) override;
int getMouseOverIndex(const QPoint &abs) const override;
int getXFromIndex(int index) override;
void addCopyTextToString(QString &str, int from = 0,
int to = INT_MAX) const override;
int getSelectionIndexCount() const override;
private:
const QPen pen_;
const float neededMargin_;
};
} // namespace chatterino