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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 2022 additions and 310 deletions

View file

@ -2,6 +2,7 @@
## Unversioned
- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722)
- Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875)
- Minor: Added option to display tabs on the right and bottom. (#3847)
- Minor: Added `is:first-msg` search option. (#3700)

View file

@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 0C4.5 0 0 4.5 0 10s4.5 10 10 10 10-4.5 10-10S15.5 0 10 0Zm5 13.6L13.6 15 10 11.4 6.4 15 5 13.6 8.6 10 5 6.4 6.4 5 10 8.6 13.6 5 15 6.4 11.4 10l3.6 3.6Z" fill="#ffffff" fill-rule="evenodd" class="fill-000000"></path></svg>

After

Width:  |  Height:  |  Size: 294 B

View file

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="20px" version="1.1" viewBox="0 0 20 20" width="20px" xmlns="http://www.w3.org/2000/svg" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><desc/><defs/><g fill="none" fill-rule="evenodd" id="Page-1" stroke="none" stroke-width="1"><g fill="#000000" id="Core" transform="translate(-380.000000, -44.000000)"><g id="cancel" transform="translate(380.000000, 44.000000)"><path d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M15,13.6 L13.6,15 L10,11.4 L6.4,15 L5,13.6 L8.6,10 L5,6.4 L6.4,5 L10,8.6 L13.6,5 L15,6.4 L11.4,10 L15,13.6 L15,13.6 Z" id="Shape"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

View file

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="17px" version="1.1" viewBox="0 0 18 17" width="18px" xmlns="http://www.w3.org/2000/svg" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><desc/><defs/><g fill="none" fill-rule="evenodd" id="Page-1" stroke="none" stroke-width="1"><g fill="#000000" id="Core" transform="translate(-45.000000, -382.000000)"><g id="reply" transform="translate(45.000000, 382.500000)"><path d="M7,4 L7,0 L0,7 L7,14 L7,9.9 C12,9.9 15.5,11.5 18,15 C17,10 14,5 7,4 L7,4 Z" id="Shape"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

View file

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="17px" version="1.1" viewBox="0 0 24 17" width="24px" xmlns="http://www.w3.org/2000/svg" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><desc/><defs/><g fill="none" fill-rule="evenodd" id="Page-1" stroke="none" stroke-width="1"><g fill="#000000" id="Core" transform="translate(-84.000000, -382.000000)"><g id="reply-all" transform="translate(84.000000, 382.500000)"><path d="M7,3 L7,0 L0,7 L7,14 L7,11 L3,7 L7,3 L7,3 Z M13,4 L13,0 L6,7 L13,14 L13,9.9 C18,9.9 21.5,11.5 24,15 C23,10 20,5 13,4 L13,4 Z" id="Shape"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 624 B

View file

@ -20,6 +20,8 @@
<file>buttons/addSplitDark.png</file>
<file>buttons/ban.png</file>
<file>buttons/banRed.png</file>
<file>buttons/cancel.svg</file>
<file>buttons/cancelDark.svg</file>
<file>buttons/clearSearch.png</file>
<file>buttons/copyDark.png</file>
<file>buttons/copyDark.svg</file>
@ -34,6 +36,10 @@
<file>buttons/modModeDisabled2.png</file>
<file>buttons/modModeEnabled.png</file>
<file>buttons/modModeEnabled2.png</file>
<file>buttons/replyDark.png</file>
<file>buttons/replyDark.svg</file>
<file>buttons/replyThreadDark.png</file>
<file>buttons/replyThreadDark.svg</file>
<file>buttons/search.png</file>
<file>buttons/timeout.png</file>
<file>buttons/trashCan.png</file>

View file

@ -144,6 +144,8 @@ set(SOURCE_FILES
messages/MessageContainer.hpp
messages/MessageElement.cpp
messages/MessageElement.hpp
messages/MessageThread.cpp
messages/MessageThread.hpp
messages/SharedMessageBuilder.cpp
messages/SharedMessageBuilder.hpp
@ -347,6 +349,8 @@ set(SOURCE_FILES
widgets/BaseWidget.hpp
widgets/BaseWindow.cpp
widgets/BaseWindow.hpp
widgets/DraggablePopup.cpp
widgets/DraggablePopup.hpp
widgets/FramelessEmbedWindow.cpp
widgets/FramelessEmbedWindow.hpp
widgets/Label.cpp
@ -383,6 +387,8 @@ set(SOURCE_FILES
widgets/dialogs/NotificationPopup.hpp
widgets/dialogs/QualityPopup.cpp
widgets/dialogs/QualityPopup.hpp
widgets/dialogs/ReplyThreadPopup.cpp
widgets/dialogs/ReplyThreadPopup.hpp
widgets/dialogs/SelectChannelDialog.cpp
widgets/dialogs/SelectChannelDialog.hpp
widgets/dialogs/SelectChannelFiltersDialog.cpp

View file

@ -32,6 +32,8 @@ Resources2::Resources2()
this->buttons.modModeDisabled2 = QPixmap(":/buttons/modModeDisabled2.png");
this->buttons.modModeEnabled = QPixmap(":/buttons/modModeEnabled.png");
this->buttons.modModeEnabled2 = QPixmap(":/buttons/modModeEnabled2.png");
this->buttons.replyDark = QPixmap(":/buttons/replyDark.png");
this->buttons.replyThreadDark = QPixmap(":/buttons/replyThreadDark.png");
this->buttons.search = QPixmap(":/buttons/search.png");
this->buttons.timeout = QPixmap(":/buttons/timeout.png");
this->buttons.trashCan = QPixmap(":/buttons/trashCan.png");

View file

@ -39,6 +39,8 @@ public:
QPixmap modModeDisabled2;
QPixmap modModeEnabled;
QPixmap modModeEnabled2;
QPixmap replyDark;
QPixmap replyThreadDark;
QPixmap search;
QPixmap timeout;
QPixmap trashCan;

View file

@ -278,6 +278,13 @@ bool Channel::canSendMessage() const
return false;
}
bool Channel::isWritable() const
{
using Type = Channel::Type;
auto type = this->getType();
return type != Type::TwitchMentions && type != Type::TwitchLive;
}
void Channel::sendMessage(const QString &message)
{
}

View file

@ -49,6 +49,9 @@ public:
// SIGNALS
pajlada::Signals::Signal<const QString &, const QString &, bool &>
sendMessageSignal;
pajlada::Signals::Signal<const QString &, const QString &, const QString &,
bool &>
sendReplySignal;
pajlada::Signals::Signal<MessagePtr &> messageRemovedFromStart;
pajlada::Signals::Signal<MessagePtr &, boost::optional<MessageFlags>>
messageAppended;
@ -84,6 +87,7 @@ public:
// CHANNEL INFO
virtual bool canSendMessage() const;
virtual bool isWritable() const; // whether split input will be usable
virtual void sendMessage(const QString &message);
virtual bool isMod() const;
virtual bool isBroadcaster() const;

View file

@ -51,6 +51,7 @@ using MessagePtr = std::shared_ptr<const Message>;
enum class CopyMode {
Everything,
EverythingButReplies,
OnlyTextAndEmotes,
};

View file

@ -26,6 +26,7 @@
#include "util/StreamLink.hpp"
#include "util/Twitch.hpp"
#include "widgets/Window.hpp"
#include "widgets/dialogs/ReplyThreadPopup.hpp"
#include "widgets/dialogs/UserInfoPopup.hpp"
#include "widgets/splits/Split.hpp"
@ -687,7 +688,8 @@ void CommandController::initialize(Settings &, Paths &paths)
auto *userPopup = new UserInfoPopup(
getSettings()->autoCloseUserPopup,
static_cast<QWidget *>(&(getApp()->windows->getMainWindow())));
static_cast<QWidget *>(&(getApp()->windows->getMainWindow())),
nullptr);
userPopup->setData(userName, channel);
userPopup->move(QCursor::pos());
userPopup->show();
@ -1114,6 +1116,56 @@ void CommandController::initialize(Settings &, Paths &paths)
return "";
});
this->registerCommand(
"/reply", [](const QStringList &words, ChannelPtr channel) {
auto *twitchChannel = dynamic_cast<TwitchChannel *>(channel.get());
if (twitchChannel == nullptr)
{
channel->addMessage(makeSystemMessage(
"The /reply command only works in Twitch channels"));
return "";
}
if (words.size() < 3)
{
channel->addMessage(
makeSystemMessage("Usage: /reply <username> <message>"));
return "";
}
QString username = words[1];
stripChannelName(username);
auto snapshot = twitchChannel->getMessageSnapshot();
for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it)
{
const auto &msg = *it;
if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0)
{
std::shared_ptr<MessageThread> thread;
// found most recent message by user
if (msg->replyThread == nullptr)
{
thread = std::make_shared<MessageThread>(msg);
twitchChannel->addReplyThread(thread);
}
else
{
thread = msg->replyThread;
}
QString reply = words.mid(2).join(" ");
twitchChannel->sendReply(reply, thread->rootId());
return "";
}
}
channel->addMessage(
makeSystemMessage("A message from that user wasn't found"));
return "";
});
#ifndef NDEBUG
this->registerCommand(
"/fakemsg",

View file

@ -82,6 +82,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel)
m->flags.has(MessageFlag::RedeemedChannelPointReward)},
{"flags.first_message", m->flags.has(MessageFlag::FirstMessage)},
{"flags.whisper", m->flags.has(MessageFlag::Whisper)},
{"flags.reply", m->flags.has(MessageFlag::ReplyMessage)},
{"message.content", m->messageText},
{"message.length", m->messageText.length()},

View file

@ -25,6 +25,7 @@ static const QMap<QString, QString> validIdentifiersMap = {
{"flags.reward_message", "channel point reward message?"},
{"flags.first_message", "first message?"},
{"flags.whisper", "whisper message?"},
{"flags.reply", "reply message?"},
{"message.content", "message text"},
{"message.length", "message length"}};

View file

@ -23,6 +23,8 @@ public:
JumpToChannel,
Reconnect,
CopyToClipboard,
ReplyToMessage,
ViewThread,
};
Link();

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;

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

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

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

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

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

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;

View file

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

View file

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

View file

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

View file

@ -9,7 +9,6 @@
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchHelpers.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "providers/twitch/TwitchMessageBuilder.hpp"
#include "singletons/Resources.hpp"
#include "singletons/Settings.hpp"
#include "singletons/WindowManager.hpp"
@ -19,6 +18,7 @@
#include <IrcMessage>
#include <memory>
#include <unordered_set>
namespace {
@ -242,6 +242,87 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message,
false, message->isAction());
}
std::vector<MessagePtr> IrcMessageHandler::parseMessageWithReply(
Channel *channel, Communi::IrcMessage *message,
const std::vector<MessagePtr> &otherLoaded)
{
std::vector<MessagePtr> builtMessages;
auto command = message->command();
if (command == "PRIVMSG")
{
auto privMsg = static_cast<Communi::IrcPrivateMessage *>(message);
auto tc = dynamic_cast<TwitchChannel *>(channel);
if (!tc)
{
return this->parsePrivMessage(channel, privMsg);
}
MessageParseArgs args;
TwitchMessageBuilder builder(channel, message, args, privMsg->content(),
privMsg->isAction());
this->populateReply(tc, message, otherLoaded, builder);
if (!builder.isIgnored())
{
builtMessages.emplace_back(builder.build());
builder.triggerHighlights();
}
}
else if (command == "USERNOTICE")
{
return this->parseUserNoticeMessage(channel, message);
}
else if (command == "NOTICE")
{
return this->parseNoticeMessage(
static_cast<Communi::IrcNoticeMessage *>(message));
}
return builtMessages;
}
void IrcMessageHandler::populateReply(
TwitchChannel *channel, Communi::IrcMessage *message,
const std::vector<MessagePtr> &otherLoaded, TwitchMessageBuilder &builder)
{
const auto &tags = message->tags();
if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end())
{
const QString replyID = it.value().toString();
auto threadIt = channel->threads_.find(replyID);
if (threadIt != channel->threads_.end())
{
const auto owned = threadIt->second.lock();
if (owned)
{
// Thread already exists (has a reply)
builder.setThread(owned);
return;
}
}
// Thread does not yet exist, find root reply and create thread.
// Linear search is justified by the infrequent use of replies
for (auto &otherMsg : otherLoaded)
{
if (otherMsg->id == replyID)
{
// Found root reply message
std::shared_ptr<MessageThread> newThread =
std::make_shared<MessageThread>(otherMsg);
builder.setThread(newThread);
// Store weak reference to thread in channel
channel->addReplyThread(newThread);
break;
}
}
}
}
void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
const QString &target,
const QString &content,
@ -276,7 +357,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
auto channel = dynamic_cast<TwitchChannel *>(chan.get());
const auto &tags = _message->tags();
if (const auto &it = tags.find("custom-reward-id"); it != tags.end())
if (const auto it = tags.find("custom-reward-id"); it != tags.end())
{
const auto rewardId = it.value().toString();
if (!channel->isChannelPointRewardKnown(rewardId))
@ -301,6 +382,31 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
TwitchMessageBuilder builder(chan.get(), _message, args, content, isAction);
if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end())
{
const QString replyID = it.value().toString();
auto threadIt = channel->threads_.find(replyID);
if (threadIt != channel->threads_.end() && !threadIt->second.expired())
{
// Thread already exists (has a reply)
builder.setThread(threadIt->second.lock());
}
else
{
// Thread does not yet exist, find root reply and create thread.
auto root = channel->findMessage(replyID);
if (root)
{
// Found root reply message
const auto newThread = std::make_shared<MessageThread>(root);
builder.setThread(newThread);
// Store weak reference to thread in channel
channel->addReplyThread(newThread);
}
}
}
if (isSub || !builder.isIgnored())
{
if (isSub)

View file

@ -3,6 +3,10 @@
#include <IrcMessage>
#include "common/Channel.hpp"
#include "messages/Message.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchMessageBuilder.hpp"
#include <vector>
namespace chatterino {
@ -20,6 +24,10 @@ public:
std::vector<MessagePtr> parseMessage(Channel *channel,
Communi::IrcMessage *message);
std::vector<MessagePtr> parseMessageWithReply(
Channel *channel, Communi::IrcMessage *message,
const std::vector<MessagePtr> &otherLoaded);
// parsePrivMessage arses a single IRC PRIVMSG into 0-1 Chatterino messages
std::vector<MessagePtr> parsePrivMessage(
Channel *channel, Communi::IrcPrivateMessage *message);
@ -59,6 +67,10 @@ private:
void addMessage(Communi::IrcMessage *message, const QString &target,
const QString &content, TwitchIrcServer &server,
bool isResub, bool isAction);
void populateReply(TwitchChannel *channel, Communi::IrcMessage *message,
const std::vector<MessagePtr> &otherLoaded,
TwitchMessageBuilder &builder);
};
} // namespace chatterino

View file

@ -1,6 +1,5 @@
#include "providers/twitch/TwitchChannel.hpp"
#include "Application.hpp"
#include "common/Common.hpp"
#include "common/Env.hpp"
#include "common/NetworkRequest.hpp"
@ -182,12 +181,34 @@ TwitchChannel::TwitchChannel(const QString &name)
this->refreshBTTVChannelEmotes(false);
});
this->messageRemovedFromStart.connect([this](MessagePtr &msg) {
if (msg->replyThread)
{
if (msg->replyThread->liveCount(msg) == 0)
{
this->threads_.erase(msg->replyThread->rootId());
}
}
});
// timers
QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [=] {
this->refreshChatters();
});
this->chattersListTimer_.start(5 * 60 * 1000);
QObject::connect(&this->threadClearTimer_, &QTimer::timeout, [=] {
// We periodically check for any dangling reply threads that missed
// being cleaned up on messageRemovedFromStart. This could occur if
// some other part of the program, like a user card, held a reference
// to the message.
//
// It seems difficult to actually replicate a situation where things
// are actually cleaned up, but I've verified that cleanups DO happen.
this->cleanUpReplyThreads();
});
this->threadClearTimer_.start(5 * 60 * 1000);
// debugging
#if 0
for (int i = 0; i < 1000; i++) {
@ -311,46 +332,34 @@ boost::optional<ChannelPointReward> TwitchChannel::channelPointReward(
return it->second;
}
void TwitchChannel::sendMessage(const QString &message)
void TwitchChannel::showLoginMessage()
{
const auto linkColor = MessageColor(MessageColor::Link);
const auto accountsLink = Link(Link::OpenAccountsPage, QString());
const auto currentUser = getApp()->accounts->twitch.getCurrent();
const auto expirationText =
QStringLiteral("You need to log in to send messages. You can link your "
"Twitch account");
const auto loginPromptText = QStringLiteral("in the settings.");
auto builder = MessageBuilder();
builder.message().flags.set(MessageFlag::System);
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
builder.emplace<TimestampElement>();
builder.emplace<TextElement>(expirationText, MessageElementFlag::Text,
MessageColor::System);
builder
.emplace<TextElement>(loginPromptText, MessageElementFlag::Text,
linkColor)
->setLink(accountsLink);
this->addMessage(builder.release());
}
QString TwitchChannel::prepareMessage(const QString &message) const
{
auto app = getApp();
if (!app->accounts->twitch.isLoggedIn())
{
if (message.isEmpty())
{
return;
}
const auto linkColor = MessageColor(MessageColor::Link);
const auto accountsLink = Link(Link::OpenAccountsPage, QString());
const auto currentUser = getApp()->accounts->twitch.getCurrent();
const auto expirationText =
QString("You need to log in to send messages. You can link your "
"Twitch account");
const auto loginPromptText = QString("in the settings.");
auto builder = MessageBuilder();
builder.message().flags.set(MessageFlag::System);
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
builder.emplace<TimestampElement>();
builder.emplace<TextElement>(expirationText, MessageElementFlag::Text,
MessageColor::System);
builder
.emplace<TextElement>(loginPromptText, MessageElementFlag::Text,
linkColor)
->setLink(accountsLink);
this->addMessage(builder.release());
return;
}
qCDebug(chatterinoTwitch)
<< "[TwitchChannel" << this->getName() << "] Send message:" << message;
// Do last message processing
QString parsedMessage = app->emotes->emojis.replaceShortCodes(message);
// This is to make sure that combined emoji go through properly, see
@ -361,7 +370,7 @@ void TwitchChannel::sendMessage(const QString &message)
if (parsedMessage.isEmpty())
{
return;
return "";
}
if (!this->hasHighRateLimit())
@ -395,6 +404,32 @@ void TwitchChannel::sendMessage(const QString &message)
}
}
return parsedMessage;
}
void TwitchChannel::sendMessage(const QString &message)
{
auto app = getApp();
if (!app->accounts->twitch.isLoggedIn())
{
if (!message.isEmpty())
{
this->showLoginMessage();
}
return;
}
qCDebug(chatterinoTwitch)
<< "[TwitchChannel" << this->getName() << "] Send message:" << message;
// Do last message processing
QString parsedMessage = this->prepareMessage(message);
if (parsedMessage.isEmpty())
{
return;
}
bool messageSent = false;
this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent);
@ -405,6 +440,40 @@ void TwitchChannel::sendMessage(const QString &message)
}
}
void TwitchChannel::sendReply(const QString &message, const QString &replyId)
{
auto app = getApp();
if (!app->accounts->twitch.isLoggedIn())
{
if (!message.isEmpty())
{
this->showLoginMessage();
}
return;
}
qCDebug(chatterinoTwitch) << "[TwitchChannel" << this->getName()
<< "] Send reply message:" << message;
// Do last message processing
QString parsedMessage = this->prepareMessage(message);
if (parsedMessage.isEmpty())
{
return;
}
bool messageSent = false;
this->sendReplySignal.invoke(this->getName(), parsedMessage, replyId,
messageSent);
if (messageSent)
{
qCDebug(chatterinoTwitch) << "sent";
this->lastSentMessage_ = parsedMessage;
}
}
bool TwitchChannel::isMod() const
{
return this->mod_;
@ -794,8 +863,10 @@ void TwitchChannel::loadRecentMessages()
}
}
for (auto builtMessage :
handler.parseMessage(shared.get(), message))
auto builtMessages = handler.parseMessageWithReply(
shared.get(), message, allBuiltMessages);
for (auto builtMessage : builtMessages)
{
builtMessage->flags.set(MessageFlag::RecentMessage);
allBuiltMessages.emplace_back(builtMessage);
@ -922,6 +993,39 @@ void TwitchChannel::fetchDisplayName()
[] {});
}
void TwitchChannel::addReplyThread(const std::shared_ptr<MessageThread> &thread)
{
this->threads_[thread->rootId()] = thread;
}
const std::unordered_map<QString, std::weak_ptr<MessageThread>>
&TwitchChannel::threads() const
{
return this->threads_;
}
void TwitchChannel::cleanUpReplyThreads()
{
for (auto it = this->threads_.begin(), last = this->threads_.end();
it != last;)
{
bool doErase = true;
if (auto thread = it->second.lock())
{
doErase = thread->liveCount() == 0;
}
if (doErase)
{
it = this->threads_.erase(it);
}
else
{
++it;
}
}
}
void TwitchChannel::refreshBadges()
{
auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" +

View file

@ -1,5 +1,6 @@
#pragma once
#include "Application.hpp"
#include "common/Aliases.hpp"
#include "common/Atomic.hpp"
#include "common/Channel.hpp"
@ -7,6 +8,7 @@
#include "common/ChatterSet.hpp"
#include "common/Outcome.hpp"
#include "common/UniqueAccess.hpp"
#include "messages/MessageThread.hpp"
#include "providers/twitch/ChannelPointReward.hpp"
#include "providers/twitch/TwitchEmotes.hpp"
#include "providers/twitch/api/Helix.hpp"
@ -74,12 +76,15 @@ public:
int slowMode = 0;
};
explicit TwitchChannel(const QString &channelName);
void initialize();
// Channel methods
virtual bool isEmpty() const override;
virtual bool canSendMessage() const override;
virtual void sendMessage(const QString &message) override;
virtual void sendReply(const QString &message, const QString &replyId);
virtual bool isMod() const override;
bool isVip() const;
bool isStaff() const;
@ -118,6 +123,17 @@ public:
// Cheers
boost::optional<CheerEmote> cheerEmote(const QString &string);
// Replies
/**
* Stores the given thread in this channel.
*
* Note: This method not take ownership of the MessageThread; this
* TwitchChannel instance will store a weak_ptr to the thread.
*/
void addReplyThread(const std::shared_ptr<MessageThread> &thread);
const std::unordered_map<QString, std::weak_ptr<MessageThread>> &threads()
const;
// Signals
pajlada::Signals::NoArgSignal roomIdChanged;
pajlada::Signals::NoArgSignal userStateChanged;
@ -138,9 +154,6 @@ private:
QString localizedName;
} nameOptions;
protected:
explicit TwitchChannel(const QString &channelName);
private:
// Methods
void refreshLiveStatus();
@ -151,6 +164,8 @@ private:
void refreshCheerEmotes();
void loadRecentMessages();
void fetchDisplayName();
void cleanUpReplyThreads();
void showLoginMessage();
void setLive(bool newLiveStatus);
void setMod(bool value);
@ -164,6 +179,8 @@ private:
const QString &getDisplayName() const override;
const QString &getLocalizedName() const override;
QString prepareMessage(const QString &message) const;
// Data
const QString subscriptionUrl_;
const QString channelUrl_;
@ -171,6 +188,7 @@ private:
int chatterCount_;
UniqueAccess<StreamStatus> streamStatus_;
UniqueAccess<RoomModes> roomModes_;
std::unordered_map<QString, std::weak_ptr<MessageThread>> threads_;
protected:
Atomic<std::shared_ptr<const EmoteMap>> bttvEmotes_;
@ -194,6 +212,7 @@ private:
QString lastSentMessage_;
QObject lifetimeGuard_;
QTimer chattersListTimer_;
QTimer threadClearTimer_;
QElapsedTimer titleRefreshedTimer_;
QElapsedTimer clipCreationTimer_;
bool isClipCreationInProgress{false};

View file

@ -121,6 +121,11 @@ std::shared_ptr<Channel> TwitchIrcServer::createChannel(
[this, channel = channel.get()](auto &chan, auto &msg, bool &sent) {
this->onMessageSendRequested(channel, msg, sent);
});
channel->sendReplySignal.connect(
[this, channel = channel.get()](auto &chan, auto &msg, auto &replyId,
bool &sent) {
this->onReplySendRequested(channel, msg, replyId, sent);
});
return std::shared_ptr<Channel>(channel);
}
@ -370,66 +375,90 @@ bool TwitchIrcServer::hasSeparateWriteConnection() const
// return getSettings()->twitchSeperateWriteConnection;
}
bool TwitchIrcServer::prepareToSend(TwitchChannel *channel)
{
std::lock_guard<std::mutex> guard(this->lastMessageMutex_);
auto &lastMessage = channel->hasHighRateLimit() ? this->lastMessageMod_
: this->lastMessagePleb_;
size_t maxMessageCount = channel->hasHighRateLimit() ? 99 : 19;
auto minMessageOffset = (channel->hasHighRateLimit() ? 100ms : 1100ms);
auto now = std::chrono::steady_clock::now();
// check if you are sending messages too fast
if (!lastMessage.empty() && lastMessage.back() + minMessageOffset > now)
{
if (this->lastErrorTimeSpeed_ + 30s < now)
{
auto errorMessage =
makeSystemMessage("You are sending messages too quickly.");
channel->addMessage(errorMessage);
this->lastErrorTimeSpeed_ = now;
}
return false;
}
// remove messages older than 30 seconds
while (!lastMessage.empty() && lastMessage.front() + 32s < now)
{
lastMessage.pop();
}
// check if you are sending too many messages
if (lastMessage.size() >= maxMessageCount)
{
if (this->lastErrorTimeAmount_ + 30s < now)
{
auto errorMessage =
makeSystemMessage("You are sending too many messages.");
channel->addMessage(errorMessage);
this->lastErrorTimeAmount_ = now;
}
return false;
}
lastMessage.push(now);
return true;
}
void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
const QString &message, bool &sent)
{
sent = false;
bool canSend = this->prepareToSend(channel);
if (!canSend)
{
std::lock_guard<std::mutex> guard(this->lastMessageMutex_);
// std::queue<std::chrono::steady_clock::time_point>
auto &lastMessage = channel->hasHighRateLimit()
? this->lastMessageMod_
: this->lastMessagePleb_;
size_t maxMessageCount = channel->hasHighRateLimit() ? 99 : 19;
auto minMessageOffset = (channel->hasHighRateLimit() ? 100ms : 1100ms);
auto now = std::chrono::steady_clock::now();
// check if you are sending messages too fast
if (!lastMessage.empty() && lastMessage.back() + minMessageOffset > now)
{
if (this->lastErrorTimeSpeed_ + 30s < now)
{
auto errorMessage =
makeSystemMessage("You are sending messages too quickly.");
channel->addMessage(errorMessage);
this->lastErrorTimeSpeed_ = now;
}
return;
}
// remove messages older than 30 seconds
while (!lastMessage.empty() && lastMessage.front() + 32s < now)
{
lastMessage.pop();
}
// check if you are sending too many messages
if (lastMessage.size() >= maxMessageCount)
{
if (this->lastErrorTimeAmount_ + 30s < now)
{
auto errorMessage =
makeSystemMessage("You are sending too many messages.");
channel->addMessage(errorMessage);
this->lastErrorTimeAmount_ = now;
}
return;
}
lastMessage.push(now);
return;
}
this->sendMessage(channel->getName(), message);
sent = true;
}
void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel,
const QString &message,
const QString &replyId, bool &sent)
{
sent = false;
bool canSend = this->prepareToSend(channel);
if (!canSend)
{
return;
}
this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" +
channel->getName() + " :" + message);
sent = true;
}
const BttvEmotes &TwitchIrcServer::getBttvEmotes() const
{
return this->bttv;

View file

@ -67,6 +67,10 @@ protected:
private:
void onMessageSendRequested(TwitchChannel *channel, const QString &message,
bool &sent);
void onReplySendRequested(TwitchChannel *channel, const QString &message,
const QString &replyId, bool &sent);
bool prepareToSend(TwitchChannel *channel);
std::mutex lastMessageMutex_;
std::queue<std::chrono::steady_clock::time_point> lastMessagePleb_;

View file

@ -48,6 +48,66 @@ const QSet<QString> zeroWidthEmotes{
namespace chatterino {
namespace {
QString stylizeUsername(const QString &username, const Message &message)
{
auto app = getApp();
const QString &localizedName = message.localizedName;
bool hasLocalizedName = !localizedName.isEmpty();
// The full string that will be rendered in the chat widget
QString usernameText;
switch (getSettings()->usernameDisplayMode.getValue())
{
case UsernameDisplayMode::Username: {
usernameText = username;
}
break;
case UsernameDisplayMode::LocalizedName: {
if (hasLocalizedName)
{
usernameText = localizedName;
}
else
{
usernameText = username;
}
}
break;
default:
case UsernameDisplayMode::UsernameAndLocalizedName: {
if (hasLocalizedName)
{
usernameText = username + "(" + localizedName + ")";
}
else
{
usernameText = username;
}
}
break;
}
auto nicknames = getCSettings().nicknames.readOnly();
for (const auto &nickname : *nicknames)
{
if (nickname.match(usernameText))
{
break;
}
}
return usernameText;
}
} // namespace
TwitchMessageBuilder::TwitchMessageBuilder(
Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage,
const MessageParseArgs &_args)
@ -138,6 +198,70 @@ MessagePtr TwitchMessageBuilder::build()
this->message().flags.set(MessageFlag::FirstMessage);
}
// reply threads
if (this->thread_)
{
// set references
this->message().replyThread = this->thread_;
this->thread_->addToThread(this->weakOf());
// enable reply flag
this->message().flags.set(MessageFlag::ReplyMessage);
const auto &threadRoot = this->thread_->root();
QString usernameText =
stylizeUsername(threadRoot->loginName, *threadRoot.get());
this->emplace<ReplyCurveElement>();
// construct reply elements
this->emplace<TextElement>(
"Replying to", MessageElementFlag::RepliedMessage,
MessageColor::System, FontStyle::ChatMediumSmall)
->setLink({Link::ViewThread, this->thread_->rootId()});
this->emplace<TextElement>(
"@" + usernameText + ":", MessageElementFlag::RepliedMessage,
threadRoot->usernameColor, FontStyle::ChatMediumSmall)
->setLink({Link::UserInfo, threadRoot->displayName});
this->emplace<SingleLineTextElement>(
threadRoot->messageText, MessageElementFlag::RepliedMessage,
this->textColor_, FontStyle::ChatMediumSmall)
->setLink({Link::ViewThread, this->thread_->rootId()});
}
else if (this->tags.find("reply-parent-msg-id") != this->tags.end())
{
// Message is a reply but we couldn't find the original message.
// Render the message using the additional reply tags
auto replyDisplayName = this->tags.find("reply-parent-display-name");
auto replyBody = this->tags.find("reply-parent-msg-body");
if (replyDisplayName != this->tags.end() &&
replyBody != this->tags.end())
{
auto name = replyDisplayName->toString();
auto body = parseTagString(replyBody->toString());
this->emplace<ReplyCurveElement>();
this->emplace<TextElement>(
"Replying to", MessageElementFlag::RepliedMessage,
MessageColor::System, FontStyle::ChatMediumSmall);
this->emplace<TextElement>(
"@" + name + ":", MessageElementFlag::RepliedMessage,
this->textColor_, FontStyle::ChatMediumSmall)
->setLink({Link::UserInfo, name});
this->emplace<SingleLineTextElement>(
body, MessageElementFlag::RepliedMessage, this->textColor_,
FontStyle::ChatMediumSmall);
}
}
// timestamp
this->message().serverReceivedTime = calculateMessageTime(this->ircMessage);
this->emplace<TimestampElement>(this->message().serverReceivedTime.time());
@ -217,6 +341,23 @@ MessagePtr TwitchMessageBuilder::build()
ColorProvider::instance().color(ColorType::Whisper);
}
if (this->thread_)
{
auto &img = getResources().buttons.replyThreadDark;
this->emplace<CircularImageElement>(Image::fromPixmap(img, 0.15), 2,
Qt::gray,
MessageElementFlag::ReplyButton)
->setLink({Link::ViewThread, this->thread_->rootId()});
}
else
{
auto &img = getResources().buttons.replyDark;
this->emplace<CircularImageElement>(Image::fromPixmap(img, 0.15), 2,
Qt::gray,
MessageElementFlag::ReplyButton)
->setLink({Link::ReplyToMessage, this->message().id});
}
return this->release();
}
@ -559,53 +700,7 @@ void TwitchMessageBuilder::appendUsername()
}
}
bool hasLocalizedName = !localizedName.isEmpty();
// The full string that will be rendered in the chat widget
QString usernameText;
switch (getSettings()->usernameDisplayMode.getValue())
{
case UsernameDisplayMode::Username: {
usernameText = username;
}
break;
case UsernameDisplayMode::LocalizedName: {
if (hasLocalizedName)
{
usernameText = localizedName;
}
else
{
usernameText = username;
}
}
break;
default:
case UsernameDisplayMode::UsernameAndLocalizedName: {
if (hasLocalizedName)
{
usernameText = username + "(" + localizedName + ")";
}
else
{
usernameText = username;
}
}
break;
}
auto nicknames = getCSettings().nicknames.readOnly();
for (const auto &nickname : *nicknames)
{
if (nickname.match(usernameText))
{
break;
}
}
QString usernameText = stylizeUsername(username, this->message());
if (this->args.isSentWhisper)
{
@ -1479,4 +1574,9 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix,
}
}
void TwitchMessageBuilder::setThread(std::shared_ptr<MessageThread> thread)
{
this->thread_ = std::move(thread);
}
} // namespace chatterino

View file

@ -2,6 +2,7 @@
#include "common/Aliases.hpp"
#include "common/Outcome.hpp"
#include "messages/MessageThread.hpp"
#include "messages/SharedMessageBuilder.hpp"
#include "providers/twitch/ChannelPointReward.hpp"
#include "providers/twitch/PubSubActions.hpp"
@ -45,6 +46,8 @@ public:
void triggerHighlights() override;
MessagePtr build() override;
void setThread(std::shared_ptr<MessageThread> thread);
static void appendChannelPointRewardMessage(
const ChannelPointReward &reward, MessageBuilder *builder, bool isMod,
bool isBroadcaster);
@ -105,6 +108,7 @@ private:
int bitsLeft;
bool bitsStacked = false;
bool historicalMessage_ = false;
std::shared_ptr<MessageThread> thread_;
QString userId_;
bool senderIsBroadcaster{};

View file

@ -103,6 +103,7 @@ public:
// BoolSetting collapseLongMessages =
// {"/appearance/messages/collapseLongMessages", false};
BoolSetting showReplyButton = {"/appearance/showReplyButton", false};
IntSetting collpseMessagesMinLines = {
"/appearance/messages/collapseMessagesMinLines", 0};
BoolSetting alternateMessages = {
@ -158,6 +159,8 @@ public:
FloatSetting mouseScrollMultiplier = {"/behaviour/mouseScrollMultiplier",
1.0};
BoolSetting autoCloseUserPopup = {"/behaviour/autoCloseUserPopup", true};
BoolSetting autoCloseThreadPopup = {"/behaviour/autoCloseThreadPopup",
false};
// BoolSetting twitchSeperateWriteConnection =
// {"/behaviour/twitchSeperateWriteConnection", false};

View file

@ -113,6 +113,7 @@ WindowManager::WindowManager()
this->wordFlagsListener_.addSetting(settings->enableEmoteImages);
this->wordFlagsListener_.addSetting(settings->boldUsernames);
this->wordFlagsListener_.addSetting(settings->lowercaseDomains);
this->wordFlagsListener_.addSetting(settings->showReplyButton);
this->wordFlagsListener_.setCB([this] {
this->updateWordTypeMask();
});
@ -182,6 +183,10 @@ void WindowManager::updateWordTypeMask()
// username
flags.set(MEF::Username);
// replies
flags.set(MEF::RepliedMessage);
flags.set(settings->showReplyButton ? MEF::ReplyButton : MEF::None);
// misc
flags.set(MEF::AlwaysShow);
flags.set(MEF::Collapsed);

View file

@ -0,0 +1,90 @@
#include "DraggablePopup.hpp"
#include <QMouseEvent>
#include <chrono>
namespace chatterino {
namespace {
#ifdef Q_OS_LINUX
FlagsEnum<BaseWindow::Flags> popupFlags{BaseWindow::Dialog,
BaseWindow::EnableCustomFrame};
FlagsEnum<BaseWindow::Flags> popupFlagsCloseAutomatically{
BaseWindow::EnableCustomFrame};
#else
FlagsEnum<BaseWindow::Flags> popupFlags{BaseWindow::EnableCustomFrame};
FlagsEnum<BaseWindow::Flags> popupFlagsCloseAutomatically{
BaseWindow::EnableCustomFrame, BaseWindow::Frameless,
BaseWindow::FramelessDraggable};
#endif
} // namespace
DraggablePopup::DraggablePopup(bool closeAutomatically, QWidget *parent)
: BaseWindow(closeAutomatically ? popupFlagsCloseAutomatically : popupFlags,
parent)
, lifetimeHack_(std::make_shared<bool>(false))
, dragTimer_(this)
{
if (closeAutomatically)
{
this->setActionOnFocusLoss(BaseWindow::Delete);
}
else
{
this->setAttribute(Qt::WA_DeleteOnClose);
}
// Update the window position according to this->requestedDragPos_ on every trigger
this->dragTimer_.callOnTimeout(
[this, hack = std::weak_ptr<bool>(this->lifetimeHack_)] {
if (!hack.lock())
{
// Ensure this timer is never called after the object has been destroyed
return;
}
if (!this->isMoving_)
{
return;
}
this->move(this->requestedDragPos_);
});
}
void DraggablePopup::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::MouseButton::LeftButton)
{
this->dragTimer_.start(std::chrono::milliseconds(17));
this->startPosDrag_ = event->pos();
this->movingRelativePos = event->localPos();
}
}
void DraggablePopup::mouseReleaseEvent(QMouseEvent *event)
{
this->dragTimer_.stop();
this->isMoving_ = false;
}
void DraggablePopup::mouseMoveEvent(QMouseEvent *event)
{
// Drag the window by the amount changed from inital position
// Note that we provide a few *units* of deadzone so people don't
// start dragging the window if they are slow at clicking.
auto movePos = event->pos() - this->startPosDrag_;
if (this->isMoving_ || movePos.manhattanLength() > 10.0)
{
this->requestedDragPos_ =
(event->screenPos() - this->movingRelativePos).toPoint();
this->isMoving_ = true;
}
}
} // namespace chatterino

View file

@ -0,0 +1,47 @@
#pragma once
#include "widgets/BaseWindow.hpp"
#include <QPoint>
#include <QTimer>
#include <memory>
namespace chatterino {
class DraggablePopup : public BaseWindow
{
Q_OBJECT
public:
/// DraggablePopup implements the automatic dragging behavior when clicking
/// anywhere in the window (that doesn't have some other widget).
///
/// If closeAutomatically is set, the window will close when losing focus,
/// and the window will be frameless.
DraggablePopup(bool closeAutomatically, QWidget *parent);
protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
// lifetimeHack_ is used to check that the window hasn't been destroyed yet
std::shared_ptr<bool> lifetimeHack_;
private:
// isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_)
bool isMoving_ = false;
// startPosDrag_ is the coordinates where the user originally pressed the mouse button down to start dragging
QPoint startPosDrag_;
// requestDragPos_ is the final screen coordinates where the widget should be moved to.
// Takes the relative position of where the user originally clicked the widget into account
QPoint requestedDragPos_;
// dragTimer_ is called ~60 times per second once the user has initiated dragging
QTimer dragTimer_;
};
} // namespace chatterino

View file

@ -0,0 +1,193 @@
#include "ReplyThreadPopup.hpp"
#include "Application.hpp"
#include "common/Channel.hpp"
#include "common/QLogging.hpp"
#include "controllers/hotkeys/HotkeyController.hpp"
#include "messages/MessageThread.hpp"
#include "util/LayoutCreator.hpp"
#include "widgets/Scrollbar.hpp"
#include "widgets/helper/ChannelView.hpp"
#include "widgets/helper/ResizingTextEdit.hpp"
#include "widgets/splits/Split.hpp"
#include "widgets/splits/SplitInput.hpp"
const QString TEXT_TITLE("Reply Thread - @%1 in #%2");
namespace chatterino {
ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent,
Split *split)
: DraggablePopup(closeAutomatically, parent)
, split_(split)
{
this->setWindowTitle(QStringLiteral("Reply Thread"));
this->setStayInScreenRect(true);
HotkeyController::HotkeyMap actions{
{"delete",
[this](std::vector<QString>) -> QString {
this->deleteLater();
return "";
}},
{"scrollPage",
[this](std::vector<QString> arguments) -> QString {
if (arguments.empty())
{
qCWarning(chatterinoHotkeys)
<< "scrollPage hotkey called without arguments!";
return "scrollPage hotkey called without arguments!";
}
auto direction = arguments.at(0);
auto &scrollbar = this->ui_.threadView->getScrollBar();
if (direction == "up")
{
scrollbar.offset(-scrollbar.getLargeChange());
}
else if (direction == "down")
{
scrollbar.offset(scrollbar.getLargeChange());
}
else
{
qCWarning(chatterinoHotkeys) << "Unknown scroll direction";
}
return "";
}},
// these actions make no sense in the context of a reply thread, so they aren't implemented
{"execModeratorAction", nullptr},
{"reject", nullptr},
{"accept", nullptr},
{"openTab", nullptr},
{"search", nullptr},
};
this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory(
HotkeyCategory::PopupWindow, actions, this);
auto layout = LayoutCreator<QWidget>(this->getLayoutContainer())
.setLayoutType<QVBoxLayout>();
// initialize UI
this->ui_.threadView =
new ChannelView(this, this->split_, ChannelView::Context::ReplyThread);
this->ui_.threadView->setMinimumSize(400, 100);
this->ui_.threadView->setSizePolicy(QSizePolicy::Expanding,
QSizePolicy::Expanding);
this->ui_.threadView->mouseDown.connect([this](QMouseEvent *) {
this->giveFocus(Qt::MouseFocusReason);
});
// Create SplitInput with inline replying disabled
this->ui_.replyInput = new SplitInput(this, this->split_, false);
this->bSignals_.emplace_back(
getApp()->accounts->twitch.currentUserChanged.connect([this] {
this->updateInputUI();
}));
layout->setSpacing(0);
// provide draggable margin if frameless
layout->setMargin(closeAutomatically ? 15 : 1);
layout->addWidget(this->ui_.threadView, 1);
layout->addWidget(this->ui_.replyInput);
}
void ReplyThreadPopup::setThread(std::shared_ptr<MessageThread> thread)
{
this->thread_ = std::move(thread);
this->ui_.replyInput->setReply(this->thread_);
this->addMessagesFromThread();
this->updateInputUI();
}
void ReplyThreadPopup::addMessagesFromThread()
{
this->ui_.threadView->clearMessages();
if (!this->thread_)
{
return;
}
const auto &sourceChannel = this->split_->getChannel();
this->setWindowTitle(TEXT_TITLE.arg(this->thread_->root()->loginName,
sourceChannel->getName()));
ChannelPtr virtualChannel;
if (sourceChannel->isTwitchChannel())
{
virtualChannel =
std::make_shared<TwitchChannel>(sourceChannel->getName());
}
else
{
virtualChannel = std::make_shared<Channel>(sourceChannel->getName(),
Channel::Type::None);
}
this->ui_.threadView->setChannel(virtualChannel);
this->ui_.threadView->setSourceChannel(sourceChannel);
virtualChannel->addMessage(this->thread_->root());
for (const auto &msgRef : this->thread_->replies())
{
if (auto msg = msgRef.lock())
{
virtualChannel->addMessage(msg);
}
}
this->messageConnection_ =
std::make_unique<pajlada::Signals::ScopedConnection>(
sourceChannel->messageAppended.connect(
[this, virtualChannel](MessagePtr &message, auto) {
if (message->replyThread == this->thread_)
{
// same reply thread, add message
virtualChannel->addMessage(message);
}
}));
}
void ReplyThreadPopup::updateInputUI()
{
auto channel = this->split_->getChannel();
// Bail out if not a twitch channel.
// Special twitch channels will hide their reply input box.
if (!channel || !channel->isTwitchChannel())
{
return;
}
this->ui_.replyInput->setVisible(channel->isWritable());
auto user = getApp()->accounts->twitch.getCurrent();
QString placeholderText;
if (user->isAnon())
{
placeholderText = QStringLiteral("Log in to send messages...");
}
else
{
placeholderText =
QStringLiteral("Reply as %1...")
.arg(getApp()->accounts->twitch.getCurrent()->getUserName());
}
this->ui_.replyInput->setPlaceholderText(placeholderText);
}
void ReplyThreadPopup::giveFocus(Qt::FocusReason reason)
{
this->ui_.replyInput->giveFocus(reason);
}
void ReplyThreadPopup::focusInEvent(QFocusEvent *event)
{
this->giveFocus(event->reason());
}
} // namespace chatterino

View file

@ -0,0 +1,48 @@
#pragma once
#include "ForwardDecl.hpp"
#include "widgets/DraggablePopup.hpp"
#include <boost/signals2.hpp>
#include <pajlada/signals/scoped-connection.hpp>
#include <pajlada/signals/signal.hpp>
namespace chatterino {
class MessageThread;
class Split;
class SplitInput;
class ReplyThreadPopup final : public DraggablePopup
{
Q_OBJECT
public:
ReplyThreadPopup(bool closeAutomatically, QWidget *parent, Split *split);
void setThread(std::shared_ptr<MessageThread> thread);
void giveFocus(Qt::FocusReason reason);
protected:
void focusInEvent(QFocusEvent *event) override;
private:
void addMessagesFromThread();
void updateInputUI();
// The message reply thread
std::shared_ptr<MessageThread> thread_;
// The channel that the reply thread is in
ChannelPtr channel_;
Split *split_;
struct {
ChannelView *threadView = nullptr;
SplitInput *replyInput = nullptr;
} ui_;
std::unique_ptr<pajlada::Signals::ScopedConnection> messageConnection_;
std::vector<boost::signals2::scoped_connection> bSignals_;
};
} // namespace chatterino

View file

@ -91,8 +91,16 @@ namespace {
LimitedQueueSnapshot<MessagePtr> snapshot =
channel->getMessageSnapshot();
ChannelPtr channelPtr(
new Channel(channel->getName(), Channel::Type::None));
ChannelPtr channelPtr;
if (channel->isTwitchChannel())
{
channelPtr = std::make_shared<TwitchChannel>(channel->getName());
}
else
{
channelPtr = std::make_shared<Channel>(channel->getName(),
Channel::Type::None);
}
for (size_t i = 0; i < snapshot.size(); i++)
{
@ -118,33 +126,14 @@ namespace {
} // namespace
#ifdef Q_OS_LINUX
FlagsEnum<BaseWindow::Flags> userInfoPopupFlags{BaseWindow::Dialog,
BaseWindow::EnableCustomFrame};
FlagsEnum<BaseWindow::Flags> userInfoPopupFlagsCloseAutomatically{
BaseWindow::EnableCustomFrame};
#else
FlagsEnum<BaseWindow::Flags> userInfoPopupFlags{BaseWindow::EnableCustomFrame};
FlagsEnum<BaseWindow::Flags> userInfoPopupFlagsCloseAutomatically{
BaseWindow::EnableCustomFrame, BaseWindow::Frameless,
BaseWindow::FramelessDraggable};
#endif
UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
: BaseWindow(closeAutomatically ? userInfoPopupFlagsCloseAutomatically
: userInfoPopupFlags,
parent)
, hack_(new bool)
, dragTimer_(this)
UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent,
Split *split)
: DraggablePopup(closeAutomatically, parent)
, split_(split)
{
this->setWindowTitle("Usercard");
this->setStayInScreenRect(true);
if (closeAutomatically)
this->setActionOnFocusLoss(BaseWindow::Delete);
else
this->setAttribute(Qt::WA_DeleteOnClose);
HotkeyController::HotkeyMap actions{
{"delete",
[this](std::vector<QString>) -> QString {
@ -498,7 +487,8 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
this->ui_.noMessagesLabel = new Label("No recent messages");
this->ui_.noMessagesLabel->setVisible(false);
this->ui_.latestMessages = new ChannelView(this);
this->ui_.latestMessages =
new ChannelView(this, this->split_, ChannelView::Context::UserCard);
this->ui_.latestMessages->setMinimumSize(400, 275);
this->ui_.latestMessages->setSizePolicy(QSizePolicy::Expanding,
QSizePolicy::Expanding);
@ -510,21 +500,6 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
this->installEvents();
this->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Policy::Ignored);
this->dragTimer_.callOnTimeout(
[this, hack = std::weak_ptr<bool>(this->hack_)] {
if (!hack.lock())
{
// Ensure this timer is never called after the object has been destroyed
return;
}
if (!this->isMoving_)
{
return;
}
this->move(this->requestedDragPos_);
});
}
void UserInfoPopup::themeChangedEvent()
@ -550,36 +525,6 @@ void UserInfoPopup::scaleChangedEvent(float /*scale*/)
});
}
void UserInfoPopup::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::MouseButton::LeftButton)
{
this->dragTimer_.start(std::chrono::milliseconds(17));
this->startPosDrag_ = event->pos();
this->movingRelativePos = event->localPos();
}
}
void UserInfoPopup::mouseReleaseEvent(QMouseEvent *event)
{
this->dragTimer_.stop();
this->isMoving_ = false;
}
void UserInfoPopup::mouseMoveEvent(QMouseEvent *event)
{
// Drag the window by the amount changed from inital position
// Note that we provide a few *units* of deadzone so people don't
// start dragging the window if they are slow at clicking.
auto movePos = event->pos() - this->startPosDrag_;
if (this->isMoving_ || movePos.manhattanLength() > 10.0)
{
this->requestedDragPos_ =
(event->screenPos() - this->movingRelativePos).toPoint();
this->isMoving_ = true;
}
}
void UserInfoPopup::installEvents()
{
std::shared_ptr<bool> ignoreNext = std::make_shared<bool>(false);
@ -765,7 +710,7 @@ void UserInfoPopup::updateLatestMessages()
void UserInfoPopup::updateUserData()
{
std::weak_ptr<bool> hack = this->hack_;
std::weak_ptr<bool> hack = this->lifetimeHack_;
auto currentUser = getApp()->accounts->twitch.getCurrent();
const auto onUserFetchFailed = [this, hack] {

View file

@ -1,6 +1,6 @@
#pragma once
#include "widgets/BaseWindow.hpp"
#include "widgets/DraggablePopup.hpp"
#include "widgets/helper/ChannelView.hpp"
#include <pajlada/signals/scoped-connection.hpp>
@ -16,12 +16,13 @@ class Channel;
using ChannelPtr = std::shared_ptr<Channel>;
class Label;
class UserInfoPopup final : public BaseWindow
class UserInfoPopup final : public DraggablePopup
{
Q_OBJECT
public:
UserInfoPopup(bool closeAutomatically, QWidget *parent);
UserInfoPopup(bool closeAutomatically, QWidget *parent,
Split *split = nullptr);
void setData(const QString &name, const ChannelPtr &channel);
void setData(const QString &name, const ChannelPtr &contextChannel,
@ -30,9 +31,6 @@ public:
protected:
virtual void themeChangedEvent() override;
virtual void scaleChangedEvent(float scale) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
private:
void installEvents();
@ -43,6 +41,8 @@ private:
bool isMod_;
bool isBroadcaster_;
Split *split_;
QString userName_;
QString userId_;
QString avatarUrl_;
@ -51,25 +51,10 @@ private:
// The channel the messages are rendered from (e.g. #forsen). Can be a special channel, but will try to not be where possible.
ChannelPtr underlyingChannel_;
// isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_)
bool isMoving_ = false;
// startPosDrag_ is the coordinates where the user originally pressed the mouse button down to start dragging
QPoint startPosDrag_;
// requestDragPos_ is the final screen coordinates where the widget should be moved to.
// Takes the relative position of where the user originally clicked the widget into account
QPoint requestedDragPos_;
// dragTimer_ is called ~60 times per second once the user has initiated dragging
QTimer dragTimer_;
pajlada::Signals::NoArgSignal userStateChanged_;
std::unique_ptr<pajlada::Signals::ScopedConnection> refreshConnection_;
std::shared_ptr<bool> hack_;
struct {
Button *avatarButton = nullptr;
Button *localizedNameCopyButton = nullptr;

View file

@ -44,6 +44,7 @@
#include "widgets/Scrollbar.hpp"
#include "widgets/TooltipWidget.hpp"
#include "widgets/Window.hpp"
#include "widgets/dialogs/ReplyThreadPopup.hpp"
#include "widgets/dialogs/SettingsDialog.hpp"
#include "widgets/dialogs/UserInfoPopup.hpp"
#include "widgets/helper/EffectLabel.hpp"
@ -119,9 +120,11 @@ namespace {
}
} // namespace
ChannelView::ChannelView(BaseWidget *parent)
ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context)
: BaseWidget(parent)
, scrollBar_(new Scrollbar(this))
, split_(split)
, context_(context)
{
this->setMouseTracking(true);
@ -160,7 +163,7 @@ ChannelView::ChannelView(BaseWidget *parent)
// and tabbing to it from another widget. I don't currently know
// of any place where you can, or where it would make sense,
// to tab to a ChannelVieChannelView
this->setFocusPolicy(Qt::FocusPolicy::StrongFocus);
this->setFocusPolicy(Qt::FocusPolicy::ClickFocus);
}
void ChannelView::initializeLayout()
@ -1019,6 +1022,17 @@ MessageElementFlags ChannelView::getFlags() const
if (this->sourceChannel_ == app->twitch->mentionsChannel)
flags.set(MessageElementFlag::ChannelName);
if (this->context_ == Context::ReplyThread)
{
// Don't show inline replies within the ReplyThreadPopup
flags.unset(MessageElementFlag::RepliedMessage);
}
if (!this->canReplyToMessages())
{
flags.unset(MessageElementFlag::ReplyButton);
}
return flags;
}
@ -1983,10 +1997,27 @@ void ChannelView::addMessageContextMenuItems(
menu.addAction("Copy full message", [layout] {
QString copyString;
layout->addSelectionText(copyString);
layout->addSelectionText(copyString, 0, INT_MAX,
CopyMode::EverythingButReplies);
crossPlatformCopy(copyString);
});
// Only display reply option where it makes sense
if (this->canReplyToMessages() && layout->isReplyable())
{
const auto &messagePtr = layout->getMessagePtr();
menu.addAction("Reply to message", [this, &messagePtr] {
this->setInputReply(messagePtr);
});
if (messagePtr->replyThread != nullptr)
{
menu.addAction("View thread", [this, &messagePtr] {
this->showReplyThreadPopup(messagePtr);
});
}
}
}
void ChannelView::addTwitchLinkContextMenuItems(
@ -2224,8 +2255,8 @@ void ChannelView::showUserInfoPopup(const QString &userName,
{
auto *userCardParent =
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()));
auto *userPopup =
new UserInfoPopup(getSettings()->autoCloseUserPopup, userCardParent);
auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup,
userCardParent, this->split_);
auto contextChannel =
getApp()->twitch->getChannelOrEmpty(alternativePopoutChannel);
@ -2351,6 +2382,14 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link,
this->underlyingChannel_.get()->reconnect();
}
break;
case Link::ReplyToMessage: {
this->setInputReply(layout->getMessagePtr());
}
break;
case Link::ViewThread: {
this->showReplyThreadPopup(layout->getMessagePtr());
}
break;
default:;
}
@ -2473,4 +2512,106 @@ void ChannelView::scrollUpdateRequested()
this->scrollBar_->offset(multiplier * offset);
}
void ChannelView::setInputReply(const MessagePtr &message)
{
if (message == nullptr)
{
return;
}
std::shared_ptr<MessageThread> thread;
if (message->replyThread == nullptr)
{
auto getThread = [&](TwitchChannel *tc) {
auto threadIt = tc->threads().find(message->id);
if (threadIt != tc->threads().end() && !threadIt->second.expired())
{
return threadIt->second.lock();
}
else
{
auto thread = std::make_shared<MessageThread>(message);
tc->addReplyThread(thread);
return thread;
}
};
if (auto tc =
dynamic_cast<TwitchChannel *>(this->underlyingChannel_.get()))
{
thread = getThread(tc);
}
else if (auto tc = dynamic_cast<TwitchChannel *>(this->channel_.get()))
{
thread = getThread(tc);
}
else
{
qCWarning(chatterinoCommon) << "Failed to create new reply thread";
// Unable to create new reply thread.
// TODO(dnsge): Should probably notify user?
return;
}
}
else
{
thread = message->replyThread;
}
this->split_->setInputReply(thread);
}
void ChannelView::showReplyThreadPopup(const MessagePtr &message)
{
if (message == nullptr || message->replyThread == nullptr)
{
return;
}
auto popupParent =
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()));
auto popup = new ReplyThreadPopup(getSettings()->autoCloseThreadPopup,
popupParent, this->split_);
popup->setThread(message->replyThread);
QPoint offset(int(150 * this->scale()), int(70 * this->scale()));
popup->move(QCursor::pos() - offset);
popup->show();
popup->giveFocus(Qt::MouseFocusReason);
}
ChannelView::Context ChannelView::getContext() const
{
return this->context_;
}
bool ChannelView::canReplyToMessages() const
{
if (this->context_ == ChannelView::Context::ReplyThread ||
this->context_ == ChannelView::Context::Search)
{
return false;
}
if (this->channel_ == nullptr)
{
return false;
}
if (!this->channel_->isTwitchChannel())
{
return false;
}
if (this->channel_->getType() == Channel::Type::TwitchWhispers ||
this->channel_->getType() == Channel::Type::TwitchLive)
{
return false;
}
return true;
}
} // namespace chatterino

View file

@ -39,6 +39,7 @@ class Scrollbar;
class EffectLabel;
struct Link;
class MessageLayoutElement;
class Split;
enum class PauseReason {
Mouse,
@ -61,7 +62,15 @@ class ChannelView final : public BaseWidget
Q_OBJECT
public:
explicit ChannelView(BaseWidget *parent = nullptr);
enum class Context {
None,
UserCard,
ReplyThread,
Search,
};
explicit ChannelView(BaseWidget *parent = nullptr, Split *split = nullptr,
Context context = Context::None);
void queueUpdate();
Scrollbar &getScrollBar();
@ -99,6 +108,8 @@ public:
void clearMessages();
Context getContext() const;
/**
* @brief Creates and shows a UserInfoPopup dialog
*
@ -196,6 +207,10 @@ private:
void enableScrolling(const QPointF &scrollStart);
void disableScrolling();
void setInputReply(const MessagePtr &message);
void showReplyThreadPopup(const MessagePtr &message);
bool canReplyToMessages() const;
QTimer *layoutCooldown_;
bool layoutQueued_;
@ -221,6 +236,7 @@ private:
ChannelPtr channel_ = nullptr;
ChannelPtr underlyingChannel_ = nullptr;
ChannelPtr sourceChannel_ = nullptr;
Split *split_ = nullptr;
Scrollbar *scrollBar_;
EffectLabel *goToBottom_;
@ -264,6 +280,8 @@ private:
Selection selection_;
bool selecting_ = false;
const Context context_;
LimitedQueue<MessageLayoutPtr> messages_;
pajlada::Signals::SignalHolder signalHolder_;

View file

@ -49,8 +49,9 @@ ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName,
return channel;
}
SearchPopup::SearchPopup(QWidget *parent)
SearchPopup::SearchPopup(QWidget *parent, Split *split)
: BasePopup({}, parent)
, split_(split)
{
this->initLayout();
this->resize(400, 600);
@ -238,7 +239,8 @@ void SearchPopup::initLayout()
// CHANNELVIEW
{
this->channelView_ = new ChannelView(this);
this->channelView_ = new ChannelView(this, this->split_,
ChannelView::Context::Search);
layout1->addWidget(this->channelView_);
}

View file

@ -5,6 +5,7 @@
#include "messages/LimitedQueueSnapshot.hpp"
#include "messages/search/MessagePredicate.hpp"
#include "widgets/BasePopup.hpp"
#include "widgets/splits/Split.hpp"
#include <memory>
@ -15,7 +16,7 @@ namespace chatterino {
class SearchPopup : public BasePopup
{
public:
SearchPopup(QWidget *parent);
SearchPopup(QWidget *parent, Split *split = nullptr);
virtual void addChannel(ChannelView &channel);
@ -58,6 +59,7 @@ private:
QLineEdit *searchInput_{};
ChannelView *channelView_{};
QString channelName_{};
Split *split_ = nullptr;
QList<std::reference_wrapper<ChannelView>> searchChannels_;
};

View file

@ -185,6 +185,7 @@ void GeneralPage::initLayout(GeneralPageView &layout)
tabDirectionDropdown->setMinimumWidth(
tabDirectionDropdown->minimumSizeHint().width());
layout.addCheckbox("Show message reply button", s.showReplyButton);
layout.addCheckbox("Show tab close button", s.showTabCloseButton);
layout.addCheckbox("Always on top", s.windowTopMost);
#ifdef USEWINSDK
@ -641,6 +642,9 @@ void GeneralPage::initLayout(GeneralPageView &layout)
layout.addCheckbox("Show parted users (< 1000 chatters)", s.showParts);
layout.addCheckbox("Automatically close user popup when it loses focus",
s.autoCloseUserPopup);
layout.addCheckbox(
"Automatically close reply thread popup when it loses focus",
s.autoCloseThreadPopup);
layout.addCheckbox("Lowercase domains (anti-phishing)", s.lowercaseDomains);
layout.addCheckbox("Bold @usernames", s.boldUsernames);
layout.addCheckbox("Color @usernames", s.colorUsernames);

View file

@ -9,6 +9,7 @@
#include "controllers/commands/CommandController.hpp"
#include "controllers/hotkeys/HotkeyController.hpp"
#include "controllers/notifications/NotificationController.hpp"
#include "messages/MessageThread.hpp"
#include "providers/twitch/EmoteValue.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
@ -85,7 +86,7 @@ Split::Split(QWidget *parent)
, channel_(Channel::getEmpty())
, vbox_(new QVBoxLayout(this))
, header_(new SplitHeader(this))
, view_(new ChannelView(this))
, view_(new ChannelView(this, this))
, input_(new SplitInput(this))
, overlay_(new SplitOverlay(this))
{
@ -1164,7 +1165,7 @@ const QList<QUuid> Split::getFilters() const
void Split::showSearch(bool singleChannel)
{
auto *popup = new SearchPopup(this);
auto *popup = new SearchPopup(this, this);
popup->setAttribute(Qt::WA_DeleteOnClose);
if (singleChannel)
@ -1269,6 +1270,11 @@ void Split::drag()
}
}
void Split::setInputReply(const std::shared_ptr<MessageThread> &reply)
{
this->input_->setReply(reply);
}
} // namespace chatterino
QDebug operator<<(QDebug dbg, const chatterino::Split &split)

View file

@ -15,6 +15,7 @@
namespace chatterino {
class ChannelView;
class MessageThread;
class SplitHeader;
class SplitInput;
class SplitContainer;
@ -74,6 +75,8 @@ public:
void setContainer(SplitContainer *container);
void setInputReply(const std::shared_ptr<MessageThread> &reply);
static pajlada::Signals::Signal<Qt::KeyboardModifiers>
modifierStatusChanged;
static Qt::KeyboardModifiers modifierStatus;

View file

@ -23,15 +23,24 @@
#include "widgets/splits/SplitContainer.hpp"
#include "widgets/splits/SplitInput.hpp"
#include <functional>
#include <QCompleter>
#include <QPainter>
namespace chatterino {
const int TWITCH_MESSAGE_LIMIT = 500;
SplitInput::SplitInput(Split *_chatWidget)
: BaseWidget(_chatWidget)
SplitInput::SplitInput(Split *_chatWidget, bool enableInlineReplying)
: SplitInput(_chatWidget, _chatWidget, enableInlineReplying)
{
}
SplitInput::SplitInput(QWidget *parent, Split *_chatWidget,
bool enableInlineReplying)
: BaseWidget(parent)
, split_(_chatWidget)
, enableInlineReplying_(enableInlineReplying)
{
this->installEventFilter(this);
this->initLayout();
@ -66,17 +75,43 @@ void SplitInput::initLayout()
LayoutCreator<SplitInput> layoutCreator(this);
auto layout =
layoutCreator.setLayoutType<QHBoxLayout>().withoutMargin().assign(
&this->ui_.hbox);
layoutCreator.setLayoutType<QVBoxLayout>().withoutMargin().assign(
&this->ui_.vbox);
// reply label stuff
auto replyWrapper =
layout.emplace<QWidget>().assign(&this->ui_.replyWrapper);
this->ui_.replyWrapper->setContentsMargins(0, 0, 0, 0);
auto replyHbox = replyWrapper.emplace<QHBoxLayout>().withoutMargin().assign(
&this->ui_.replyHbox);
auto replyLabel = replyHbox.emplace<QLabel>().assign(&this->ui_.replyLabel);
replyLabel->setAlignment(Qt::AlignLeft);
replyLabel->setFont(
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
replyHbox->addStretch(1);
auto replyCancelButton = replyHbox.emplace<EffectLabel>(nullptr, 4)
.assign(&this->ui_.cancelReplyButton);
replyCancelButton->getLabel().setTextFormat(Qt::RichText);
replyCancelButton->hide();
replyLabel->hide();
// hbox for input, right box
auto hboxLayout =
layout.emplace<QHBoxLayout>().withoutMargin().assign(&this->ui_.hbox);
// input
auto textEdit =
layout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
hboxLayout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
connect(textEdit.getElement(), &ResizingTextEdit::textChanged, this,
&SplitInput::editTextChanged);
// right box
auto box = layout.emplace<QVBoxLayout>().withoutMargin();
auto box = hboxLayout.emplace<QVBoxLayout>().withoutMargin();
box->setSpacing(0);
{
auto textEditLength =
@ -102,6 +137,8 @@ void SplitInput::initLayout()
this->managedConnections_.managedConnect(app->fonts->fontChanged, [=]() {
this->ui_.textEdit->setFont(
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
this->ui_.replyLabel->setFont(
app->fonts->getFont(FontStyle::ChatMediumBold, this->scale()));
});
// open emote popup
@ -109,6 +146,12 @@ void SplitInput::initLayout()
this->openEmotePopup();
});
// clear input and remove reply thread
QObject::connect(this->ui_.cancelReplyButton, &EffectLabel::leftClicked,
[=] {
this->clearInput();
});
// clear channelview selection when selecting in the input
QObject::connect(this->ui_.textEdit, &QTextEdit::copyAvailable,
[this](bool available) {
@ -129,8 +172,10 @@ void SplitInput::initLayout()
void SplitInput::scaleChangedEvent(float scale)
{
// update the icon size of the emote button
auto app = getApp();
// update the icon size of the buttons
this->updateEmoteButton();
this->updateCancelReplyButton();
// set maximum height
if (!this->hidden)
@ -138,9 +183,11 @@ void SplitInput::scaleChangedEvent(float scale)
this->setMaximumHeight(this->scaledMaxHeight());
}
this->ui_.textEdit->setFont(
getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale()));
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
this->ui_.textEditLength->setFont(
getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale()));
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
this->ui_.replyLabel->setFont(
app->fonts->getFont(FontStyle::ChatMediumBold, this->scale()));
}
void SplitInput::themeChangedEvent()
@ -155,6 +202,7 @@ void SplitInput::themeChangedEvent()
#endif
this->updateEmoteButton();
this->updateCancelReplyButton();
this->ui_.textEditLength->setPalette(palette);
this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet);
@ -162,10 +210,19 @@ void SplitInput::themeChangedEvent()
this->ui_.textEdit->setPalette(placeholderPalette);
#endif
this->ui_.hbox->setMargin(
this->ui_.vbox->setMargin(
int((this->theme->isLightTheme() ? 4 : 2) * this->scale()));
this->ui_.emoteButton->getLabel().setStyleSheet("color: #000");
if (this->theme->isLightTheme())
{
this->ui_.replyLabel->setStyleSheet("color: #333");
}
else
{
this->ui_.replyLabel->setStyleSheet("color: #ccc");
}
}
void SplitInput::updateEmoteButton()
@ -184,6 +241,24 @@ void SplitInput::updateEmoteButton()
this->ui_.emoteButton->setFixedHeight(int(18 * scale));
}
void SplitInput::updateCancelReplyButton()
{
float scale = this->scale();
QString text =
QStringLiteral(
"<img src=':/buttons/cancel.svg' width='%1' height='%1' />")
.arg(QString::number(int(12 * scale)));
if (this->theme->isLightTheme())
{
text.replace("cancel", "cancelDark");
}
this->ui_.cancelReplyButton->getLabel().setText(text);
this->ui_.cancelReplyButton->setFixedHeight(int(12 * scale));
}
void SplitInput::openEmotePopup()
{
if (!this->emotePopup_)
@ -217,6 +292,79 @@ void SplitInput::openEmotePopup()
this->emotePopup_->activateWindow();
}
QString SplitInput::handleSendMessage(std::vector<QString> &arguments)
{
auto c = this->split_->getChannel();
if (c == nullptr)
return "";
if (!c->isTwitchChannel() || this->replyThread_ == nullptr)
{
// standard message send behavior
QString message = ui_.textEdit->toPlainText();
message = message.replace('\n', ' ');
QString sendMessage =
getApp()->commands->execCommand(message, c, false);
c->sendMessage(sendMessage);
this->postMessageSend(message, arguments);
return "";
}
else
{
// Reply to message
auto tc = dynamic_cast<TwitchChannel *>(c.get());
if (!tc)
{
// this should not fail
return "";
}
QString message = this->ui_.textEdit->toPlainText();
if (this->enableInlineReplying_)
{
// Remove @username prefix that is inserted when doing inline replies
message.remove(0, this->replyThread_->root()->displayName.length() +
1); // remove "@username"
if (!message.isEmpty() && message.at(0) == ' ')
{
message.remove(0, 1); // remove possible space
}
}
message = message.replace('\n', ' ');
QString sendMessage =
getApp()->commands->execCommand(message, c, false);
// Reply within TwitchChannel
tc->sendReply(sendMessage, this->replyThread_->rootId());
this->postMessageSend(message, arguments);
return "";
}
}
void SplitInput::postMessageSend(const QString &message,
const std::vector<QString> &arguments)
{
// don't add duplicate messages and empty message to message history
if ((this->prevMsg_.isEmpty() || !this->prevMsg_.endsWith(message)) &&
!message.trimmed().isEmpty())
{
this->prevMsg_.append(message);
}
if (arguments.empty() || arguments.at(0) != "keepInput")
{
this->clearInput();
}
this->prevIndex_ = this->prevMsg_.size();
}
int SplitInput::scaledMaxHeight() const
{
return int(150 * this->scale());
@ -302,37 +450,7 @@ void SplitInput::addShortcuts()
}},
{"sendMessage",
[this](std::vector<QString> arguments) -> QString {
auto c = this->split_->getChannel();
if (c == nullptr)
return "";
QString message = ui_.textEdit->toPlainText();
message = message.replace('\n', ' ');
QString sendMessage =
getApp()->commands->execCommand(message, c, false);
c->sendMessage(sendMessage);
// don't add duplicate messages and empty message to message history
if ((this->prevMsg_.isEmpty() ||
!this->prevMsg_.endsWith(message)) &&
!message.trimmed().isEmpty())
{
this->prevMsg_.append(message);
}
bool shouldClearInput = true;
if (arguments.size() != 0 && arguments.at(0) == "keepInput")
{
shouldClearInput = false;
}
if (shouldClearInput)
{
this->currMsg_ = QString();
this->ui_.textEdit->setPlainText(QString());
}
this->prevIndex_ = this->prevMsg_.size();
return "";
return this->handleSendMessage(arguments);
}},
{"previousMessage",
[this](std::vector<QString>) -> QString {
@ -456,8 +574,7 @@ void SplitInput::addShortcuts()
}},
{"clear",
[this](std::vector<QString>) -> QString {
this->ui_.textEdit->setText("");
this->ui_.textEdit->moveCursor(QTextCursor::Start);
this->clearInput();
return "";
}},
{"selectAll",
@ -769,37 +886,71 @@ void SplitInput::editTextChanged()
}
this->ui_.textEditLength->setText(labelText);
bool hasReply = false;
if (this->enableInlineReplying_)
{
if (this->replyThread_ != nullptr)
{
// Check if the input still starts with @username. If not, don't reply.
//
// We need to verify that
// 1. the @username prefix exists and
// 2. if a character exists after the @username, it is a space
QString replyPrefix = "@" + this->replyThread_->root()->displayName;
if (!text.startsWith(replyPrefix) ||
(text.length() > replyPrefix.length() &&
text.at(replyPrefix.length()) != ' '))
{
this->replyThread_ = nullptr;
}
}
// Show/hide reply label if inline replies are possible
hasReply = this->replyThread_ != nullptr;
}
this->ui_.replyWrapper->setVisible(hasReply);
this->ui_.replyLabel->setVisible(hasReply);
this->ui_.cancelReplyButton->setVisible(hasReply);
}
void SplitInput::paintEvent(QPaintEvent * /*event*/)
{
QPainter painter(this);
int s;
QColor borderColor;
if (this->theme->isLightTheme())
{
int s = int(3 * this->scale());
QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
painter.fillRect(rect, this->theme->splits.input.background);
painter.setPen(QColor("#ccc"));
painter.drawRect(rect);
s = int(3 * this->scale());
borderColor = QColor("#ccc");
}
else
{
int s = int(1 * this->scale());
QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
painter.fillRect(rect, this->theme->splits.input.background);
painter.setPen(QColor("#333"));
painter.drawRect(rect);
s = int(1 * this->scale());
borderColor = QColor("#333");
}
// int offset = 2;
// painter.fillRect(offset, this->height() - offset, this->width() - 2 *
// offset, 1,
// getApp()->themes->splits.input.focusedLine);
QMargins removeMargins(s - 1, s - 1, s, s);
QRect baseRect = this->rect();
// completeAreaRect includes the reply label
QRect completeAreaRect = baseRect.marginsRemoved(removeMargins);
painter.fillRect(completeAreaRect, this->theme->splits.input.background);
painter.setPen(borderColor);
painter.drawRect(completeAreaRect);
if (this->enableInlineReplying_ && this->replyThread_ != nullptr)
{
// Move top of rect down to not include reply label
baseRect.setTop(baseRect.top() + this->ui_.replyWrapper->height());
QRect onlyInputRect = baseRect.marginsRemoved(removeMargins);
painter.setPen(borderColor);
painter.drawRect(onlyInputRect);
}
}
void SplitInput::resizeEvent(QResizeEvent *)
@ -814,4 +965,41 @@ void SplitInput::resizeEvent(QResizeEvent *)
}
}
void SplitInput::giveFocus(Qt::FocusReason reason)
{
this->ui_.textEdit->setFocus(reason);
}
void SplitInput::setReply(std::shared_ptr<MessageThread> reply,
bool showReplyingLabel)
{
this->replyThread_ = std::move(reply);
if (this->enableInlineReplying_)
{
// Only enable reply label if inline replying
this->ui_.textEdit->setPlainText(
"@" + this->replyThread_->root()->displayName + " ");
this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock);
this->ui_.replyLabel->setText("Replying to @" +
this->replyThread_->root()->displayName);
}
}
void SplitInput::setPlaceholderText(const QString &text)
{
this->ui_.textEdit->setPlaceholderText(text);
}
void SplitInput::clearInput()
{
this->currMsg_ = "";
this->ui_.textEdit->setText("");
this->ui_.textEdit->moveCursor(QTextCursor::Start);
if (this->enableInlineReplying_)
{
this->replyThread_ = nullptr;
}
}
} // namespace chatterino

View file

@ -11,6 +11,7 @@
#include <QTextEdit>
#include <QVBoxLayout>
#include <QWidget>
#include <memory>
namespace chatterino {
@ -18,6 +19,7 @@ class Split;
class EmotePopup;
class InputCompletionPopup;
class EffectLabel;
class MessageThread;
class ResizingTextEdit;
class SplitInput : public BaseWidget
@ -25,13 +27,19 @@ class SplitInput : public BaseWidget
Q_OBJECT
public:
SplitInput(Split *_chatWidget);
SplitInput(Split *_chatWidget, bool enableInlineReplying = true);
SplitInput(QWidget *parent, Split *_chatWidget,
bool enableInlineReplying = true);
void clearSelection();
bool isEditFirstWord() const;
QString getInputText() const;
void insertText(const QString &text);
void setReply(std::shared_ptr<MessageThread> reply,
bool showInlineReplying = true);
void setPlaceholderText(const QString &text);
/**
* @brief Hide the widget
*
@ -64,7 +72,16 @@ protected:
void paintEvent(QPaintEvent * /*event*/) override;
void resizeEvent(QResizeEvent * /*event*/) override;
private:
virtual void giveFocus(Qt::FocusReason reason);
QString handleSendMessage(std::vector<QString> &arguments);
void postMessageSend(const QString &message,
const std::vector<QString> &arguments);
/// Clears the input box, clears reply thread if inline replies are enabled
void clearInput();
protected:
void addShortcuts() override;
void initLayout();
bool eventFilter(QObject *obj, QEvent *event) override;
@ -78,6 +95,8 @@ private:
void insertCompletionText(const QString &text);
void openEmotePopup();
void updateCancelReplyButton();
// scaledMaxHeight returns the height in pixels that this widget can grow to
// This does not take hidden into account, so callers must take hidden into account themselves
int scaledMaxHeight() const;
@ -92,8 +111,17 @@ private:
EffectLabel *emoteButton;
QHBoxLayout *hbox;
QVBoxLayout *vbox;
QWidget *replyWrapper;
QHBoxLayout *replyHbox;
QLabel *replyLabel;
EffectLabel *cancelReplyButton;
} ui_;
std::shared_ptr<MessageThread> replyThread_ = nullptr;
bool enableInlineReplying_;
pajlada::Signals::SignalHolder managedConnections_;
QStringList prevMsg_;
QString currMsg_;
@ -109,6 +137,7 @@ private slots:
void editTextChanged();
friend class Split;
friend class ReplyThreadPopup;
};
} // namespace chatterino