diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40e01bf44..1c8710595 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/resources/buttons/cancel.svg b/resources/buttons/cancel.svg
new file mode 100644
index 000000000..7bc012dae
--- /dev/null
+++ b/resources/buttons/cancel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/buttons/cancelDark.svg b/resources/buttons/cancelDark.svg
new file mode 100644
index 000000000..5c231ffdb
--- /dev/null
+++ b/resources/buttons/cancelDark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/buttons/replyDark.png b/resources/buttons/replyDark.png
new file mode 100644
index 000000000..0b47b7b92
Binary files /dev/null and b/resources/buttons/replyDark.png differ
diff --git a/resources/buttons/replyDark.svg b/resources/buttons/replyDark.svg
new file mode 100644
index 000000000..d41fcfda8
--- /dev/null
+++ b/resources/buttons/replyDark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/buttons/replyThreadDark.png b/resources/buttons/replyThreadDark.png
new file mode 100644
index 000000000..3d6c0b437
Binary files /dev/null and b/resources/buttons/replyThreadDark.png differ
diff --git a/resources/buttons/replyThreadDark.svg b/resources/buttons/replyThreadDark.svg
new file mode 100644
index 000000000..a0f781ca4
--- /dev/null
+++ b/resources/buttons/replyThreadDark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/resources_autogenerated.qrc b/resources/resources_autogenerated.qrc
index 3516addd4..2c91e0282 100644
--- a/resources/resources_autogenerated.qrc
+++ b/resources/resources_autogenerated.qrc
@@ -20,6 +20,8 @@
buttons/addSplitDark.png
buttons/ban.png
buttons/banRed.png
+ buttons/cancel.svg
+ buttons/cancelDark.svg
buttons/clearSearch.png
buttons/copyDark.png
buttons/copyDark.svg
@@ -34,6 +36,10 @@
buttons/modModeDisabled2.png
buttons/modModeEnabled.png
buttons/modModeEnabled2.png
+ buttons/replyDark.png
+ buttons/replyDark.svg
+ buttons/replyThreadDark.png
+ buttons/replyThreadDark.svg
buttons/search.png
buttons/timeout.png
buttons/trashCan.png
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 5b76d4b57..a943a31a2 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -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
diff --git a/src/autogenerated/ResourcesAutogen.cpp b/src/autogenerated/ResourcesAutogen.cpp
index 7f9bba594..9661241da 100644
--- a/src/autogenerated/ResourcesAutogen.cpp
+++ b/src/autogenerated/ResourcesAutogen.cpp
@@ -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");
diff --git a/src/autogenerated/ResourcesAutogen.hpp b/src/autogenerated/ResourcesAutogen.hpp
index 0e40fe3ea..7aed77a2b 100644
--- a/src/autogenerated/ResourcesAutogen.hpp
+++ b/src/autogenerated/ResourcesAutogen.hpp
@@ -39,6 +39,8 @@ public:
QPixmap modModeDisabled2;
QPixmap modModeEnabled;
QPixmap modModeEnabled2;
+ QPixmap replyDark;
+ QPixmap replyThreadDark;
QPixmap search;
QPixmap timeout;
QPixmap trashCan;
diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp
index 374035e2a..9b700f0c5 100644
--- a/src/common/Channel.cpp
+++ b/src/common/Channel.cpp
@@ -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)
{
}
diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp
index 0b7c81653..7b9f4fd6b 100644
--- a/src/common/Channel.hpp
+++ b/src/common/Channel.hpp
@@ -49,6 +49,9 @@ public:
// SIGNALS
pajlada::Signals::Signal
sendMessageSignal;
+ pajlada::Signals::Signal
+ sendReplySignal;
pajlada::Signals::Signal messageRemovedFromStart;
pajlada::Signals::Signal>
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;
diff --git a/src/common/Common.hpp b/src/common/Common.hpp
index 1dded1d26..2284fd0ea 100644
--- a/src/common/Common.hpp
+++ b/src/common/Common.hpp
@@ -51,6 +51,7 @@ using MessagePtr = std::shared_ptr;
enum class CopyMode {
Everything,
+ EverythingButReplies,
OnlyTextAndEmotes,
};
diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp
index b0db5e731..702262510 100644
--- a/src/controllers/commands/CommandController.cpp
+++ b/src/controllers/commands/CommandController.cpp
@@ -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(&(getApp()->windows->getMainWindow())));
+ static_cast(&(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(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 "));
+ 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 thread;
+ // found most recent message by user
+ if (msg->replyThread == nullptr)
+ {
+ thread = std::make_shared(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",
diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp
index 3e973da77..f4c80efa6 100644
--- a/src/controllers/filters/parser/FilterParser.cpp
+++ b/src/controllers/filters/parser/FilterParser.cpp
@@ -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()},
diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/parser/Tokenizer.hpp
index f37026539..752616078 100644
--- a/src/controllers/filters/parser/Tokenizer.hpp
+++ b/src/controllers/filters/parser/Tokenizer.hpp
@@ -25,6 +25,7 @@ static const QMap 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"}};
diff --git a/src/messages/Link.hpp b/src/messages/Link.hpp
index 616ecf87d..f6a48a7d3 100644
--- a/src/messages/Link.hpp
+++ b/src/messages/Link.hpp
@@ -23,6 +23,8 @@ public:
JumpToChannel,
Reconnect,
CopyToClipboard,
+ ReplyToMessage,
+ ViewThread,
};
Link();
diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp
index a5969daaa..cc9f36503 100644
--- a/src/messages/Message.hpp
+++ b/src/messages/Message.hpp
@@ -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;
@@ -68,6 +70,10 @@ struct Message : boost::noncopyable {
std::vector badges;
std::unordered_map badgeInfos;
std::shared_ptr 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 replyThread;
uint32_t count = 1;
std::vector> elements;
diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp
index 3d3f4cde6..a266e5855 100644
--- a/src/messages/MessageElement.cpp
+++ b/src/messages/MessageElement.cpp
@@ -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(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(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
diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp
index c26df48fc..4cc47873a 100644
--- a/src/messages/MessageElement.hpp
+++ b/src/messages/MessageElement.hpp
@@ -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 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 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
diff --git a/src/messages/MessageThread.cpp b/src/messages/MessageThread.cpp
new file mode 100644
index 000000000..dc798d993
--- /dev/null
+++ b/src/messages/MessageThread.cpp
@@ -0,0 +1,61 @@
+#include "MessageThread.hpp"
+
+#include "messages/Message.hpp"
+#include "util/DebugCount.hpp"
+
+#include
+
+namespace chatterino {
+
+MessageThread::MessageThread(std::shared_ptr 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 &message)
+{
+ this->replies_.emplace_back(message);
+}
+
+void MessageThread::addToThread(const std::weak_ptr &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 &exclude) const
+{
+ size_t count = 0;
+ for (auto reply : this->replies_)
+ {
+ if (!reply.expired() && reply.lock() != exclude)
+ {
+ ++count;
+ }
+ }
+
+ return count;
+}
+
+} // namespace chatterino
diff --git a/src/messages/MessageThread.hpp b/src/messages/MessageThread.hpp
new file mode 100644
index 000000000..f2a8e57d5
--- /dev/null
+++ b/src/messages/MessageThread.hpp
@@ -0,0 +1,47 @@
+#pragma once
+
+#include
+
+#include
+#include
+
+namespace chatterino {
+struct Message;
+
+class MessageThread
+{
+public:
+ MessageThread(std::shared_ptr rootMessage);
+ ~MessageThread();
+
+ void addToThread(const std::shared_ptr &message);
+ void addToThread(const std::weak_ptr &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 &exclude) const;
+
+ const QString &rootId() const
+ {
+ return rootMessageId_;
+ }
+
+ const std::shared_ptr &root() const
+ {
+ return rootMessage_;
+ }
+
+ const std::vector> &replies() const
+ {
+ return replies_;
+ }
+
+private:
+ const QString rootMessageId_;
+ const std::shared_ptr rootMessage_;
+ std::vector> replies_;
+};
+
+} // namespace chatterino
diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp
index 1e48f7d02..f0f01f933 100644
--- a/src/messages/layouts/MessageLayout.cpp
+++ b/src/messages/layouts/MessageLayout.cpp
@@ -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
diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp
index d097934d4..33c1fad72 100644
--- a/src/messages/layouts/MessageLayout.hpp
+++ b/src/messages/layouts/MessageLayout.hpp
@@ -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 container_;
std::shared_ptr buffer_{};
bool bufferValid_ = false;
+ bool renderReplies_ = true;
int height_ = 0;
diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp
index 5fca4008a..eefc88ae0 100644
--- a/src/messages/layouts/MessageLayoutContainer.cpp
+++ b/src/messages/layouts/MessageLayoutContainer.cpp
@@ -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(
diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp
index fba472876..492a4c8bb 100644
--- a/src/messages/layouts/MessageLayoutElement.cpp
+++ b/src/messages/layouts/MessageLayoutElement.cpp
@@ -10,6 +10,7 @@
#include
#include
+#include
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
diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp
index 1c7db0374..4bf26ad29 100644
--- a/src/messages/layouts/MessageLayoutElement.hpp
+++ b/src/messages/layouts/MessageLayoutElement.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include
#include
#include
#include
@@ -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
diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp
index 5f26ca47c..ca1411952 100644
--- a/src/providers/twitch/IrcMessageHandler.cpp
+++ b/src/providers/twitch/IrcMessageHandler.cpp
@@ -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
+#include
#include
namespace {
@@ -242,6 +242,87 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message,
false, message->isAction());
}
+std::vector IrcMessageHandler::parseMessageWithReply(
+ Channel *channel, Communi::IrcMessage *message,
+ const std::vector &otherLoaded)
+{
+ std::vector builtMessages;
+
+ auto command = message->command();
+
+ if (command == "PRIVMSG")
+ {
+ auto privMsg = static_cast(message);
+ auto tc = dynamic_cast(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(message));
+ }
+
+ return builtMessages;
+}
+
+void IrcMessageHandler::populateReply(
+ TwitchChannel *channel, Communi::IrcMessage *message,
+ const std::vector &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 newThread =
+ std::make_shared(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(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(root);
+
+ builder.setThread(newThread);
+ // Store weak reference to thread in channel
+ channel->addReplyThread(newThread);
+ }
+ }
+ }
+
if (isSub || !builder.isIgnored())
{
if (isSub)
diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp
index caa8740b9..db3fb8c48 100644
--- a/src/providers/twitch/IrcMessageHandler.hpp
+++ b/src/providers/twitch/IrcMessageHandler.hpp
@@ -3,6 +3,10 @@
#include
#include "common/Channel.hpp"
#include "messages/Message.hpp"
+#include "providers/twitch/TwitchChannel.hpp"
+#include "providers/twitch/TwitchMessageBuilder.hpp"
+
+#include
namespace chatterino {
@@ -20,6 +24,10 @@ public:
std::vector parseMessage(Channel *channel,
Communi::IrcMessage *message);
+ std::vector parseMessageWithReply(
+ Channel *channel, Communi::IrcMessage *message,
+ const std::vector &otherLoaded);
+
// parsePrivMessage arses a single IRC PRIVMSG into 0-1 Chatterino messages
std::vector 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 &otherLoaded,
+ TwitchMessageBuilder &builder);
};
} // namespace chatterino
diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp
index 26e30a976..f866cbc38 100644
--- a/src/providers/twitch/TwitchChannel.cpp
+++ b/src/providers/twitch/TwitchChannel.cpp
@@ -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 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();
+ builder.emplace(expirationText, MessageElementFlag::Text,
+ MessageColor::System);
+ builder
+ .emplace(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();
- builder.emplace(expirationText, MessageElementFlag::Text,
- MessageColor::System);
- builder
- .emplace(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 &thread)
+{
+ this->threads_[thread->rootId()] = thread;
+}
+
+const std::unordered_map>
+ &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/" +
diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp
index 12f74374a..b605d9592 100644
--- a/src/providers/twitch/TwitchChannel.hpp
+++ b/src/providers/twitch/TwitchChannel.hpp
@@ -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(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 &thread);
+ const std::unordered_map> &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_;
UniqueAccess roomModes_;
+ std::unordered_map> threads_;
protected:
Atomic> bttvEmotes_;
@@ -194,6 +212,7 @@ private:
QString lastSentMessage_;
QObject lifetimeGuard_;
QTimer chattersListTimer_;
+ QTimer threadClearTimer_;
QElapsedTimer titleRefreshedTimer_;
QElapsedTimer clipCreationTimer_;
bool isClipCreationInProgress{false};
diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp
index 1286deb41..4110142c7 100644
--- a/src/providers/twitch/TwitchIrcServer.cpp
+++ b/src/providers/twitch/TwitchIrcServer.cpp
@@ -121,6 +121,11 @@ std::shared_ptr 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);
}
@@ -370,66 +375,90 @@ bool TwitchIrcServer::hasSeparateWriteConnection() const
// return getSettings()->twitchSeperateWriteConnection;
}
+bool TwitchIrcServer::prepareToSend(TwitchChannel *channel)
+{
+ std::lock_guard 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 guard(this->lastMessageMutex_);
-
- // std::queue
- 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;
diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp
index fd693a206..323581c63 100644
--- a/src/providers/twitch/TwitchIrcServer.hpp
+++ b/src/providers/twitch/TwitchIrcServer.hpp
@@ -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 lastMessagePleb_;
diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp
index c4fcd156f..b2eb7488d 100644
--- a/src/providers/twitch/TwitchMessageBuilder.cpp
+++ b/src/providers/twitch/TwitchMessageBuilder.cpp
@@ -48,6 +48,66 @@ const QSet 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();
+
+ // construct reply elements
+ this->emplace(
+ "Replying to", MessageElementFlag::RepliedMessage,
+ MessageColor::System, FontStyle::ChatMediumSmall)
+ ->setLink({Link::ViewThread, this->thread_->rootId()});
+
+ this->emplace(
+ "@" + usernameText + ":", MessageElementFlag::RepliedMessage,
+ threadRoot->usernameColor, FontStyle::ChatMediumSmall)
+ ->setLink({Link::UserInfo, threadRoot->displayName});
+
+ this->emplace(
+ 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();
+
+ this->emplace(
+ "Replying to", MessageElementFlag::RepliedMessage,
+ MessageColor::System, FontStyle::ChatMediumSmall);
+
+ this->emplace(
+ "@" + name + ":", MessageElementFlag::RepliedMessage,
+ this->textColor_, FontStyle::ChatMediumSmall)
+ ->setLink({Link::UserInfo, name});
+
+ this->emplace(
+ body, MessageElementFlag::RepliedMessage, this->textColor_,
+ FontStyle::ChatMediumSmall);
+ }
+ }
+
// timestamp
this->message().serverReceivedTime = calculateMessageTime(this->ircMessage);
this->emplace(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(Image::fromPixmap(img, 0.15), 2,
+ Qt::gray,
+ MessageElementFlag::ReplyButton)
+ ->setLink({Link::ViewThread, this->thread_->rootId()});
+ }
+ else
+ {
+ auto &img = getResources().buttons.replyDark;
+ this->emplace(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 thread)
+{
+ this->thread_ = std::move(thread);
+}
+
} // namespace chatterino
diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp
index c96712d61..130c89c3c 100644
--- a/src/providers/twitch/TwitchMessageBuilder.hpp
+++ b/src/providers/twitch/TwitchMessageBuilder.hpp
@@ -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 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 thread_;
QString userId_;
bool senderIsBroadcaster{};
diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp
index 439cd4327..38863c114 100644
--- a/src/singletons/Settings.hpp
+++ b/src/singletons/Settings.hpp
@@ -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};
diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp
index 2c6e66131..f7f555d66 100644
--- a/src/singletons/WindowManager.cpp
+++ b/src/singletons/WindowManager.cpp
@@ -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);
diff --git a/src/widgets/DraggablePopup.cpp b/src/widgets/DraggablePopup.cpp
new file mode 100644
index 000000000..e103ac974
--- /dev/null
+++ b/src/widgets/DraggablePopup.cpp
@@ -0,0 +1,90 @@
+#include "DraggablePopup.hpp"
+
+#include
+
+#include
+
+namespace chatterino {
+
+namespace {
+
+#ifdef Q_OS_LINUX
+ FlagsEnum popupFlags{BaseWindow::Dialog,
+ BaseWindow::EnableCustomFrame};
+ FlagsEnum popupFlagsCloseAutomatically{
+ BaseWindow::EnableCustomFrame};
+#else
+ FlagsEnum popupFlags{BaseWindow::EnableCustomFrame};
+ FlagsEnum popupFlagsCloseAutomatically{
+ BaseWindow::EnableCustomFrame, BaseWindow::Frameless,
+ BaseWindow::FramelessDraggable};
+#endif
+
+} // namespace
+
+DraggablePopup::DraggablePopup(bool closeAutomatically, QWidget *parent)
+ : BaseWindow(closeAutomatically ? popupFlagsCloseAutomatically : popupFlags,
+ parent)
+ , lifetimeHack_(std::make_shared(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(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
diff --git a/src/widgets/DraggablePopup.hpp b/src/widgets/DraggablePopup.hpp
new file mode 100644
index 000000000..65050e15b
--- /dev/null
+++ b/src/widgets/DraggablePopup.hpp
@@ -0,0 +1,47 @@
+#pragma once
+
+#include "widgets/BaseWindow.hpp"
+
+#include
+#include
+
+#include
+
+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 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
diff --git a/src/widgets/dialogs/ReplyThreadPopup.cpp b/src/widgets/dialogs/ReplyThreadPopup.cpp
new file mode 100644
index 000000000..c4771de03
--- /dev/null
+++ b/src/widgets/dialogs/ReplyThreadPopup.cpp
@@ -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 {
+ this->deleteLater();
+ return "";
+ }},
+ {"scrollPage",
+ [this](std::vector 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(this->getLayoutContainer())
+ .setLayoutType();
+
+ // 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 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(sourceChannel->getName());
+ }
+ else
+ {
+ virtualChannel = std::make_shared(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(
+ 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
diff --git a/src/widgets/dialogs/ReplyThreadPopup.hpp b/src/widgets/dialogs/ReplyThreadPopup.hpp
new file mode 100644
index 000000000..863274e5f
--- /dev/null
+++ b/src/widgets/dialogs/ReplyThreadPopup.hpp
@@ -0,0 +1,48 @@
+#pragma once
+
+#include "ForwardDecl.hpp"
+#include "widgets/DraggablePopup.hpp"
+
+#include
+#include
+#include
+
+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 thread);
+ void giveFocus(Qt::FocusReason reason);
+
+protected:
+ void focusInEvent(QFocusEvent *event) override;
+
+private:
+ void addMessagesFromThread();
+ void updateInputUI();
+
+ // The message reply thread
+ std::shared_ptr thread_;
+ // The channel that the reply thread is in
+ ChannelPtr channel_;
+ Split *split_;
+
+ struct {
+ ChannelView *threadView = nullptr;
+ SplitInput *replyInput = nullptr;
+ } ui_;
+
+ std::unique_ptr messageConnection_;
+ std::vector bSignals_;
+};
+
+} // namespace chatterino
diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp
index 957451c0c..a57fcedc5 100644
--- a/src/widgets/dialogs/UserInfoPopup.cpp
+++ b/src/widgets/dialogs/UserInfoPopup.cpp
@@ -91,8 +91,16 @@ namespace {
LimitedQueueSnapshot snapshot =
channel->getMessageSnapshot();
- ChannelPtr channelPtr(
- new Channel(channel->getName(), Channel::Type::None));
+ ChannelPtr channelPtr;
+ if (channel->isTwitchChannel())
+ {
+ channelPtr = std::make_shared(channel->getName());
+ }
+ else
+ {
+ channelPtr = std::make_shared(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 userInfoPopupFlags{BaseWindow::Dialog,
- BaseWindow::EnableCustomFrame};
-FlagsEnum userInfoPopupFlagsCloseAutomatically{
- BaseWindow::EnableCustomFrame};
-#else
-FlagsEnum userInfoPopupFlags{BaseWindow::EnableCustomFrame};
-FlagsEnum 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 {
@@ -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(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 ignoreNext = std::make_shared(false);
@@ -765,7 +710,7 @@ void UserInfoPopup::updateLatestMessages()
void UserInfoPopup::updateUserData()
{
- std::weak_ptr hack = this->hack_;
+ std::weak_ptr hack = this->lifetimeHack_;
auto currentUser = getApp()->accounts->twitch.getCurrent();
const auto onUserFetchFailed = [this, hack] {
diff --git a/src/widgets/dialogs/UserInfoPopup.hpp b/src/widgets/dialogs/UserInfoPopup.hpp
index 2c7e1cf04..d87651bab 100644
--- a/src/widgets/dialogs/UserInfoPopup.hpp
+++ b/src/widgets/dialogs/UserInfoPopup.hpp
@@ -1,6 +1,6 @@
#pragma once
-#include "widgets/BaseWindow.hpp"
+#include "widgets/DraggablePopup.hpp"
#include "widgets/helper/ChannelView.hpp"
#include
@@ -16,12 +16,13 @@ class Channel;
using ChannelPtr = std::shared_ptr;
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 refreshConnection_;
- std::shared_ptr hack_;
-
struct {
Button *avatarButton = nullptr;
Button *localizedNameCopyButton = nullptr;
diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp
index 96fef73cb..09696c5bc 100644
--- a/src/widgets/helper/ChannelView.cpp
+++ b/src/widgets/helper/ChannelView.cpp
@@ -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(&(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 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(message);
+ tc->addReplyThread(thread);
+ return thread;
+ }
+ };
+
+ if (auto tc =
+ dynamic_cast(this->underlyingChannel_.get()))
+ {
+ thread = getThread(tc);
+ }
+ else if (auto tc = dynamic_cast(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(&(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
diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp
index 1aabbf62f..34e4ba463 100644
--- a/src/widgets/helper/ChannelView.hpp
+++ b/src/widgets/helper/ChannelView.hpp
@@ -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 messages_;
pajlada::Signals::SignalHolder signalHolder_;
diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp
index 9b255d27d..d33465499 100644
--- a/src/widgets/helper/SearchPopup.cpp
+++ b/src/widgets/helper/SearchPopup.cpp
@@ -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_);
}
diff --git a/src/widgets/helper/SearchPopup.hpp b/src/widgets/helper/SearchPopup.hpp
index 61c0f9719..c927ff5b1 100644
--- a/src/widgets/helper/SearchPopup.hpp
+++ b/src/widgets/helper/SearchPopup.hpp
@@ -5,6 +5,7 @@
#include "messages/LimitedQueueSnapshot.hpp"
#include "messages/search/MessagePredicate.hpp"
#include "widgets/BasePopup.hpp"
+#include "widgets/splits/Split.hpp"
#include
@@ -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> searchChannels_;
};
diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp
index 8655b6c42..a09b6bdb6 100644
--- a/src/widgets/settingspages/GeneralPage.cpp
+++ b/src/widgets/settingspages/GeneralPage.cpp
@@ -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);
diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp
index d4b8a1ba5..c7c0d7579 100644
--- a/src/widgets/splits/Split.cpp
+++ b/src/widgets/splits/Split.cpp
@@ -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 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 &reply)
+{
+ this->input_->setReply(reply);
+}
+
} // namespace chatterino
QDebug operator<<(QDebug dbg, const chatterino::Split &split)
diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp
index 06d36fcd2..25190a797 100644
--- a/src/widgets/splits/Split.hpp
+++ b/src/widgets/splits/Split.hpp
@@ -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 &reply);
+
static pajlada::Signals::Signal
modifierStatusChanged;
static Qt::KeyboardModifiers modifierStatus;
diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp
index 0188f2ced..4a4c73f2d 100644
--- a/src/widgets/splits/SplitInput.cpp
+++ b/src/widgets/splits/SplitInput.cpp
@@ -23,15 +23,24 @@
#include "widgets/splits/SplitContainer.hpp"
#include "widgets/splits/SplitInput.hpp"
+#include
+
#include
#include
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 layoutCreator(this);
auto layout =
- layoutCreator.setLayoutType().withoutMargin().assign(
- &this->ui_.hbox);
+ layoutCreator.setLayoutType().withoutMargin().assign(
+ &this->ui_.vbox);
+
+ // reply label stuff
+ auto replyWrapper =
+ layout.emplace().assign(&this->ui_.replyWrapper);
+ this->ui_.replyWrapper->setContentsMargins(0, 0, 0, 0);
+
+ auto replyHbox = replyWrapper.emplace().withoutMargin().assign(
+ &this->ui_.replyHbox);
+
+ auto replyLabel = replyHbox.emplace().assign(&this->ui_.replyLabel);
+ replyLabel->setAlignment(Qt::AlignLeft);
+ replyLabel->setFont(
+ app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
+
+ replyHbox->addStretch(1);
+
+ auto replyCancelButton = replyHbox.emplace(nullptr, 4)
+ .assign(&this->ui_.cancelReplyButton);
+ replyCancelButton->getLabel().setTextFormat(Qt::RichText);
+
+ replyCancelButton->hide();
+ replyLabel->hide();
+
+ // hbox for input, right box
+ auto hboxLayout =
+ layout.emplace().withoutMargin().assign(&this->ui_.hbox);
// input
auto textEdit =
- layout.emplace().assign(&this->ui_.textEdit);
+ hboxLayout.emplace().assign(&this->ui_.textEdit);
connect(textEdit.getElement(), &ResizingTextEdit::textChanged, this,
&SplitInput::editTextChanged);
// right box
- auto box = layout.emplace().withoutMargin();
+ auto box = hboxLayout.emplace().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(
+ "
")
+ .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 &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(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 &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 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 {
@@ -456,8 +574,7 @@ void SplitInput::addShortcuts()
}},
{"clear",
[this](std::vector) -> 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 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
diff --git a/src/widgets/splits/SplitInput.hpp b/src/widgets/splits/SplitInput.hpp
index 7296dc8b3..bcb4d149e 100644
--- a/src/widgets/splits/SplitInput.hpp
+++ b/src/widgets/splits/SplitInput.hpp
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
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 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 &arguments);
+ void postMessageSend(const QString &message,
+ const std::vector &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 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