mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-21 22:24:07 +01:00
Added support for Twitch's Chat Replies (#3722)
Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
parent
a280089693
commit
20c974fdab
53 changed files with 2022 additions and 310 deletions
|
@ -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)
|
||||
|
|
1
resources/buttons/cancel.svg
Normal file
1
resources/buttons/cancel.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 0C4.5 0 0 4.5 0 10s4.5 10 10 10 10-4.5 10-10S15.5 0 10 0Zm5 13.6L13.6 15 10 11.4 6.4 15 5 13.6 8.6 10 5 6.4 6.4 5 10 8.6 13.6 5 15 6.4 11.4 10l3.6 3.6Z" fill="#ffffff" fill-rule="evenodd" class="fill-000000"></path></svg>
|
After Width: | Height: | Size: 294 B |
1
resources/buttons/cancelDark.svg
Normal file
1
resources/buttons/cancelDark.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" ?><svg height="20px" version="1.1" viewBox="0 0 20 20" width="20px" xmlns="http://www.w3.org/2000/svg" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><desc/><defs/><g fill="none" fill-rule="evenodd" id="Page-1" stroke="none" stroke-width="1"><g fill="#000000" id="Core" transform="translate(-380.000000, -44.000000)"><g id="cancel" transform="translate(380.000000, 44.000000)"><path d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M15,13.6 L13.6,15 L10,11.4 L6.4,15 L5,13.6 L8.6,10 L5,6.4 L6.4,5 L10,8.6 L13.6,5 L15,6.4 L11.4,10 L15,13.6 L15,13.6 Z" id="Shape"/></g></g></g></svg>
|
After Width: | Height: | Size: 710 B |
BIN
resources/buttons/replyDark.png
Normal file
BIN
resources/buttons/replyDark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 769 B |
1
resources/buttons/replyDark.svg
Normal file
1
resources/buttons/replyDark.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" ?><svg height="17px" version="1.1" viewBox="0 0 18 17" width="18px" xmlns="http://www.w3.org/2000/svg" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><desc/><defs/><g fill="none" fill-rule="evenodd" id="Page-1" stroke="none" stroke-width="1"><g fill="#000000" id="Core" transform="translate(-45.000000, -382.000000)"><g id="reply" transform="translate(45.000000, 382.500000)"><path d="M7,4 L7,0 L0,7 L7,14 L7,9.9 C12,9.9 15.5,11.5 18,15 C17,10 14,5 7,4 L7,4 Z" id="Shape"/></g></g></g></svg>
|
After Width: | Height: | Size: 570 B |
BIN
resources/buttons/replyThreadDark.png
Normal file
BIN
resources/buttons/replyThreadDark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 671 B |
1
resources/buttons/replyThreadDark.svg
Normal file
1
resources/buttons/replyThreadDark.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" ?><svg height="17px" version="1.1" viewBox="0 0 24 17" width="24px" xmlns="http://www.w3.org/2000/svg" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><desc/><defs/><g fill="none" fill-rule="evenodd" id="Page-1" stroke="none" stroke-width="1"><g fill="#000000" id="Core" transform="translate(-84.000000, -382.000000)"><g id="reply-all" transform="translate(84.000000, 382.500000)"><path d="M7,3 L7,0 L0,7 L7,14 L7,11 L3,7 L7,3 L7,3 Z M13,4 L13,0 L6,7 L13,14 L13,9.9 C18,9.9 21.5,11.5 24,15 C23,10 20,5 13,4 L13,4 Z" id="Shape"/></g></g></g></svg>
|
After Width: | Height: | Size: 624 B |
|
@ -20,6 +20,8 @@
|
|||
<file>buttons/addSplitDark.png</file>
|
||||
<file>buttons/ban.png</file>
|
||||
<file>buttons/banRed.png</file>
|
||||
<file>buttons/cancel.svg</file>
|
||||
<file>buttons/cancelDark.svg</file>
|
||||
<file>buttons/clearSearch.png</file>
|
||||
<file>buttons/copyDark.png</file>
|
||||
<file>buttons/copyDark.svg</file>
|
||||
|
@ -34,6 +36,10 @@
|
|||
<file>buttons/modModeDisabled2.png</file>
|
||||
<file>buttons/modModeEnabled.png</file>
|
||||
<file>buttons/modModeEnabled2.png</file>
|
||||
<file>buttons/replyDark.png</file>
|
||||
<file>buttons/replyDark.svg</file>
|
||||
<file>buttons/replyThreadDark.png</file>
|
||||
<file>buttons/replyThreadDark.svg</file>
|
||||
<file>buttons/search.png</file>
|
||||
<file>buttons/timeout.png</file>
|
||||
<file>buttons/trashCan.png</file>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -39,6 +39,8 @@ public:
|
|||
QPixmap modModeDisabled2;
|
||||
QPixmap modModeEnabled;
|
||||
QPixmap modModeEnabled2;
|
||||
QPixmap replyDark;
|
||||
QPixmap replyThreadDark;
|
||||
QPixmap search;
|
||||
QPixmap timeout;
|
||||
QPixmap trashCan;
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
|
|
@ -49,6 +49,9 @@ public:
|
|||
// SIGNALS
|
||||
pajlada::Signals::Signal<const QString &, const QString &, bool &>
|
||||
sendMessageSignal;
|
||||
pajlada::Signals::Signal<const QString &, const QString &, const QString &,
|
||||
bool &>
|
||||
sendReplySignal;
|
||||
pajlada::Signals::Signal<MessagePtr &> messageRemovedFromStart;
|
||||
pajlada::Signals::Signal<MessagePtr &, boost::optional<MessageFlags>>
|
||||
messageAppended;
|
||||
|
@ -84,6 +87,7 @@ public:
|
|||
|
||||
// CHANNEL INFO
|
||||
virtual bool canSendMessage() const;
|
||||
virtual bool isWritable() const; // whether split input will be usable
|
||||
virtual void sendMessage(const QString &message);
|
||||
virtual bool isMod() const;
|
||||
virtual bool isBroadcaster() const;
|
||||
|
|
|
@ -51,6 +51,7 @@ using MessagePtr = std::shared_ptr<const Message>;
|
|||
|
||||
enum class CopyMode {
|
||||
Everything,
|
||||
EverythingButReplies,
|
||||
OnlyTextAndEmotes,
|
||||
};
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
#include "util/StreamLink.hpp"
|
||||
#include "util/Twitch.hpp"
|
||||
#include "widgets/Window.hpp"
|
||||
#include "widgets/dialogs/ReplyThreadPopup.hpp"
|
||||
#include "widgets/dialogs/UserInfoPopup.hpp"
|
||||
#include "widgets/splits/Split.hpp"
|
||||
|
||||
|
@ -687,7 +688,8 @@ void CommandController::initialize(Settings &, Paths &paths)
|
|||
|
||||
auto *userPopup = new UserInfoPopup(
|
||||
getSettings()->autoCloseUserPopup,
|
||||
static_cast<QWidget *>(&(getApp()->windows->getMainWindow())));
|
||||
static_cast<QWidget *>(&(getApp()->windows->getMainWindow())),
|
||||
nullptr);
|
||||
userPopup->setData(userName, channel);
|
||||
userPopup->move(QCursor::pos());
|
||||
userPopup->show();
|
||||
|
@ -1114,6 +1116,56 @@ void CommandController::initialize(Settings &, Paths &paths)
|
|||
return "";
|
||||
});
|
||||
|
||||
this->registerCommand(
|
||||
"/reply", [](const QStringList &words, ChannelPtr channel) {
|
||||
auto *twitchChannel = dynamic_cast<TwitchChannel *>(channel.get());
|
||||
if (twitchChannel == nullptr)
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
"The /reply command only works in Twitch channels"));
|
||||
return "";
|
||||
}
|
||||
|
||||
if (words.size() < 3)
|
||||
{
|
||||
channel->addMessage(
|
||||
makeSystemMessage("Usage: /reply <username> <message>"));
|
||||
return "";
|
||||
}
|
||||
|
||||
QString username = words[1];
|
||||
stripChannelName(username);
|
||||
|
||||
auto snapshot = twitchChannel->getMessageSnapshot();
|
||||
for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it)
|
||||
{
|
||||
const auto &msg = *it;
|
||||
if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0)
|
||||
{
|
||||
std::shared_ptr<MessageThread> thread;
|
||||
// found most recent message by user
|
||||
if (msg->replyThread == nullptr)
|
||||
{
|
||||
thread = std::make_shared<MessageThread>(msg);
|
||||
twitchChannel->addReplyThread(thread);
|
||||
}
|
||||
else
|
||||
{
|
||||
thread = msg->replyThread;
|
||||
}
|
||||
|
||||
QString reply = words.mid(2).join(" ");
|
||||
twitchChannel->sendReply(reply, thread->rootId());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
channel->addMessage(
|
||||
makeSystemMessage("A message from that user wasn't found"));
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
#ifndef NDEBUG
|
||||
this->registerCommand(
|
||||
"/fakemsg",
|
||||
|
|
|
@ -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()},
|
||||
|
|
|
@ -25,6 +25,7 @@ static const QMap<QString, QString> validIdentifiersMap = {
|
|||
{"flags.reward_message", "channel point reward message?"},
|
||||
{"flags.first_message", "first message?"},
|
||||
{"flags.whisper", "whisper message?"},
|
||||
{"flags.reply", "reply message?"},
|
||||
{"message.content", "message text"},
|
||||
{"message.length", "message length"}};
|
||||
|
||||
|
|
|
@ -23,6 +23,8 @@ public:
|
|||
JumpToChannel,
|
||||
Reconnect,
|
||||
CopyToClipboard,
|
||||
ReplyToMessage,
|
||||
ViewThread,
|
||||
};
|
||||
|
||||
Link();
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
namespace chatterino {
|
||||
class MessageElement;
|
||||
class MessageThread;
|
||||
|
||||
enum class MessageFlag : uint32_t {
|
||||
None = 0,
|
||||
|
@ -40,6 +41,7 @@ enum class MessageFlag : uint32_t {
|
|||
RedeemedChannelPointReward = (1 << 21),
|
||||
ShowInMentions = (1 << 22),
|
||||
FirstMessage = (1 << 23),
|
||||
ReplyMessage = (1 << 24),
|
||||
};
|
||||
using MessageFlags = FlagsEnum<MessageFlag>;
|
||||
|
||||
|
@ -68,6 +70,10 @@ struct Message : boost::noncopyable {
|
|||
std::vector<Badge> badges;
|
||||
std::unordered_map<QString, QString> badgeInfos;
|
||||
std::shared_ptr<QColor> highlightColor;
|
||||
// Each reply holds a reference to the thread. When every reply is dropped,
|
||||
// the reply thread will be cleaned up by the TwitchChannel.
|
||||
// The root of the thread does not have replyThread set.
|
||||
std::shared_ptr<MessageThread> replyThread;
|
||||
uint32_t count = 1;
|
||||
std::vector<std::unique_ptr<MessageElement>> elements;
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
#include "messages/Emote.hpp"
|
||||
#include "messages/layouts/MessageLayoutContainer.hpp"
|
||||
#include "messages/layouts/MessageLayoutElement.hpp"
|
||||
#include "providers/emoji/Emojis.hpp"
|
||||
#include "singletons/Emotes.hpp"
|
||||
#include "singletons/Settings.hpp"
|
||||
#include "singletons/Theme.hpp"
|
||||
#include "util/DebugCount.hpp"
|
||||
|
@ -132,6 +134,31 @@ void ImageElement::addToContainer(MessageLayoutContainer &container,
|
|||
}
|
||||
}
|
||||
|
||||
CircularImageElement::CircularImageElement(ImagePtr image, int padding,
|
||||
QColor background,
|
||||
MessageElementFlags flags)
|
||||
: MessageElement(flags)
|
||||
, image_(image)
|
||||
, padding_(padding)
|
||||
, background_(background)
|
||||
{
|
||||
}
|
||||
|
||||
void CircularImageElement::addToContainer(MessageLayoutContainer &container,
|
||||
MessageElementFlags flags)
|
||||
{
|
||||
if (flags.hasAny(this->getFlags()))
|
||||
{
|
||||
auto imgSize = QSize(this->image_->width(), this->image_->height()) *
|
||||
container.getScale();
|
||||
|
||||
container.addElement((new ImageWithCircleBackgroundLayoutElement(
|
||||
*this, this->image_, imgSize,
|
||||
this->background_, this->padding_))
|
||||
->setLink(this->getLink()));
|
||||
}
|
||||
}
|
||||
|
||||
// EMOTE
|
||||
EmoteElement::EmoteElement(const EmotePtr &emote, MessageElementFlags flags,
|
||||
const MessageColor &textElementColor)
|
||||
|
@ -395,6 +422,137 @@ void TextElement::addToContainer(MessageLayoutContainer &container,
|
|||
}
|
||||
}
|
||||
|
||||
SingleLineTextElement::SingleLineTextElement(const QString &text,
|
||||
MessageElementFlags flags,
|
||||
const MessageColor &color,
|
||||
FontStyle style)
|
||||
: MessageElement(flags)
|
||||
, color_(color)
|
||||
, style_(style)
|
||||
{
|
||||
for (const auto &word : text.split(' '))
|
||||
{
|
||||
this->words_.push_back({word, -1});
|
||||
}
|
||||
}
|
||||
|
||||
void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
|
||||
MessageElementFlags flags)
|
||||
{
|
||||
auto app = getApp();
|
||||
|
||||
if (flags.hasAny(this->getFlags()))
|
||||
{
|
||||
QFontMetrics metrics =
|
||||
app->fonts->getFontMetrics(this->style_, container.getScale());
|
||||
|
||||
auto getTextLayoutElement = [&](QString text, int width,
|
||||
bool hasTrailingSpace) {
|
||||
auto color = this->color_.getColor(*app->themes);
|
||||
app->themes->normalizeColor(color);
|
||||
|
||||
auto e = (new TextLayoutElement(
|
||||
*this, text, QSize(width, metrics.height()), color,
|
||||
this->style_, container.getScale()))
|
||||
->setLink(this->getLink());
|
||||
e->setTrailingSpace(hasTrailingSpace);
|
||||
e->setText(text);
|
||||
|
||||
// If URL link was changed,
|
||||
// Should update it in MessageLayoutElement too!
|
||||
if (this->getLink().type == Link::Url)
|
||||
{
|
||||
static_cast<TextLayoutElement *>(e)->listenToLinkChanges();
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
static const auto ellipsis = QStringLiteral("...");
|
||||
auto addEllipsis = [&]() {
|
||||
int ellipsisSize = metrics.horizontalAdvance(ellipsis);
|
||||
container.addElementNoLineBreak(
|
||||
getTextLayoutElement(ellipsis, ellipsisSize, false));
|
||||
};
|
||||
|
||||
for (Word &word : this->words_)
|
||||
{
|
||||
auto parsedWords = app->emotes->emojis.parse(word.text);
|
||||
if (parsedWords.size() == 0)
|
||||
{
|
||||
continue; // sanity check
|
||||
}
|
||||
|
||||
auto &parsedWord = parsedWords[0];
|
||||
if (parsedWord.type() == typeid(EmotePtr))
|
||||
{
|
||||
auto emote = boost::get<EmotePtr>(parsedWord);
|
||||
auto image =
|
||||
emote->images.getImageOrLoaded(container.getScale());
|
||||
if (!image->isEmpty())
|
||||
{
|
||||
auto emoteScale = getSettings()->emoteScale.getValue();
|
||||
|
||||
auto size = QSize(image->width(), image->height()) *
|
||||
(emoteScale * container.getScale());
|
||||
|
||||
if (!container.fitsInLine(size.width()))
|
||||
{
|
||||
addEllipsis();
|
||||
break;
|
||||
}
|
||||
|
||||
container.addElementNoLineBreak(
|
||||
(new ImageLayoutElement(*this, image, size))
|
||||
->setLink(this->getLink()));
|
||||
}
|
||||
}
|
||||
else if (parsedWord.type() == typeid(QString))
|
||||
{
|
||||
word.width = metrics.horizontalAdvance(word.text);
|
||||
|
||||
// see if the text fits in the current line
|
||||
if (container.fitsInLine(word.width))
|
||||
{
|
||||
container.addElementNoLineBreak(getTextLayoutElement(
|
||||
word.text, word.width, this->hasTrailingSpace()));
|
||||
}
|
||||
else
|
||||
{
|
||||
// word overflows, try minimum truncation
|
||||
bool cutSuccess = false;
|
||||
for (size_t cut = 1; cut < word.text.length(); ++cut)
|
||||
{
|
||||
// Cut off n characters and append the ellipsis.
|
||||
// Try removing characters one by one until the word fits.
|
||||
QString truncatedWord =
|
||||
word.text.chopped(cut) + ellipsis;
|
||||
int newSize = metrics.horizontalAdvance(truncatedWord);
|
||||
if (container.fitsInLine(newSize))
|
||||
{
|
||||
container.addElementNoLineBreak(
|
||||
getTextLayoutElement(truncatedWord, newSize,
|
||||
false));
|
||||
cutSuccess = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cutSuccess)
|
||||
{
|
||||
// We weren't able to show any part of the current word, so
|
||||
// just append the ellipsis.
|
||||
addEllipsis();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.breakLine();
|
||||
}
|
||||
}
|
||||
|
||||
// TIMESTAMP
|
||||
TimestampElement::TimestampElement(QTime time)
|
||||
: MessageElement(MessageElementFlag::Timestamp)
|
||||
|
@ -502,4 +660,24 @@ void ScalingImageElement::addToContainer(MessageLayoutContainer &container,
|
|||
}
|
||||
}
|
||||
|
||||
ReplyCurveElement::ReplyCurveElement()
|
||||
: MessageElement(MessageElementFlag::RepliedMessage)
|
||||
// these values nicely align with a single badge
|
||||
, neededMargin_(3)
|
||||
, size_(18, 14)
|
||||
{
|
||||
}
|
||||
|
||||
void ReplyCurveElement::addToContainer(MessageLayoutContainer &container,
|
||||
MessageElementFlags flags)
|
||||
{
|
||||
if (flags.hasAny(this->getFlags()))
|
||||
{
|
||||
QSize boxSize = this->size_ * container.getScale();
|
||||
container.addElement(new ReplyCurveLayoutElement(
|
||||
*this, boxSize, 1.5 * container.getScale(),
|
||||
this->neededMargin_ * container.getScale()));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
|
@ -126,6 +126,12 @@ enum class MessageElementFlag : int64_t {
|
|||
// e.g. BTTV's SoSnowy during christmas season
|
||||
ZeroWidthEmote = (1LL << 31),
|
||||
|
||||
// for elements of the message reply
|
||||
RepliedMessage = (1LL << 32),
|
||||
|
||||
// for the reply button element
|
||||
ReplyButton = (1LL << 33),
|
||||
|
||||
Default = Timestamp | Badges | Username | BitsStatic | FfzEmoteImage |
|
||||
BttvEmoteImage | TwitchEmoteImage | BitsAmount | Text |
|
||||
AlwaysShow,
|
||||
|
@ -209,6 +215,22 @@ private:
|
|||
ImagePtr image_;
|
||||
};
|
||||
|
||||
// contains a image with a circular background color
|
||||
class CircularImageElement : public MessageElement
|
||||
{
|
||||
public:
|
||||
CircularImageElement(ImagePtr image, int padding, QColor background,
|
||||
MessageElementFlags flags);
|
||||
|
||||
void addToContainer(MessageLayoutContainer &container,
|
||||
MessageElementFlags flags) override;
|
||||
|
||||
private:
|
||||
ImagePtr image_;
|
||||
int padding_;
|
||||
QColor background_;
|
||||
};
|
||||
|
||||
// contains a text, it will split it into words
|
||||
class TextElement : public MessageElement
|
||||
{
|
||||
|
@ -232,6 +254,29 @@ private:
|
|||
std::vector<Word> words_;
|
||||
};
|
||||
|
||||
// contains a text that will be truncated to one line
|
||||
class SingleLineTextElement : public MessageElement
|
||||
{
|
||||
public:
|
||||
SingleLineTextElement(const QString &text, MessageElementFlags flags,
|
||||
const MessageColor &color = MessageColor::Text,
|
||||
FontStyle style = FontStyle::ChatMedium);
|
||||
~SingleLineTextElement() override = default;
|
||||
|
||||
void addToContainer(MessageLayoutContainer &container,
|
||||
MessageElementFlags flags) override;
|
||||
|
||||
private:
|
||||
MessageColor color_;
|
||||
FontStyle style_;
|
||||
|
||||
struct Word {
|
||||
QString text;
|
||||
int width = -1;
|
||||
};
|
||||
std::vector<Word> words_;
|
||||
};
|
||||
|
||||
// contains emote data and will pick the emote based on :
|
||||
// a) are images for the emote type enabled
|
||||
// b) which size it wants
|
||||
|
@ -355,4 +400,18 @@ public:
|
|||
private:
|
||||
ImageSet images_;
|
||||
};
|
||||
|
||||
class ReplyCurveElement : public MessageElement
|
||||
{
|
||||
public:
|
||||
ReplyCurveElement();
|
||||
|
||||
void addToContainer(MessageLayoutContainer &container,
|
||||
MessageElementFlags flags) override;
|
||||
|
||||
private:
|
||||
int neededMargin_;
|
||||
QSize size_;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
61
src/messages/MessageThread.cpp
Normal file
61
src/messages/MessageThread.cpp
Normal file
|
@ -0,0 +1,61 @@
|
|||
#include "MessageThread.hpp"
|
||||
|
||||
#include "messages/Message.hpp"
|
||||
#include "util/DebugCount.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
MessageThread::MessageThread(std::shared_ptr<const Message> rootMessage)
|
||||
: rootMessageId_(rootMessage->id)
|
||||
, rootMessage_(std::move(rootMessage))
|
||||
{
|
||||
DebugCount::increase("message threads");
|
||||
}
|
||||
|
||||
MessageThread::~MessageThread()
|
||||
{
|
||||
DebugCount::decrease("message threads");
|
||||
}
|
||||
|
||||
void MessageThread::addToThread(const std::shared_ptr<const Message> &message)
|
||||
{
|
||||
this->replies_.emplace_back(message);
|
||||
}
|
||||
|
||||
void MessageThread::addToThread(const std::weak_ptr<const Message> &message)
|
||||
{
|
||||
this->replies_.push_back(message);
|
||||
}
|
||||
|
||||
size_t MessageThread::liveCount() const
|
||||
{
|
||||
size_t count = 0;
|
||||
for (auto reply : this->replies_)
|
||||
{
|
||||
if (!reply.expired())
|
||||
{
|
||||
++count;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
size_t MessageThread::liveCount(
|
||||
const std::shared_ptr<const Message> &exclude) const
|
||||
{
|
||||
size_t count = 0;
|
||||
for (auto reply : this->replies_)
|
||||
{
|
||||
if (!reply.expired() && reply.lock() != exclude)
|
||||
{
|
||||
++count;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
47
src/messages/MessageThread.hpp
Normal file
47
src/messages/MessageThread.hpp
Normal file
|
@ -0,0 +1,47 @@
|
|||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace chatterino {
|
||||
struct Message;
|
||||
|
||||
class MessageThread
|
||||
{
|
||||
public:
|
||||
MessageThread(std::shared_ptr<const Message> rootMessage);
|
||||
~MessageThread();
|
||||
|
||||
void addToThread(const std::shared_ptr<const Message> &message);
|
||||
void addToThread(const std::weak_ptr<const Message> &message);
|
||||
|
||||
/// Returns the number of live reply references
|
||||
size_t liveCount() const;
|
||||
|
||||
/// Returns the number of live reply references
|
||||
size_t liveCount(const std::shared_ptr<const Message> &exclude) const;
|
||||
|
||||
const QString &rootId() const
|
||||
{
|
||||
return rootMessageId_;
|
||||
}
|
||||
|
||||
const std::shared_ptr<const Message> &root() const
|
||||
{
|
||||
return rootMessage_;
|
||||
}
|
||||
|
||||
const std::vector<std::weak_ptr<const Message>> &replies() const
|
||||
{
|
||||
return replies_;
|
||||
}
|
||||
|
||||
private:
|
||||
const QString rootMessageId_;
|
||||
const std::shared_ptr<const Message> rootMessage_;
|
||||
std::vector<std::weak_ptr<const Message>> replies_;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
|
@ -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
|
||||
|
|
|
@ -37,6 +37,7 @@ public:
|
|||
~MessageLayout();
|
||||
|
||||
const Message *getMessage();
|
||||
const MessagePtr &getMessagePtr() const;
|
||||
|
||||
int getHeight() const;
|
||||
|
||||
|
@ -62,6 +63,8 @@ public:
|
|||
|
||||
// Misc
|
||||
bool isDisabled() const;
|
||||
bool isReplyable() const;
|
||||
void setRenderReplies(bool render);
|
||||
|
||||
private:
|
||||
// variables
|
||||
|
@ -69,6 +72,7 @@ private:
|
|||
std::shared_ptr<MessageLayoutContainer> container_;
|
||||
std::shared_ptr<QPixmap> buffer_{};
|
||||
bool bufferValid_ = false;
|
||||
bool renderReplies_ = true;
|
||||
|
||||
int height_ = 0;
|
||||
|
||||
|
|
|
@ -661,6 +661,14 @@ void MessageLayoutContainer::addSelectionText(QString &str, int from, int to,
|
|||
|
||||
for (auto &element : this->elements_)
|
||||
{
|
||||
if (copymode != CopyMode::Everything &&
|
||||
element->getCreator().getFlags().has(
|
||||
MessageElementFlag::RepliedMessage))
|
||||
{
|
||||
// Don't include the message being replied to
|
||||
continue;
|
||||
}
|
||||
|
||||
if (copymode == CopyMode::OnlyTextAndEmotes)
|
||||
{
|
||||
if (element->getCreator().getFlags().hasAny(
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
#include <QDebug>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
|
@ -205,6 +206,44 @@ void ImageWithBackgroundLayoutElement::paint(QPainter &painter)
|
|||
}
|
||||
}
|
||||
|
||||
//
|
||||
// IMAGE WITH CIRCLE BACKGROUND
|
||||
//
|
||||
ImageWithCircleBackgroundLayoutElement::ImageWithCircleBackgroundLayoutElement(
|
||||
MessageElement &creator, ImagePtr image, const QSize &imageSize,
|
||||
QColor color, int padding)
|
||||
: ImageLayoutElement(creator, image,
|
||||
imageSize + QSize(padding, padding) * 2)
|
||||
, color_(color)
|
||||
, imageSize_(imageSize)
|
||||
, padding_(padding)
|
||||
{
|
||||
}
|
||||
|
||||
void ImageWithCircleBackgroundLayoutElement::paint(QPainter &painter)
|
||||
{
|
||||
if (this->image_ == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto pixmap = this->image_->pixmapOrLoad();
|
||||
if (pixmap && !this->image_->animated())
|
||||
{
|
||||
QRectF boxRect(this->getRect());
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(QBrush(this->color_, Qt::SolidPattern));
|
||||
painter.drawEllipse(boxRect);
|
||||
|
||||
QRectF imgRect;
|
||||
imgRect.setTopLeft(boxRect.topLeft());
|
||||
imgRect.setSize(this->imageSize_);
|
||||
imgRect.translate(this->padding_, this->padding_);
|
||||
|
||||
painter.drawPixmap(imgRect, *pixmap, QRectF());
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// TEXT
|
||||
//
|
||||
|
@ -402,4 +441,67 @@ int TextIconLayoutElement::getXFromIndex(int index)
|
|||
}
|
||||
}
|
||||
|
||||
ReplyCurveLayoutElement::ReplyCurveLayoutElement(MessageElement &creator,
|
||||
const QSize &size,
|
||||
float thickness,
|
||||
float neededMargin)
|
||||
: MessageLayoutElement(creator, size)
|
||||
, pen_(QColor("#888"), thickness, Qt::SolidLine, Qt::RoundCap)
|
||||
, neededMargin_(neededMargin)
|
||||
{
|
||||
}
|
||||
|
||||
void ReplyCurveLayoutElement::paint(QPainter &painter)
|
||||
{
|
||||
QRectF paintRect(this->getRect());
|
||||
QPainterPath bezierPath;
|
||||
|
||||
qreal top = paintRect.top() + paintRect.height() * 0.25; // 25% from top
|
||||
qreal left = paintRect.left() + this->neededMargin_;
|
||||
qreal bottom = paintRect.bottom() - this->neededMargin_;
|
||||
QPointF startPoint(left, bottom);
|
||||
QPointF controlPoint(left, top);
|
||||
QPointF endPoint(paintRect.right(), top);
|
||||
|
||||
// Create curve path
|
||||
bezierPath.moveTo(startPoint);
|
||||
bezierPath.quadTo(controlPoint, endPoint);
|
||||
|
||||
// Render curve
|
||||
painter.setPen(this->pen_);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
painter.drawPath(bezierPath);
|
||||
}
|
||||
|
||||
void ReplyCurveLayoutElement::paintAnimated(QPainter &painter, int yOffset)
|
||||
{
|
||||
}
|
||||
|
||||
int ReplyCurveLayoutElement::getMouseOverIndex(const QPoint &abs) const
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ReplyCurveLayoutElement::getXFromIndex(int index)
|
||||
{
|
||||
if (index <= 0)
|
||||
{
|
||||
return this->getRect().left();
|
||||
}
|
||||
else
|
||||
{
|
||||
return this->getRect().right();
|
||||
}
|
||||
}
|
||||
|
||||
void ReplyCurveLayoutElement::addCopyTextToString(QString &str, int from,
|
||||
int to) const
|
||||
{
|
||||
}
|
||||
|
||||
int ReplyCurveLayoutElement::getSelectionIndexCount() const
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
#include <QPen>
|
||||
#include <QPoint>
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
|
@ -93,6 +94,23 @@ private:
|
|||
QColor color_;
|
||||
};
|
||||
|
||||
class ImageWithCircleBackgroundLayoutElement : public ImageLayoutElement
|
||||
{
|
||||
public:
|
||||
ImageWithCircleBackgroundLayoutElement(MessageElement &creator,
|
||||
ImagePtr image,
|
||||
const QSize &imageSize, QColor color,
|
||||
int padding);
|
||||
|
||||
protected:
|
||||
void paint(QPainter &painter) override;
|
||||
|
||||
private:
|
||||
const QColor color_;
|
||||
const QSize imageSize_;
|
||||
const int padding_;
|
||||
};
|
||||
|
||||
// TEXT
|
||||
class TextLayoutElement : public MessageLayoutElement
|
||||
{
|
||||
|
@ -142,4 +160,24 @@ private:
|
|||
QString line2;
|
||||
};
|
||||
|
||||
class ReplyCurveLayoutElement : public MessageLayoutElement
|
||||
{
|
||||
public:
|
||||
ReplyCurveLayoutElement(MessageElement &creator, const QSize &size,
|
||||
float thickness, float lMargin);
|
||||
|
||||
protected:
|
||||
void paint(QPainter &painter) override;
|
||||
void paintAnimated(QPainter &painter, int yOffset) override;
|
||||
int getMouseOverIndex(const QPoint &abs) const override;
|
||||
int getXFromIndex(int index) override;
|
||||
void addCopyTextToString(QString &str, int from = 0,
|
||||
int to = INT_MAX) const override;
|
||||
int getSelectionIndexCount() const override;
|
||||
|
||||
private:
|
||||
const QPen pen_;
|
||||
const float neededMargin_;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
#include "providers/twitch/TwitchChannel.hpp"
|
||||
#include "providers/twitch/TwitchHelpers.hpp"
|
||||
#include "providers/twitch/TwitchIrcServer.hpp"
|
||||
#include "providers/twitch/TwitchMessageBuilder.hpp"
|
||||
#include "singletons/Resources.hpp"
|
||||
#include "singletons/Settings.hpp"
|
||||
#include "singletons/WindowManager.hpp"
|
||||
|
@ -19,6 +18,7 @@
|
|||
|
||||
#include <IrcMessage>
|
||||
|
||||
#include <memory>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace {
|
||||
|
@ -242,6 +242,87 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message,
|
|||
false, message->isAction());
|
||||
}
|
||||
|
||||
std::vector<MessagePtr> IrcMessageHandler::parseMessageWithReply(
|
||||
Channel *channel, Communi::IrcMessage *message,
|
||||
const std::vector<MessagePtr> &otherLoaded)
|
||||
{
|
||||
std::vector<MessagePtr> builtMessages;
|
||||
|
||||
auto command = message->command();
|
||||
|
||||
if (command == "PRIVMSG")
|
||||
{
|
||||
auto privMsg = static_cast<Communi::IrcPrivateMessage *>(message);
|
||||
auto tc = dynamic_cast<TwitchChannel *>(channel);
|
||||
if (!tc)
|
||||
{
|
||||
return this->parsePrivMessage(channel, privMsg);
|
||||
}
|
||||
|
||||
MessageParseArgs args;
|
||||
TwitchMessageBuilder builder(channel, message, args, privMsg->content(),
|
||||
privMsg->isAction());
|
||||
|
||||
this->populateReply(tc, message, otherLoaded, builder);
|
||||
|
||||
if (!builder.isIgnored())
|
||||
{
|
||||
builtMessages.emplace_back(builder.build());
|
||||
builder.triggerHighlights();
|
||||
}
|
||||
}
|
||||
else if (command == "USERNOTICE")
|
||||
{
|
||||
return this->parseUserNoticeMessage(channel, message);
|
||||
}
|
||||
else if (command == "NOTICE")
|
||||
{
|
||||
return this->parseNoticeMessage(
|
||||
static_cast<Communi::IrcNoticeMessage *>(message));
|
||||
}
|
||||
|
||||
return builtMessages;
|
||||
}
|
||||
|
||||
void IrcMessageHandler::populateReply(
|
||||
TwitchChannel *channel, Communi::IrcMessage *message,
|
||||
const std::vector<MessagePtr> &otherLoaded, TwitchMessageBuilder &builder)
|
||||
{
|
||||
const auto &tags = message->tags();
|
||||
if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end())
|
||||
{
|
||||
const QString replyID = it.value().toString();
|
||||
auto threadIt = channel->threads_.find(replyID);
|
||||
if (threadIt != channel->threads_.end())
|
||||
{
|
||||
const auto owned = threadIt->second.lock();
|
||||
if (owned)
|
||||
{
|
||||
// Thread already exists (has a reply)
|
||||
builder.setThread(owned);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Thread does not yet exist, find root reply and create thread.
|
||||
// Linear search is justified by the infrequent use of replies
|
||||
for (auto &otherMsg : otherLoaded)
|
||||
{
|
||||
if (otherMsg->id == replyID)
|
||||
{
|
||||
// Found root reply message
|
||||
std::shared_ptr<MessageThread> newThread =
|
||||
std::make_shared<MessageThread>(otherMsg);
|
||||
|
||||
builder.setThread(newThread);
|
||||
// Store weak reference to thread in channel
|
||||
channel->addReplyThread(newThread);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
|
||||
const QString &target,
|
||||
const QString &content,
|
||||
|
@ -276,7 +357,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
|
|||
auto channel = dynamic_cast<TwitchChannel *>(chan.get());
|
||||
|
||||
const auto &tags = _message->tags();
|
||||
if (const auto &it = tags.find("custom-reward-id"); it != tags.end())
|
||||
if (const auto it = tags.find("custom-reward-id"); it != tags.end())
|
||||
{
|
||||
const auto rewardId = it.value().toString();
|
||||
if (!channel->isChannelPointRewardKnown(rewardId))
|
||||
|
@ -301,6 +382,31 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
|
|||
|
||||
TwitchMessageBuilder builder(chan.get(), _message, args, content, isAction);
|
||||
|
||||
if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end())
|
||||
{
|
||||
const QString replyID = it.value().toString();
|
||||
auto threadIt = channel->threads_.find(replyID);
|
||||
if (threadIt != channel->threads_.end() && !threadIt->second.expired())
|
||||
{
|
||||
// Thread already exists (has a reply)
|
||||
builder.setThread(threadIt->second.lock());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Thread does not yet exist, find root reply and create thread.
|
||||
auto root = channel->findMessage(replyID);
|
||||
if (root)
|
||||
{
|
||||
// Found root reply message
|
||||
const auto newThread = std::make_shared<MessageThread>(root);
|
||||
|
||||
builder.setThread(newThread);
|
||||
// Store weak reference to thread in channel
|
||||
channel->addReplyThread(newThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSub || !builder.isIgnored())
|
||||
{
|
||||
if (isSub)
|
||||
|
|
|
@ -3,6 +3,10 @@
|
|||
#include <IrcMessage>
|
||||
#include "common/Channel.hpp"
|
||||
#include "messages/Message.hpp"
|
||||
#include "providers/twitch/TwitchChannel.hpp"
|
||||
#include "providers/twitch/TwitchMessageBuilder.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
|
@ -20,6 +24,10 @@ public:
|
|||
std::vector<MessagePtr> parseMessage(Channel *channel,
|
||||
Communi::IrcMessage *message);
|
||||
|
||||
std::vector<MessagePtr> parseMessageWithReply(
|
||||
Channel *channel, Communi::IrcMessage *message,
|
||||
const std::vector<MessagePtr> &otherLoaded);
|
||||
|
||||
// parsePrivMessage arses a single IRC PRIVMSG into 0-1 Chatterino messages
|
||||
std::vector<MessagePtr> parsePrivMessage(
|
||||
Channel *channel, Communi::IrcPrivateMessage *message);
|
||||
|
@ -59,6 +67,10 @@ private:
|
|||
void addMessage(Communi::IrcMessage *message, const QString &target,
|
||||
const QString &content, TwitchIrcServer &server,
|
||||
bool isResub, bool isAction);
|
||||
|
||||
void populateReply(TwitchChannel *channel, Communi::IrcMessage *message,
|
||||
const std::vector<MessagePtr> &otherLoaded,
|
||||
TwitchMessageBuilder &builder);
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
#include "providers/twitch/TwitchChannel.hpp"
|
||||
|
||||
#include "Application.hpp"
|
||||
#include "common/Common.hpp"
|
||||
#include "common/Env.hpp"
|
||||
#include "common/NetworkRequest.hpp"
|
||||
|
@ -182,12 +181,34 @@ TwitchChannel::TwitchChannel(const QString &name)
|
|||
this->refreshBTTVChannelEmotes(false);
|
||||
});
|
||||
|
||||
this->messageRemovedFromStart.connect([this](MessagePtr &msg) {
|
||||
if (msg->replyThread)
|
||||
{
|
||||
if (msg->replyThread->liveCount(msg) == 0)
|
||||
{
|
||||
this->threads_.erase(msg->replyThread->rootId());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// timers
|
||||
QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [=] {
|
||||
this->refreshChatters();
|
||||
});
|
||||
this->chattersListTimer_.start(5 * 60 * 1000);
|
||||
|
||||
QObject::connect(&this->threadClearTimer_, &QTimer::timeout, [=] {
|
||||
// We periodically check for any dangling reply threads that missed
|
||||
// being cleaned up on messageRemovedFromStart. This could occur if
|
||||
// some other part of the program, like a user card, held a reference
|
||||
// to the message.
|
||||
//
|
||||
// It seems difficult to actually replicate a situation where things
|
||||
// are actually cleaned up, but I've verified that cleanups DO happen.
|
||||
this->cleanUpReplyThreads();
|
||||
});
|
||||
this->threadClearTimer_.start(5 * 60 * 1000);
|
||||
|
||||
// debugging
|
||||
#if 0
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
|
@ -311,46 +332,34 @@ boost::optional<ChannelPointReward> TwitchChannel::channelPointReward(
|
|||
return it->second;
|
||||
}
|
||||
|
||||
void TwitchChannel::sendMessage(const QString &message)
|
||||
void TwitchChannel::showLoginMessage()
|
||||
{
|
||||
const auto linkColor = MessageColor(MessageColor::Link);
|
||||
const auto accountsLink = Link(Link::OpenAccountsPage, QString());
|
||||
const auto currentUser = getApp()->accounts->twitch.getCurrent();
|
||||
const auto expirationText =
|
||||
QStringLiteral("You need to log in to send messages. You can link your "
|
||||
"Twitch account");
|
||||
const auto loginPromptText = QStringLiteral("in the settings.");
|
||||
|
||||
auto builder = MessageBuilder();
|
||||
builder.message().flags.set(MessageFlag::System);
|
||||
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
||||
|
||||
builder.emplace<TimestampElement>();
|
||||
builder.emplace<TextElement>(expirationText, MessageElementFlag::Text,
|
||||
MessageColor::System);
|
||||
builder
|
||||
.emplace<TextElement>(loginPromptText, MessageElementFlag::Text,
|
||||
linkColor)
|
||||
->setLink(accountsLink);
|
||||
|
||||
this->addMessage(builder.release());
|
||||
}
|
||||
|
||||
QString TwitchChannel::prepareMessage(const QString &message) const
|
||||
{
|
||||
auto app = getApp();
|
||||
|
||||
if (!app->accounts->twitch.isLoggedIn())
|
||||
{
|
||||
if (message.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const auto linkColor = MessageColor(MessageColor::Link);
|
||||
const auto accountsLink = Link(Link::OpenAccountsPage, QString());
|
||||
const auto currentUser = getApp()->accounts->twitch.getCurrent();
|
||||
const auto expirationText =
|
||||
QString("You need to log in to send messages. You can link your "
|
||||
"Twitch account");
|
||||
const auto loginPromptText = QString("in the settings.");
|
||||
|
||||
auto builder = MessageBuilder();
|
||||
builder.message().flags.set(MessageFlag::System);
|
||||
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
||||
|
||||
builder.emplace<TimestampElement>();
|
||||
builder.emplace<TextElement>(expirationText, MessageElementFlag::Text,
|
||||
MessageColor::System);
|
||||
builder
|
||||
.emplace<TextElement>(loginPromptText, MessageElementFlag::Text,
|
||||
linkColor)
|
||||
->setLink(accountsLink);
|
||||
|
||||
this->addMessage(builder.release());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
qCDebug(chatterinoTwitch)
|
||||
<< "[TwitchChannel" << this->getName() << "] Send message:" << message;
|
||||
|
||||
// Do last message processing
|
||||
QString parsedMessage = app->emotes->emojis.replaceShortCodes(message);
|
||||
|
||||
// This is to make sure that combined emoji go through properly, see
|
||||
|
@ -361,7 +370,7 @@ void TwitchChannel::sendMessage(const QString &message)
|
|||
|
||||
if (parsedMessage.isEmpty())
|
||||
{
|
||||
return;
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!this->hasHighRateLimit())
|
||||
|
@ -395,6 +404,32 @@ void TwitchChannel::sendMessage(const QString &message)
|
|||
}
|
||||
}
|
||||
|
||||
return parsedMessage;
|
||||
}
|
||||
|
||||
void TwitchChannel::sendMessage(const QString &message)
|
||||
{
|
||||
auto app = getApp();
|
||||
if (!app->accounts->twitch.isLoggedIn())
|
||||
{
|
||||
if (!message.isEmpty())
|
||||
{
|
||||
this->showLoginMessage();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
qCDebug(chatterinoTwitch)
|
||||
<< "[TwitchChannel" << this->getName() << "] Send message:" << message;
|
||||
|
||||
// Do last message processing
|
||||
QString parsedMessage = this->prepareMessage(message);
|
||||
if (parsedMessage.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool messageSent = false;
|
||||
this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent);
|
||||
|
||||
|
@ -405,6 +440,40 @@ void TwitchChannel::sendMessage(const QString &message)
|
|||
}
|
||||
}
|
||||
|
||||
void TwitchChannel::sendReply(const QString &message, const QString &replyId)
|
||||
{
|
||||
auto app = getApp();
|
||||
if (!app->accounts->twitch.isLoggedIn())
|
||||
{
|
||||
if (!message.isEmpty())
|
||||
{
|
||||
this->showLoginMessage();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
qCDebug(chatterinoTwitch) << "[TwitchChannel" << this->getName()
|
||||
<< "] Send reply message:" << message;
|
||||
|
||||
// Do last message processing
|
||||
QString parsedMessage = this->prepareMessage(message);
|
||||
if (parsedMessage.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool messageSent = false;
|
||||
this->sendReplySignal.invoke(this->getName(), parsedMessage, replyId,
|
||||
messageSent);
|
||||
|
||||
if (messageSent)
|
||||
{
|
||||
qCDebug(chatterinoTwitch) << "sent";
|
||||
this->lastSentMessage_ = parsedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
bool TwitchChannel::isMod() const
|
||||
{
|
||||
return this->mod_;
|
||||
|
@ -794,8 +863,10 @@ void TwitchChannel::loadRecentMessages()
|
|||
}
|
||||
}
|
||||
|
||||
for (auto builtMessage :
|
||||
handler.parseMessage(shared.get(), message))
|
||||
auto builtMessages = handler.parseMessageWithReply(
|
||||
shared.get(), message, allBuiltMessages);
|
||||
|
||||
for (auto builtMessage : builtMessages)
|
||||
{
|
||||
builtMessage->flags.set(MessageFlag::RecentMessage);
|
||||
allBuiltMessages.emplace_back(builtMessage);
|
||||
|
@ -922,6 +993,39 @@ void TwitchChannel::fetchDisplayName()
|
|||
[] {});
|
||||
}
|
||||
|
||||
void TwitchChannel::addReplyThread(const std::shared_ptr<MessageThread> &thread)
|
||||
{
|
||||
this->threads_[thread->rootId()] = thread;
|
||||
}
|
||||
|
||||
const std::unordered_map<QString, std::weak_ptr<MessageThread>>
|
||||
&TwitchChannel::threads() const
|
||||
{
|
||||
return this->threads_;
|
||||
}
|
||||
|
||||
void TwitchChannel::cleanUpReplyThreads()
|
||||
{
|
||||
for (auto it = this->threads_.begin(), last = this->threads_.end();
|
||||
it != last;)
|
||||
{
|
||||
bool doErase = true;
|
||||
if (auto thread = it->second.lock())
|
||||
{
|
||||
doErase = thread->liveCount() == 0;
|
||||
}
|
||||
|
||||
if (doErase)
|
||||
{
|
||||
it = this->threads_.erase(it);
|
||||
}
|
||||
else
|
||||
{
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TwitchChannel::refreshBadges()
|
||||
{
|
||||
auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" +
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
#include "Application.hpp"
|
||||
#include "common/Aliases.hpp"
|
||||
#include "common/Atomic.hpp"
|
||||
#include "common/Channel.hpp"
|
||||
|
@ -7,6 +8,7 @@
|
|||
#include "common/ChatterSet.hpp"
|
||||
#include "common/Outcome.hpp"
|
||||
#include "common/UniqueAccess.hpp"
|
||||
#include "messages/MessageThread.hpp"
|
||||
#include "providers/twitch/ChannelPointReward.hpp"
|
||||
#include "providers/twitch/TwitchEmotes.hpp"
|
||||
#include "providers/twitch/api/Helix.hpp"
|
||||
|
@ -74,12 +76,15 @@ public:
|
|||
int slowMode = 0;
|
||||
};
|
||||
|
||||
explicit TwitchChannel(const QString &channelName);
|
||||
|
||||
void initialize();
|
||||
|
||||
// Channel methods
|
||||
virtual bool isEmpty() const override;
|
||||
virtual bool canSendMessage() const override;
|
||||
virtual void sendMessage(const QString &message) override;
|
||||
virtual void sendReply(const QString &message, const QString &replyId);
|
||||
virtual bool isMod() const override;
|
||||
bool isVip() const;
|
||||
bool isStaff() const;
|
||||
|
@ -118,6 +123,17 @@ public:
|
|||
// Cheers
|
||||
boost::optional<CheerEmote> cheerEmote(const QString &string);
|
||||
|
||||
// Replies
|
||||
/**
|
||||
* Stores the given thread in this channel.
|
||||
*
|
||||
* Note: This method not take ownership of the MessageThread; this
|
||||
* TwitchChannel instance will store a weak_ptr to the thread.
|
||||
*/
|
||||
void addReplyThread(const std::shared_ptr<MessageThread> &thread);
|
||||
const std::unordered_map<QString, std::weak_ptr<MessageThread>> &threads()
|
||||
const;
|
||||
|
||||
// Signals
|
||||
pajlada::Signals::NoArgSignal roomIdChanged;
|
||||
pajlada::Signals::NoArgSignal userStateChanged;
|
||||
|
@ -138,9 +154,6 @@ private:
|
|||
QString localizedName;
|
||||
} nameOptions;
|
||||
|
||||
protected:
|
||||
explicit TwitchChannel(const QString &channelName);
|
||||
|
||||
private:
|
||||
// Methods
|
||||
void refreshLiveStatus();
|
||||
|
@ -151,6 +164,8 @@ private:
|
|||
void refreshCheerEmotes();
|
||||
void loadRecentMessages();
|
||||
void fetchDisplayName();
|
||||
void cleanUpReplyThreads();
|
||||
void showLoginMessage();
|
||||
|
||||
void setLive(bool newLiveStatus);
|
||||
void setMod(bool value);
|
||||
|
@ -164,6 +179,8 @@ private:
|
|||
const QString &getDisplayName() const override;
|
||||
const QString &getLocalizedName() const override;
|
||||
|
||||
QString prepareMessage(const QString &message) const;
|
||||
|
||||
// Data
|
||||
const QString subscriptionUrl_;
|
||||
const QString channelUrl_;
|
||||
|
@ -171,6 +188,7 @@ private:
|
|||
int chatterCount_;
|
||||
UniqueAccess<StreamStatus> streamStatus_;
|
||||
UniqueAccess<RoomModes> roomModes_;
|
||||
std::unordered_map<QString, std::weak_ptr<MessageThread>> threads_;
|
||||
|
||||
protected:
|
||||
Atomic<std::shared_ptr<const EmoteMap>> bttvEmotes_;
|
||||
|
@ -194,6 +212,7 @@ private:
|
|||
QString lastSentMessage_;
|
||||
QObject lifetimeGuard_;
|
||||
QTimer chattersListTimer_;
|
||||
QTimer threadClearTimer_;
|
||||
QElapsedTimer titleRefreshedTimer_;
|
||||
QElapsedTimer clipCreationTimer_;
|
||||
bool isClipCreationInProgress{false};
|
||||
|
|
|
@ -121,6 +121,11 @@ std::shared_ptr<Channel> TwitchIrcServer::createChannel(
|
|||
[this, channel = channel.get()](auto &chan, auto &msg, bool &sent) {
|
||||
this->onMessageSendRequested(channel, msg, sent);
|
||||
});
|
||||
channel->sendReplySignal.connect(
|
||||
[this, channel = channel.get()](auto &chan, auto &msg, auto &replyId,
|
||||
bool &sent) {
|
||||
this->onReplySendRequested(channel, msg, replyId, sent);
|
||||
});
|
||||
|
||||
return std::shared_ptr<Channel>(channel);
|
||||
}
|
||||
|
@ -370,66 +375,90 @@ bool TwitchIrcServer::hasSeparateWriteConnection() const
|
|||
// return getSettings()->twitchSeperateWriteConnection;
|
||||
}
|
||||
|
||||
bool TwitchIrcServer::prepareToSend(TwitchChannel *channel)
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(this->lastMessageMutex_);
|
||||
|
||||
auto &lastMessage = channel->hasHighRateLimit() ? this->lastMessageMod_
|
||||
: this->lastMessagePleb_;
|
||||
size_t maxMessageCount = channel->hasHighRateLimit() ? 99 : 19;
|
||||
auto minMessageOffset = (channel->hasHighRateLimit() ? 100ms : 1100ms);
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
// check if you are sending messages too fast
|
||||
if (!lastMessage.empty() && lastMessage.back() + minMessageOffset > now)
|
||||
{
|
||||
if (this->lastErrorTimeSpeed_ + 30s < now)
|
||||
{
|
||||
auto errorMessage =
|
||||
makeSystemMessage("You are sending messages too quickly.");
|
||||
|
||||
channel->addMessage(errorMessage);
|
||||
|
||||
this->lastErrorTimeSpeed_ = now;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// remove messages older than 30 seconds
|
||||
while (!lastMessage.empty() && lastMessage.front() + 32s < now)
|
||||
{
|
||||
lastMessage.pop();
|
||||
}
|
||||
|
||||
// check if you are sending too many messages
|
||||
if (lastMessage.size() >= maxMessageCount)
|
||||
{
|
||||
if (this->lastErrorTimeAmount_ + 30s < now)
|
||||
{
|
||||
auto errorMessage =
|
||||
makeSystemMessage("You are sending too many messages.");
|
||||
|
||||
channel->addMessage(errorMessage);
|
||||
|
||||
this->lastErrorTimeAmount_ = now;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
lastMessage.push(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
|
||||
const QString &message, bool &sent)
|
||||
{
|
||||
sent = false;
|
||||
|
||||
bool canSend = this->prepareToSend(channel);
|
||||
if (!canSend)
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(this->lastMessageMutex_);
|
||||
|
||||
// std::queue<std::chrono::steady_clock::time_point>
|
||||
auto &lastMessage = channel->hasHighRateLimit()
|
||||
? this->lastMessageMod_
|
||||
: this->lastMessagePleb_;
|
||||
size_t maxMessageCount = channel->hasHighRateLimit() ? 99 : 19;
|
||||
auto minMessageOffset = (channel->hasHighRateLimit() ? 100ms : 1100ms);
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
// check if you are sending messages too fast
|
||||
if (!lastMessage.empty() && lastMessage.back() + minMessageOffset > now)
|
||||
{
|
||||
if (this->lastErrorTimeSpeed_ + 30s < now)
|
||||
{
|
||||
auto errorMessage =
|
||||
makeSystemMessage("You are sending messages too quickly.");
|
||||
|
||||
channel->addMessage(errorMessage);
|
||||
|
||||
this->lastErrorTimeSpeed_ = now;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// remove messages older than 30 seconds
|
||||
while (!lastMessage.empty() && lastMessage.front() + 32s < now)
|
||||
{
|
||||
lastMessage.pop();
|
||||
}
|
||||
|
||||
// check if you are sending too many messages
|
||||
if (lastMessage.size() >= maxMessageCount)
|
||||
{
|
||||
if (this->lastErrorTimeAmount_ + 30s < now)
|
||||
{
|
||||
auto errorMessage =
|
||||
makeSystemMessage("You are sending too many messages.");
|
||||
|
||||
channel->addMessage(errorMessage);
|
||||
|
||||
this->lastErrorTimeAmount_ = now;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastMessage.push(now);
|
||||
return;
|
||||
}
|
||||
|
||||
this->sendMessage(channel->getName(), message);
|
||||
sent = true;
|
||||
}
|
||||
|
||||
void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel,
|
||||
const QString &message,
|
||||
const QString &replyId, bool &sent)
|
||||
{
|
||||
sent = false;
|
||||
|
||||
bool canSend = this->prepareToSend(channel);
|
||||
if (!canSend)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" +
|
||||
channel->getName() + " :" + message);
|
||||
|
||||
sent = true;
|
||||
}
|
||||
|
||||
const BttvEmotes &TwitchIrcServer::getBttvEmotes() const
|
||||
{
|
||||
return this->bttv;
|
||||
|
|
|
@ -67,6 +67,10 @@ protected:
|
|||
private:
|
||||
void onMessageSendRequested(TwitchChannel *channel, const QString &message,
|
||||
bool &sent);
|
||||
void onReplySendRequested(TwitchChannel *channel, const QString &message,
|
||||
const QString &replyId, bool &sent);
|
||||
|
||||
bool prepareToSend(TwitchChannel *channel);
|
||||
|
||||
std::mutex lastMessageMutex_;
|
||||
std::queue<std::chrono::steady_clock::time_point> lastMessagePleb_;
|
||||
|
|
|
@ -48,6 +48,66 @@ const QSet<QString> zeroWidthEmotes{
|
|||
|
||||
namespace chatterino {
|
||||
|
||||
namespace {
|
||||
|
||||
QString stylizeUsername(const QString &username, const Message &message)
|
||||
{
|
||||
auto app = getApp();
|
||||
|
||||
const QString &localizedName = message.localizedName;
|
||||
bool hasLocalizedName = !localizedName.isEmpty();
|
||||
|
||||
// The full string that will be rendered in the chat widget
|
||||
QString usernameText;
|
||||
|
||||
switch (getSettings()->usernameDisplayMode.getValue())
|
||||
{
|
||||
case UsernameDisplayMode::Username: {
|
||||
usernameText = username;
|
||||
}
|
||||
break;
|
||||
|
||||
case UsernameDisplayMode::LocalizedName: {
|
||||
if (hasLocalizedName)
|
||||
{
|
||||
usernameText = localizedName;
|
||||
}
|
||||
else
|
||||
{
|
||||
usernameText = username;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
case UsernameDisplayMode::UsernameAndLocalizedName: {
|
||||
if (hasLocalizedName)
|
||||
{
|
||||
usernameText = username + "(" + localizedName + ")";
|
||||
}
|
||||
else
|
||||
{
|
||||
usernameText = username;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
auto nicknames = getCSettings().nicknames.readOnly();
|
||||
|
||||
for (const auto &nickname : *nicknames)
|
||||
{
|
||||
if (nickname.match(usernameText))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return usernameText;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TwitchMessageBuilder::TwitchMessageBuilder(
|
||||
Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage,
|
||||
const MessageParseArgs &_args)
|
||||
|
@ -138,6 +198,70 @@ MessagePtr TwitchMessageBuilder::build()
|
|||
this->message().flags.set(MessageFlag::FirstMessage);
|
||||
}
|
||||
|
||||
// reply threads
|
||||
if (this->thread_)
|
||||
{
|
||||
// set references
|
||||
this->message().replyThread = this->thread_;
|
||||
this->thread_->addToThread(this->weakOf());
|
||||
|
||||
// enable reply flag
|
||||
this->message().flags.set(MessageFlag::ReplyMessage);
|
||||
|
||||
const auto &threadRoot = this->thread_->root();
|
||||
|
||||
QString usernameText =
|
||||
stylizeUsername(threadRoot->loginName, *threadRoot.get());
|
||||
|
||||
this->emplace<ReplyCurveElement>();
|
||||
|
||||
// construct reply elements
|
||||
this->emplace<TextElement>(
|
||||
"Replying to", MessageElementFlag::RepliedMessage,
|
||||
MessageColor::System, FontStyle::ChatMediumSmall)
|
||||
->setLink({Link::ViewThread, this->thread_->rootId()});
|
||||
|
||||
this->emplace<TextElement>(
|
||||
"@" + usernameText + ":", MessageElementFlag::RepliedMessage,
|
||||
threadRoot->usernameColor, FontStyle::ChatMediumSmall)
|
||||
->setLink({Link::UserInfo, threadRoot->displayName});
|
||||
|
||||
this->emplace<SingleLineTextElement>(
|
||||
threadRoot->messageText, MessageElementFlag::RepliedMessage,
|
||||
this->textColor_, FontStyle::ChatMediumSmall)
|
||||
->setLink({Link::ViewThread, this->thread_->rootId()});
|
||||
}
|
||||
else if (this->tags.find("reply-parent-msg-id") != this->tags.end())
|
||||
{
|
||||
// Message is a reply but we couldn't find the original message.
|
||||
// Render the message using the additional reply tags
|
||||
|
||||
auto replyDisplayName = this->tags.find("reply-parent-display-name");
|
||||
auto replyBody = this->tags.find("reply-parent-msg-body");
|
||||
|
||||
if (replyDisplayName != this->tags.end() &&
|
||||
replyBody != this->tags.end())
|
||||
{
|
||||
auto name = replyDisplayName->toString();
|
||||
auto body = parseTagString(replyBody->toString());
|
||||
|
||||
this->emplace<ReplyCurveElement>();
|
||||
|
||||
this->emplace<TextElement>(
|
||||
"Replying to", MessageElementFlag::RepliedMessage,
|
||||
MessageColor::System, FontStyle::ChatMediumSmall);
|
||||
|
||||
this->emplace<TextElement>(
|
||||
"@" + name + ":", MessageElementFlag::RepliedMessage,
|
||||
this->textColor_, FontStyle::ChatMediumSmall)
|
||||
->setLink({Link::UserInfo, name});
|
||||
|
||||
this->emplace<SingleLineTextElement>(
|
||||
body, MessageElementFlag::RepliedMessage, this->textColor_,
|
||||
FontStyle::ChatMediumSmall);
|
||||
}
|
||||
}
|
||||
|
||||
// timestamp
|
||||
this->message().serverReceivedTime = calculateMessageTime(this->ircMessage);
|
||||
this->emplace<TimestampElement>(this->message().serverReceivedTime.time());
|
||||
|
@ -217,6 +341,23 @@ MessagePtr TwitchMessageBuilder::build()
|
|||
ColorProvider::instance().color(ColorType::Whisper);
|
||||
}
|
||||
|
||||
if (this->thread_)
|
||||
{
|
||||
auto &img = getResources().buttons.replyThreadDark;
|
||||
this->emplace<CircularImageElement>(Image::fromPixmap(img, 0.15), 2,
|
||||
Qt::gray,
|
||||
MessageElementFlag::ReplyButton)
|
||||
->setLink({Link::ViewThread, this->thread_->rootId()});
|
||||
}
|
||||
else
|
||||
{
|
||||
auto &img = getResources().buttons.replyDark;
|
||||
this->emplace<CircularImageElement>(Image::fromPixmap(img, 0.15), 2,
|
||||
Qt::gray,
|
||||
MessageElementFlag::ReplyButton)
|
||||
->setLink({Link::ReplyToMessage, this->message().id});
|
||||
}
|
||||
|
||||
return this->release();
|
||||
}
|
||||
|
||||
|
@ -559,53 +700,7 @@ void TwitchMessageBuilder::appendUsername()
|
|||
}
|
||||
}
|
||||
|
||||
bool hasLocalizedName = !localizedName.isEmpty();
|
||||
|
||||
// The full string that will be rendered in the chat widget
|
||||
QString usernameText;
|
||||
|
||||
switch (getSettings()->usernameDisplayMode.getValue())
|
||||
{
|
||||
case UsernameDisplayMode::Username: {
|
||||
usernameText = username;
|
||||
}
|
||||
break;
|
||||
|
||||
case UsernameDisplayMode::LocalizedName: {
|
||||
if (hasLocalizedName)
|
||||
{
|
||||
usernameText = localizedName;
|
||||
}
|
||||
else
|
||||
{
|
||||
usernameText = username;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
case UsernameDisplayMode::UsernameAndLocalizedName: {
|
||||
if (hasLocalizedName)
|
||||
{
|
||||
usernameText = username + "(" + localizedName + ")";
|
||||
}
|
||||
else
|
||||
{
|
||||
usernameText = username;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
auto nicknames = getCSettings().nicknames.readOnly();
|
||||
|
||||
for (const auto &nickname : *nicknames)
|
||||
{
|
||||
if (nickname.match(usernameText))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
QString usernameText = stylizeUsername(username, this->message());
|
||||
|
||||
if (this->args.isSentWhisper)
|
||||
{
|
||||
|
@ -1479,4 +1574,9 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix,
|
|||
}
|
||||
}
|
||||
|
||||
void TwitchMessageBuilder::setThread(std::shared_ptr<MessageThread> thread)
|
||||
{
|
||||
this->thread_ = std::move(thread);
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "common/Aliases.hpp"
|
||||
#include "common/Outcome.hpp"
|
||||
#include "messages/MessageThread.hpp"
|
||||
#include "messages/SharedMessageBuilder.hpp"
|
||||
#include "providers/twitch/ChannelPointReward.hpp"
|
||||
#include "providers/twitch/PubSubActions.hpp"
|
||||
|
@ -45,6 +46,8 @@ public:
|
|||
void triggerHighlights() override;
|
||||
MessagePtr build() override;
|
||||
|
||||
void setThread(std::shared_ptr<MessageThread> thread);
|
||||
|
||||
static void appendChannelPointRewardMessage(
|
||||
const ChannelPointReward &reward, MessageBuilder *builder, bool isMod,
|
||||
bool isBroadcaster);
|
||||
|
@ -105,6 +108,7 @@ private:
|
|||
int bitsLeft;
|
||||
bool bitsStacked = false;
|
||||
bool historicalMessage_ = false;
|
||||
std::shared_ptr<MessageThread> thread_;
|
||||
|
||||
QString userId_;
|
||||
bool senderIsBroadcaster{};
|
||||
|
|
|
@ -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};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
90
src/widgets/DraggablePopup.cpp
Normal file
90
src/widgets/DraggablePopup.cpp
Normal file
|
@ -0,0 +1,90 @@
|
|||
#include "DraggablePopup.hpp"
|
||||
|
||||
#include <QMouseEvent>
|
||||
|
||||
#include <chrono>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
namespace {
|
||||
|
||||
#ifdef Q_OS_LINUX
|
||||
FlagsEnum<BaseWindow::Flags> popupFlags{BaseWindow::Dialog,
|
||||
BaseWindow::EnableCustomFrame};
|
||||
FlagsEnum<BaseWindow::Flags> popupFlagsCloseAutomatically{
|
||||
BaseWindow::EnableCustomFrame};
|
||||
#else
|
||||
FlagsEnum<BaseWindow::Flags> popupFlags{BaseWindow::EnableCustomFrame};
|
||||
FlagsEnum<BaseWindow::Flags> popupFlagsCloseAutomatically{
|
||||
BaseWindow::EnableCustomFrame, BaseWindow::Frameless,
|
||||
BaseWindow::FramelessDraggable};
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
DraggablePopup::DraggablePopup(bool closeAutomatically, QWidget *parent)
|
||||
: BaseWindow(closeAutomatically ? popupFlagsCloseAutomatically : popupFlags,
|
||||
parent)
|
||||
, lifetimeHack_(std::make_shared<bool>(false))
|
||||
, dragTimer_(this)
|
||||
|
||||
{
|
||||
if (closeAutomatically)
|
||||
{
|
||||
this->setActionOnFocusLoss(BaseWindow::Delete);
|
||||
}
|
||||
else
|
||||
{
|
||||
this->setAttribute(Qt::WA_DeleteOnClose);
|
||||
}
|
||||
|
||||
// Update the window position according to this->requestedDragPos_ on every trigger
|
||||
this->dragTimer_.callOnTimeout(
|
||||
[this, hack = std::weak_ptr<bool>(this->lifetimeHack_)] {
|
||||
if (!hack.lock())
|
||||
{
|
||||
// Ensure this timer is never called after the object has been destroyed
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this->isMoving_)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this->move(this->requestedDragPos_);
|
||||
});
|
||||
}
|
||||
|
||||
void DraggablePopup::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::MouseButton::LeftButton)
|
||||
{
|
||||
this->dragTimer_.start(std::chrono::milliseconds(17));
|
||||
this->startPosDrag_ = event->pos();
|
||||
this->movingRelativePos = event->localPos();
|
||||
}
|
||||
}
|
||||
|
||||
void DraggablePopup::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
this->dragTimer_.stop();
|
||||
this->isMoving_ = false;
|
||||
}
|
||||
|
||||
void DraggablePopup::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
// Drag the window by the amount changed from inital position
|
||||
// Note that we provide a few *units* of deadzone so people don't
|
||||
// start dragging the window if they are slow at clicking.
|
||||
|
||||
auto movePos = event->pos() - this->startPosDrag_;
|
||||
if (this->isMoving_ || movePos.manhattanLength() > 10.0)
|
||||
{
|
||||
this->requestedDragPos_ =
|
||||
(event->screenPos() - this->movingRelativePos).toPoint();
|
||||
this->isMoving_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
47
src/widgets/DraggablePopup.hpp
Normal file
47
src/widgets/DraggablePopup.hpp
Normal file
|
@ -0,0 +1,47 @@
|
|||
#pragma once
|
||||
|
||||
#include "widgets/BaseWindow.hpp"
|
||||
|
||||
#include <QPoint>
|
||||
#include <QTimer>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
class DraggablePopup : public BaseWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/// DraggablePopup implements the automatic dragging behavior when clicking
|
||||
/// anywhere in the window (that doesn't have some other widget).
|
||||
///
|
||||
/// If closeAutomatically is set, the window will close when losing focus,
|
||||
/// and the window will be frameless.
|
||||
DraggablePopup(bool closeAutomatically, QWidget *parent);
|
||||
|
||||
protected:
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
|
||||
// lifetimeHack_ is used to check that the window hasn't been destroyed yet
|
||||
std::shared_ptr<bool> lifetimeHack_;
|
||||
|
||||
private:
|
||||
// isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_)
|
||||
bool isMoving_ = false;
|
||||
|
||||
// startPosDrag_ is the coordinates where the user originally pressed the mouse button down to start dragging
|
||||
QPoint startPosDrag_;
|
||||
|
||||
// requestDragPos_ is the final screen coordinates where the widget should be moved to.
|
||||
// Takes the relative position of where the user originally clicked the widget into account
|
||||
QPoint requestedDragPos_;
|
||||
|
||||
// dragTimer_ is called ~60 times per second once the user has initiated dragging
|
||||
QTimer dragTimer_;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
193
src/widgets/dialogs/ReplyThreadPopup.cpp
Normal file
193
src/widgets/dialogs/ReplyThreadPopup.cpp
Normal file
|
@ -0,0 +1,193 @@
|
|||
#include "ReplyThreadPopup.hpp"
|
||||
|
||||
#include "Application.hpp"
|
||||
#include "common/Channel.hpp"
|
||||
#include "common/QLogging.hpp"
|
||||
#include "controllers/hotkeys/HotkeyController.hpp"
|
||||
#include "messages/MessageThread.hpp"
|
||||
#include "util/LayoutCreator.hpp"
|
||||
#include "widgets/Scrollbar.hpp"
|
||||
#include "widgets/helper/ChannelView.hpp"
|
||||
#include "widgets/helper/ResizingTextEdit.hpp"
|
||||
#include "widgets/splits/Split.hpp"
|
||||
#include "widgets/splits/SplitInput.hpp"
|
||||
|
||||
const QString TEXT_TITLE("Reply Thread - @%1 in #%2");
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent,
|
||||
Split *split)
|
||||
: DraggablePopup(closeAutomatically, parent)
|
||||
, split_(split)
|
||||
{
|
||||
this->setWindowTitle(QStringLiteral("Reply Thread"));
|
||||
this->setStayInScreenRect(true);
|
||||
|
||||
HotkeyController::HotkeyMap actions{
|
||||
{"delete",
|
||||
[this](std::vector<QString>) -> QString {
|
||||
this->deleteLater();
|
||||
return "";
|
||||
}},
|
||||
{"scrollPage",
|
||||
[this](std::vector<QString> arguments) -> QString {
|
||||
if (arguments.empty())
|
||||
{
|
||||
qCWarning(chatterinoHotkeys)
|
||||
<< "scrollPage hotkey called without arguments!";
|
||||
return "scrollPage hotkey called without arguments!";
|
||||
}
|
||||
auto direction = arguments.at(0);
|
||||
|
||||
auto &scrollbar = this->ui_.threadView->getScrollBar();
|
||||
if (direction == "up")
|
||||
{
|
||||
scrollbar.offset(-scrollbar.getLargeChange());
|
||||
}
|
||||
else if (direction == "down")
|
||||
{
|
||||
scrollbar.offset(scrollbar.getLargeChange());
|
||||
}
|
||||
else
|
||||
{
|
||||
qCWarning(chatterinoHotkeys) << "Unknown scroll direction";
|
||||
}
|
||||
return "";
|
||||
}},
|
||||
|
||||
// these actions make no sense in the context of a reply thread, so they aren't implemented
|
||||
{"execModeratorAction", nullptr},
|
||||
{"reject", nullptr},
|
||||
{"accept", nullptr},
|
||||
{"openTab", nullptr},
|
||||
{"search", nullptr},
|
||||
};
|
||||
|
||||
this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory(
|
||||
HotkeyCategory::PopupWindow, actions, this);
|
||||
|
||||
auto layout = LayoutCreator<QWidget>(this->getLayoutContainer())
|
||||
.setLayoutType<QVBoxLayout>();
|
||||
|
||||
// initialize UI
|
||||
this->ui_.threadView =
|
||||
new ChannelView(this, this->split_, ChannelView::Context::ReplyThread);
|
||||
this->ui_.threadView->setMinimumSize(400, 100);
|
||||
this->ui_.threadView->setSizePolicy(QSizePolicy::Expanding,
|
||||
QSizePolicy::Expanding);
|
||||
this->ui_.threadView->mouseDown.connect([this](QMouseEvent *) {
|
||||
this->giveFocus(Qt::MouseFocusReason);
|
||||
});
|
||||
|
||||
// Create SplitInput with inline replying disabled
|
||||
this->ui_.replyInput = new SplitInput(this, this->split_, false);
|
||||
|
||||
this->bSignals_.emplace_back(
|
||||
getApp()->accounts->twitch.currentUserChanged.connect([this] {
|
||||
this->updateInputUI();
|
||||
}));
|
||||
|
||||
layout->setSpacing(0);
|
||||
// provide draggable margin if frameless
|
||||
layout->setMargin(closeAutomatically ? 15 : 1);
|
||||
layout->addWidget(this->ui_.threadView, 1);
|
||||
layout->addWidget(this->ui_.replyInput);
|
||||
}
|
||||
|
||||
void ReplyThreadPopup::setThread(std::shared_ptr<MessageThread> thread)
|
||||
{
|
||||
this->thread_ = std::move(thread);
|
||||
this->ui_.replyInput->setReply(this->thread_);
|
||||
this->addMessagesFromThread();
|
||||
this->updateInputUI();
|
||||
}
|
||||
|
||||
void ReplyThreadPopup::addMessagesFromThread()
|
||||
{
|
||||
this->ui_.threadView->clearMessages();
|
||||
if (!this->thread_)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const auto &sourceChannel = this->split_->getChannel();
|
||||
this->setWindowTitle(TEXT_TITLE.arg(this->thread_->root()->loginName,
|
||||
sourceChannel->getName()));
|
||||
|
||||
ChannelPtr virtualChannel;
|
||||
if (sourceChannel->isTwitchChannel())
|
||||
{
|
||||
virtualChannel =
|
||||
std::make_shared<TwitchChannel>(sourceChannel->getName());
|
||||
}
|
||||
else
|
||||
{
|
||||
virtualChannel = std::make_shared<Channel>(sourceChannel->getName(),
|
||||
Channel::Type::None);
|
||||
}
|
||||
|
||||
this->ui_.threadView->setChannel(virtualChannel);
|
||||
this->ui_.threadView->setSourceChannel(sourceChannel);
|
||||
|
||||
virtualChannel->addMessage(this->thread_->root());
|
||||
for (const auto &msgRef : this->thread_->replies())
|
||||
{
|
||||
if (auto msg = msgRef.lock())
|
||||
{
|
||||
virtualChannel->addMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
this->messageConnection_ =
|
||||
std::make_unique<pajlada::Signals::ScopedConnection>(
|
||||
sourceChannel->messageAppended.connect(
|
||||
[this, virtualChannel](MessagePtr &message, auto) {
|
||||
if (message->replyThread == this->thread_)
|
||||
{
|
||||
// same reply thread, add message
|
||||
virtualChannel->addMessage(message);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void ReplyThreadPopup::updateInputUI()
|
||||
{
|
||||
auto channel = this->split_->getChannel();
|
||||
// Bail out if not a twitch channel.
|
||||
// Special twitch channels will hide their reply input box.
|
||||
if (!channel || !channel->isTwitchChannel())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this->ui_.replyInput->setVisible(channel->isWritable());
|
||||
|
||||
auto user = getApp()->accounts->twitch.getCurrent();
|
||||
QString placeholderText;
|
||||
|
||||
if (user->isAnon())
|
||||
{
|
||||
placeholderText = QStringLiteral("Log in to send messages...");
|
||||
}
|
||||
else
|
||||
{
|
||||
placeholderText =
|
||||
QStringLiteral("Reply as %1...")
|
||||
.arg(getApp()->accounts->twitch.getCurrent()->getUserName());
|
||||
}
|
||||
|
||||
this->ui_.replyInput->setPlaceholderText(placeholderText);
|
||||
}
|
||||
|
||||
void ReplyThreadPopup::giveFocus(Qt::FocusReason reason)
|
||||
{
|
||||
this->ui_.replyInput->giveFocus(reason);
|
||||
}
|
||||
|
||||
void ReplyThreadPopup::focusInEvent(QFocusEvent *event)
|
||||
{
|
||||
this->giveFocus(event->reason());
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
48
src/widgets/dialogs/ReplyThreadPopup.hpp
Normal file
48
src/widgets/dialogs/ReplyThreadPopup.hpp
Normal file
|
@ -0,0 +1,48 @@
|
|||
#pragma once
|
||||
|
||||
#include "ForwardDecl.hpp"
|
||||
#include "widgets/DraggablePopup.hpp"
|
||||
|
||||
#include <boost/signals2.hpp>
|
||||
#include <pajlada/signals/scoped-connection.hpp>
|
||||
#include <pajlada/signals/signal.hpp>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
class MessageThread;
|
||||
class Split;
|
||||
class SplitInput;
|
||||
|
||||
class ReplyThreadPopup final : public DraggablePopup
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ReplyThreadPopup(bool closeAutomatically, QWidget *parent, Split *split);
|
||||
|
||||
void setThread(std::shared_ptr<MessageThread> thread);
|
||||
void giveFocus(Qt::FocusReason reason);
|
||||
|
||||
protected:
|
||||
void focusInEvent(QFocusEvent *event) override;
|
||||
|
||||
private:
|
||||
void addMessagesFromThread();
|
||||
void updateInputUI();
|
||||
|
||||
// The message reply thread
|
||||
std::shared_ptr<MessageThread> thread_;
|
||||
// The channel that the reply thread is in
|
||||
ChannelPtr channel_;
|
||||
Split *split_;
|
||||
|
||||
struct {
|
||||
ChannelView *threadView = nullptr;
|
||||
SplitInput *replyInput = nullptr;
|
||||
} ui_;
|
||||
|
||||
std::unique_ptr<pajlada::Signals::ScopedConnection> messageConnection_;
|
||||
std::vector<boost::signals2::scoped_connection> bSignals_;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
|
@ -91,8 +91,16 @@ namespace {
|
|||
LimitedQueueSnapshot<MessagePtr> snapshot =
|
||||
channel->getMessageSnapshot();
|
||||
|
||||
ChannelPtr channelPtr(
|
||||
new Channel(channel->getName(), Channel::Type::None));
|
||||
ChannelPtr channelPtr;
|
||||
if (channel->isTwitchChannel())
|
||||
{
|
||||
channelPtr = std::make_shared<TwitchChannel>(channel->getName());
|
||||
}
|
||||
else
|
||||
{
|
||||
channelPtr = std::make_shared<Channel>(channel->getName(),
|
||||
Channel::Type::None);
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < snapshot.size(); i++)
|
||||
{
|
||||
|
@ -118,33 +126,14 @@ namespace {
|
|||
|
||||
} // namespace
|
||||
|
||||
#ifdef Q_OS_LINUX
|
||||
FlagsEnum<BaseWindow::Flags> userInfoPopupFlags{BaseWindow::Dialog,
|
||||
BaseWindow::EnableCustomFrame};
|
||||
FlagsEnum<BaseWindow::Flags> userInfoPopupFlagsCloseAutomatically{
|
||||
BaseWindow::EnableCustomFrame};
|
||||
#else
|
||||
FlagsEnum<BaseWindow::Flags> userInfoPopupFlags{BaseWindow::EnableCustomFrame};
|
||||
FlagsEnum<BaseWindow::Flags> userInfoPopupFlagsCloseAutomatically{
|
||||
BaseWindow::EnableCustomFrame, BaseWindow::Frameless,
|
||||
BaseWindow::FramelessDraggable};
|
||||
#endif
|
||||
|
||||
UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
|
||||
: BaseWindow(closeAutomatically ? userInfoPopupFlagsCloseAutomatically
|
||||
: userInfoPopupFlags,
|
||||
parent)
|
||||
, hack_(new bool)
|
||||
, dragTimer_(this)
|
||||
UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent,
|
||||
Split *split)
|
||||
: DraggablePopup(closeAutomatically, parent)
|
||||
, split_(split)
|
||||
{
|
||||
this->setWindowTitle("Usercard");
|
||||
this->setStayInScreenRect(true);
|
||||
|
||||
if (closeAutomatically)
|
||||
this->setActionOnFocusLoss(BaseWindow::Delete);
|
||||
else
|
||||
this->setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
HotkeyController::HotkeyMap actions{
|
||||
{"delete",
|
||||
[this](std::vector<QString>) -> QString {
|
||||
|
@ -498,7 +487,8 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
|
|||
this->ui_.noMessagesLabel = new Label("No recent messages");
|
||||
this->ui_.noMessagesLabel->setVisible(false);
|
||||
|
||||
this->ui_.latestMessages = new ChannelView(this);
|
||||
this->ui_.latestMessages =
|
||||
new ChannelView(this, this->split_, ChannelView::Context::UserCard);
|
||||
this->ui_.latestMessages->setMinimumSize(400, 275);
|
||||
this->ui_.latestMessages->setSizePolicy(QSizePolicy::Expanding,
|
||||
QSizePolicy::Expanding);
|
||||
|
@ -510,21 +500,6 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
|
|||
|
||||
this->installEvents();
|
||||
this->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Policy::Ignored);
|
||||
|
||||
this->dragTimer_.callOnTimeout(
|
||||
[this, hack = std::weak_ptr<bool>(this->hack_)] {
|
||||
if (!hack.lock())
|
||||
{
|
||||
// Ensure this timer is never called after the object has been destroyed
|
||||
return;
|
||||
}
|
||||
if (!this->isMoving_)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this->move(this->requestedDragPos_);
|
||||
});
|
||||
}
|
||||
|
||||
void UserInfoPopup::themeChangedEvent()
|
||||
|
@ -550,36 +525,6 @@ void UserInfoPopup::scaleChangedEvent(float /*scale*/)
|
|||
});
|
||||
}
|
||||
|
||||
void UserInfoPopup::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::MouseButton::LeftButton)
|
||||
{
|
||||
this->dragTimer_.start(std::chrono::milliseconds(17));
|
||||
this->startPosDrag_ = event->pos();
|
||||
this->movingRelativePos = event->localPos();
|
||||
}
|
||||
}
|
||||
|
||||
void UserInfoPopup::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
this->dragTimer_.stop();
|
||||
this->isMoving_ = false;
|
||||
}
|
||||
|
||||
void UserInfoPopup::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
// Drag the window by the amount changed from inital position
|
||||
// Note that we provide a few *units* of deadzone so people don't
|
||||
// start dragging the window if they are slow at clicking.
|
||||
auto movePos = event->pos() - this->startPosDrag_;
|
||||
if (this->isMoving_ || movePos.manhattanLength() > 10.0)
|
||||
{
|
||||
this->requestedDragPos_ =
|
||||
(event->screenPos() - this->movingRelativePos).toPoint();
|
||||
this->isMoving_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void UserInfoPopup::installEvents()
|
||||
{
|
||||
std::shared_ptr<bool> ignoreNext = std::make_shared<bool>(false);
|
||||
|
@ -765,7 +710,7 @@ void UserInfoPopup::updateLatestMessages()
|
|||
|
||||
void UserInfoPopup::updateUserData()
|
||||
{
|
||||
std::weak_ptr<bool> hack = this->hack_;
|
||||
std::weak_ptr<bool> hack = this->lifetimeHack_;
|
||||
auto currentUser = getApp()->accounts->twitch.getCurrent();
|
||||
|
||||
const auto onUserFetchFailed = [this, hack] {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
#include "widgets/BaseWindow.hpp"
|
||||
#include "widgets/DraggablePopup.hpp"
|
||||
#include "widgets/helper/ChannelView.hpp"
|
||||
|
||||
#include <pajlada/signals/scoped-connection.hpp>
|
||||
|
@ -16,12 +16,13 @@ class Channel;
|
|||
using ChannelPtr = std::shared_ptr<Channel>;
|
||||
class Label;
|
||||
|
||||
class UserInfoPopup final : public BaseWindow
|
||||
class UserInfoPopup final : public DraggablePopup
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
UserInfoPopup(bool closeAutomatically, QWidget *parent);
|
||||
UserInfoPopup(bool closeAutomatically, QWidget *parent,
|
||||
Split *split = nullptr);
|
||||
|
||||
void setData(const QString &name, const ChannelPtr &channel);
|
||||
void setData(const QString &name, const ChannelPtr &contextChannel,
|
||||
|
@ -30,9 +31,6 @@ public:
|
|||
protected:
|
||||
virtual void themeChangedEvent() override;
|
||||
virtual void scaleChangedEvent(float scale) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
|
||||
private:
|
||||
void installEvents();
|
||||
|
@ -43,6 +41,8 @@ private:
|
|||
bool isMod_;
|
||||
bool isBroadcaster_;
|
||||
|
||||
Split *split_;
|
||||
|
||||
QString userName_;
|
||||
QString userId_;
|
||||
QString avatarUrl_;
|
||||
|
@ -51,25 +51,10 @@ private:
|
|||
// The channel the messages are rendered from (e.g. #forsen). Can be a special channel, but will try to not be where possible.
|
||||
ChannelPtr underlyingChannel_;
|
||||
|
||||
// isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_)
|
||||
bool isMoving_ = false;
|
||||
|
||||
// startPosDrag_ is the coordinates where the user originally pressed the mouse button down to start dragging
|
||||
QPoint startPosDrag_;
|
||||
|
||||
// requestDragPos_ is the final screen coordinates where the widget should be moved to.
|
||||
// Takes the relative position of where the user originally clicked the widget into account
|
||||
QPoint requestedDragPos_;
|
||||
|
||||
// dragTimer_ is called ~60 times per second once the user has initiated dragging
|
||||
QTimer dragTimer_;
|
||||
|
||||
pajlada::Signals::NoArgSignal userStateChanged_;
|
||||
|
||||
std::unique_ptr<pajlada::Signals::ScopedConnection> refreshConnection_;
|
||||
|
||||
std::shared_ptr<bool> hack_;
|
||||
|
||||
struct {
|
||||
Button *avatarButton = nullptr;
|
||||
Button *localizedNameCopyButton = nullptr;
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
#include "widgets/Scrollbar.hpp"
|
||||
#include "widgets/TooltipWidget.hpp"
|
||||
#include "widgets/Window.hpp"
|
||||
#include "widgets/dialogs/ReplyThreadPopup.hpp"
|
||||
#include "widgets/dialogs/SettingsDialog.hpp"
|
||||
#include "widgets/dialogs/UserInfoPopup.hpp"
|
||||
#include "widgets/helper/EffectLabel.hpp"
|
||||
|
@ -119,9 +120,11 @@ namespace {
|
|||
}
|
||||
} // namespace
|
||||
|
||||
ChannelView::ChannelView(BaseWidget *parent)
|
||||
ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context)
|
||||
: BaseWidget(parent)
|
||||
, scrollBar_(new Scrollbar(this))
|
||||
, split_(split)
|
||||
, context_(context)
|
||||
{
|
||||
this->setMouseTracking(true);
|
||||
|
||||
|
@ -160,7 +163,7 @@ ChannelView::ChannelView(BaseWidget *parent)
|
|||
// and tabbing to it from another widget. I don't currently know
|
||||
// of any place where you can, or where it would make sense,
|
||||
// to tab to a ChannelVieChannelView
|
||||
this->setFocusPolicy(Qt::FocusPolicy::StrongFocus);
|
||||
this->setFocusPolicy(Qt::FocusPolicy::ClickFocus);
|
||||
}
|
||||
|
||||
void ChannelView::initializeLayout()
|
||||
|
@ -1019,6 +1022,17 @@ MessageElementFlags ChannelView::getFlags() const
|
|||
if (this->sourceChannel_ == app->twitch->mentionsChannel)
|
||||
flags.set(MessageElementFlag::ChannelName);
|
||||
|
||||
if (this->context_ == Context::ReplyThread)
|
||||
{
|
||||
// Don't show inline replies within the ReplyThreadPopup
|
||||
flags.unset(MessageElementFlag::RepliedMessage);
|
||||
}
|
||||
|
||||
if (!this->canReplyToMessages())
|
||||
{
|
||||
flags.unset(MessageElementFlag::ReplyButton);
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
|
@ -1983,10 +1997,27 @@ void ChannelView::addMessageContextMenuItems(
|
|||
|
||||
menu.addAction("Copy full message", [layout] {
|
||||
QString copyString;
|
||||
layout->addSelectionText(copyString);
|
||||
layout->addSelectionText(copyString, 0, INT_MAX,
|
||||
CopyMode::EverythingButReplies);
|
||||
|
||||
crossPlatformCopy(copyString);
|
||||
});
|
||||
|
||||
// Only display reply option where it makes sense
|
||||
if (this->canReplyToMessages() && layout->isReplyable())
|
||||
{
|
||||
const auto &messagePtr = layout->getMessagePtr();
|
||||
menu.addAction("Reply to message", [this, &messagePtr] {
|
||||
this->setInputReply(messagePtr);
|
||||
});
|
||||
|
||||
if (messagePtr->replyThread != nullptr)
|
||||
{
|
||||
menu.addAction("View thread", [this, &messagePtr] {
|
||||
this->showReplyThreadPopup(messagePtr);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChannelView::addTwitchLinkContextMenuItems(
|
||||
|
@ -2224,8 +2255,8 @@ void ChannelView::showUserInfoPopup(const QString &userName,
|
|||
{
|
||||
auto *userCardParent =
|
||||
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()));
|
||||
auto *userPopup =
|
||||
new UserInfoPopup(getSettings()->autoCloseUserPopup, userCardParent);
|
||||
auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup,
|
||||
userCardParent, this->split_);
|
||||
|
||||
auto contextChannel =
|
||||
getApp()->twitch->getChannelOrEmpty(alternativePopoutChannel);
|
||||
|
@ -2351,6 +2382,14 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link,
|
|||
this->underlyingChannel_.get()->reconnect();
|
||||
}
|
||||
break;
|
||||
case Link::ReplyToMessage: {
|
||||
this->setInputReply(layout->getMessagePtr());
|
||||
}
|
||||
break;
|
||||
case Link::ViewThread: {
|
||||
this->showReplyThreadPopup(layout->getMessagePtr());
|
||||
}
|
||||
break;
|
||||
|
||||
default:;
|
||||
}
|
||||
|
@ -2473,4 +2512,106 @@ void ChannelView::scrollUpdateRequested()
|
|||
this->scrollBar_->offset(multiplier * offset);
|
||||
}
|
||||
|
||||
void ChannelView::setInputReply(const MessagePtr &message)
|
||||
{
|
||||
if (message == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
std::shared_ptr<MessageThread> thread;
|
||||
|
||||
if (message->replyThread == nullptr)
|
||||
{
|
||||
auto getThread = [&](TwitchChannel *tc) {
|
||||
auto threadIt = tc->threads().find(message->id);
|
||||
if (threadIt != tc->threads().end() && !threadIt->second.expired())
|
||||
{
|
||||
return threadIt->second.lock();
|
||||
}
|
||||
else
|
||||
{
|
||||
auto thread = std::make_shared<MessageThread>(message);
|
||||
tc->addReplyThread(thread);
|
||||
return thread;
|
||||
}
|
||||
};
|
||||
|
||||
if (auto tc =
|
||||
dynamic_cast<TwitchChannel *>(this->underlyingChannel_.get()))
|
||||
{
|
||||
thread = getThread(tc);
|
||||
}
|
||||
else if (auto tc = dynamic_cast<TwitchChannel *>(this->channel_.get()))
|
||||
{
|
||||
thread = getThread(tc);
|
||||
}
|
||||
else
|
||||
{
|
||||
qCWarning(chatterinoCommon) << "Failed to create new reply thread";
|
||||
// Unable to create new reply thread.
|
||||
// TODO(dnsge): Should probably notify user?
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
thread = message->replyThread;
|
||||
}
|
||||
|
||||
this->split_->setInputReply(thread);
|
||||
}
|
||||
|
||||
void ChannelView::showReplyThreadPopup(const MessagePtr &message)
|
||||
{
|
||||
if (message == nullptr || message->replyThread == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto popupParent =
|
||||
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()));
|
||||
auto popup = new ReplyThreadPopup(getSettings()->autoCloseThreadPopup,
|
||||
popupParent, this->split_);
|
||||
|
||||
popup->setThread(message->replyThread);
|
||||
|
||||
QPoint offset(int(150 * this->scale()), int(70 * this->scale()));
|
||||
popup->move(QCursor::pos() - offset);
|
||||
popup->show();
|
||||
popup->giveFocus(Qt::MouseFocusReason);
|
||||
}
|
||||
|
||||
ChannelView::Context ChannelView::getContext() const
|
||||
{
|
||||
return this->context_;
|
||||
}
|
||||
|
||||
bool ChannelView::canReplyToMessages() const
|
||||
{
|
||||
if (this->context_ == ChannelView::Context::ReplyThread ||
|
||||
this->context_ == ChannelView::Context::Search)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->channel_ == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this->channel_->isTwitchChannel())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->channel_->getType() == Channel::Type::TwitchWhispers ||
|
||||
this->channel_->getType() == Channel::Type::TwitchLive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
|
@ -39,6 +39,7 @@ class Scrollbar;
|
|||
class EffectLabel;
|
||||
struct Link;
|
||||
class MessageLayoutElement;
|
||||
class Split;
|
||||
|
||||
enum class PauseReason {
|
||||
Mouse,
|
||||
|
@ -61,7 +62,15 @@ class ChannelView final : public BaseWidget
|
|||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChannelView(BaseWidget *parent = nullptr);
|
||||
enum class Context {
|
||||
None,
|
||||
UserCard,
|
||||
ReplyThread,
|
||||
Search,
|
||||
};
|
||||
|
||||
explicit ChannelView(BaseWidget *parent = nullptr, Split *split = nullptr,
|
||||
Context context = Context::None);
|
||||
|
||||
void queueUpdate();
|
||||
Scrollbar &getScrollBar();
|
||||
|
@ -99,6 +108,8 @@ public:
|
|||
|
||||
void clearMessages();
|
||||
|
||||
Context getContext() const;
|
||||
|
||||
/**
|
||||
* @brief Creates and shows a UserInfoPopup dialog
|
||||
*
|
||||
|
@ -196,6 +207,10 @@ private:
|
|||
void enableScrolling(const QPointF &scrollStart);
|
||||
void disableScrolling();
|
||||
|
||||
void setInputReply(const MessagePtr &message);
|
||||
void showReplyThreadPopup(const MessagePtr &message);
|
||||
bool canReplyToMessages() const;
|
||||
|
||||
QTimer *layoutCooldown_;
|
||||
bool layoutQueued_;
|
||||
|
||||
|
@ -221,6 +236,7 @@ private:
|
|||
ChannelPtr channel_ = nullptr;
|
||||
ChannelPtr underlyingChannel_ = nullptr;
|
||||
ChannelPtr sourceChannel_ = nullptr;
|
||||
Split *split_ = nullptr;
|
||||
|
||||
Scrollbar *scrollBar_;
|
||||
EffectLabel *goToBottom_;
|
||||
|
@ -264,6 +280,8 @@ private:
|
|||
Selection selection_;
|
||||
bool selecting_ = false;
|
||||
|
||||
const Context context_;
|
||||
|
||||
LimitedQueue<MessageLayoutPtr> messages_;
|
||||
|
||||
pajlada::Signals::SignalHolder signalHolder_;
|
||||
|
|
|
@ -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_);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
#include "messages/LimitedQueueSnapshot.hpp"
|
||||
#include "messages/search/MessagePredicate.hpp"
|
||||
#include "widgets/BasePopup.hpp"
|
||||
#include "widgets/splits/Split.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
|
@ -15,7 +16,7 @@ namespace chatterino {
|
|||
class SearchPopup : public BasePopup
|
||||
{
|
||||
public:
|
||||
SearchPopup(QWidget *parent);
|
||||
SearchPopup(QWidget *parent, Split *split = nullptr);
|
||||
|
||||
virtual void addChannel(ChannelView &channel);
|
||||
|
||||
|
@ -58,6 +59,7 @@ private:
|
|||
QLineEdit *searchInput_{};
|
||||
ChannelView *channelView_{};
|
||||
QString channelName_{};
|
||||
Split *split_ = nullptr;
|
||||
QList<std::reference_wrapper<ChannelView>> searchChannels_;
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
#include "controllers/commands/CommandController.hpp"
|
||||
#include "controllers/hotkeys/HotkeyController.hpp"
|
||||
#include "controllers/notifications/NotificationController.hpp"
|
||||
#include "messages/MessageThread.hpp"
|
||||
#include "providers/twitch/EmoteValue.hpp"
|
||||
#include "providers/twitch/TwitchChannel.hpp"
|
||||
#include "providers/twitch/TwitchIrcServer.hpp"
|
||||
|
@ -85,7 +86,7 @@ Split::Split(QWidget *parent)
|
|||
, channel_(Channel::getEmpty())
|
||||
, vbox_(new QVBoxLayout(this))
|
||||
, header_(new SplitHeader(this))
|
||||
, view_(new ChannelView(this))
|
||||
, view_(new ChannelView(this, this))
|
||||
, input_(new SplitInput(this))
|
||||
, overlay_(new SplitOverlay(this))
|
||||
{
|
||||
|
@ -1164,7 +1165,7 @@ const QList<QUuid> Split::getFilters() const
|
|||
|
||||
void Split::showSearch(bool singleChannel)
|
||||
{
|
||||
auto *popup = new SearchPopup(this);
|
||||
auto *popup = new SearchPopup(this, this);
|
||||
popup->setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
if (singleChannel)
|
||||
|
@ -1269,6 +1270,11 @@ void Split::drag()
|
|||
}
|
||||
}
|
||||
|
||||
void Split::setInputReply(const std::shared_ptr<MessageThread> &reply)
|
||||
{
|
||||
this->input_->setReply(reply);
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
|
||||
QDebug operator<<(QDebug dbg, const chatterino::Split &split)
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
namespace chatterino {
|
||||
|
||||
class ChannelView;
|
||||
class MessageThread;
|
||||
class SplitHeader;
|
||||
class SplitInput;
|
||||
class SplitContainer;
|
||||
|
@ -74,6 +75,8 @@ public:
|
|||
|
||||
void setContainer(SplitContainer *container);
|
||||
|
||||
void setInputReply(const std::shared_ptr<MessageThread> &reply);
|
||||
|
||||
static pajlada::Signals::Signal<Qt::KeyboardModifiers>
|
||||
modifierStatusChanged;
|
||||
static Qt::KeyboardModifiers modifierStatus;
|
||||
|
|
|
@ -23,15 +23,24 @@
|
|||
#include "widgets/splits/SplitContainer.hpp"
|
||||
#include "widgets/splits/SplitInput.hpp"
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QCompleter>
|
||||
#include <QPainter>
|
||||
|
||||
namespace chatterino {
|
||||
const int TWITCH_MESSAGE_LIMIT = 500;
|
||||
|
||||
SplitInput::SplitInput(Split *_chatWidget)
|
||||
: BaseWidget(_chatWidget)
|
||||
SplitInput::SplitInput(Split *_chatWidget, bool enableInlineReplying)
|
||||
: SplitInput(_chatWidget, _chatWidget, enableInlineReplying)
|
||||
{
|
||||
}
|
||||
|
||||
SplitInput::SplitInput(QWidget *parent, Split *_chatWidget,
|
||||
bool enableInlineReplying)
|
||||
: BaseWidget(parent)
|
||||
, split_(_chatWidget)
|
||||
, enableInlineReplying_(enableInlineReplying)
|
||||
{
|
||||
this->installEventFilter(this);
|
||||
this->initLayout();
|
||||
|
@ -66,17 +75,43 @@ void SplitInput::initLayout()
|
|||
LayoutCreator<SplitInput> layoutCreator(this);
|
||||
|
||||
auto layout =
|
||||
layoutCreator.setLayoutType<QHBoxLayout>().withoutMargin().assign(
|
||||
&this->ui_.hbox);
|
||||
layoutCreator.setLayoutType<QVBoxLayout>().withoutMargin().assign(
|
||||
&this->ui_.vbox);
|
||||
|
||||
// reply label stuff
|
||||
auto replyWrapper =
|
||||
layout.emplace<QWidget>().assign(&this->ui_.replyWrapper);
|
||||
this->ui_.replyWrapper->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
auto replyHbox = replyWrapper.emplace<QHBoxLayout>().withoutMargin().assign(
|
||||
&this->ui_.replyHbox);
|
||||
|
||||
auto replyLabel = replyHbox.emplace<QLabel>().assign(&this->ui_.replyLabel);
|
||||
replyLabel->setAlignment(Qt::AlignLeft);
|
||||
replyLabel->setFont(
|
||||
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
||||
|
||||
replyHbox->addStretch(1);
|
||||
|
||||
auto replyCancelButton = replyHbox.emplace<EffectLabel>(nullptr, 4)
|
||||
.assign(&this->ui_.cancelReplyButton);
|
||||
replyCancelButton->getLabel().setTextFormat(Qt::RichText);
|
||||
|
||||
replyCancelButton->hide();
|
||||
replyLabel->hide();
|
||||
|
||||
// hbox for input, right box
|
||||
auto hboxLayout =
|
||||
layout.emplace<QHBoxLayout>().withoutMargin().assign(&this->ui_.hbox);
|
||||
|
||||
// input
|
||||
auto textEdit =
|
||||
layout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
|
||||
hboxLayout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
|
||||
connect(textEdit.getElement(), &ResizingTextEdit::textChanged, this,
|
||||
&SplitInput::editTextChanged);
|
||||
|
||||
// right box
|
||||
auto box = layout.emplace<QVBoxLayout>().withoutMargin();
|
||||
auto box = hboxLayout.emplace<QVBoxLayout>().withoutMargin();
|
||||
box->setSpacing(0);
|
||||
{
|
||||
auto textEditLength =
|
||||
|
@ -102,6 +137,8 @@ void SplitInput::initLayout()
|
|||
this->managedConnections_.managedConnect(app->fonts->fontChanged, [=]() {
|
||||
this->ui_.textEdit->setFont(
|
||||
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
||||
this->ui_.replyLabel->setFont(
|
||||
app->fonts->getFont(FontStyle::ChatMediumBold, this->scale()));
|
||||
});
|
||||
|
||||
// open emote popup
|
||||
|
@ -109,6 +146,12 @@ void SplitInput::initLayout()
|
|||
this->openEmotePopup();
|
||||
});
|
||||
|
||||
// clear input and remove reply thread
|
||||
QObject::connect(this->ui_.cancelReplyButton, &EffectLabel::leftClicked,
|
||||
[=] {
|
||||
this->clearInput();
|
||||
});
|
||||
|
||||
// clear channelview selection when selecting in the input
|
||||
QObject::connect(this->ui_.textEdit, &QTextEdit::copyAvailable,
|
||||
[this](bool available) {
|
||||
|
@ -129,8 +172,10 @@ void SplitInput::initLayout()
|
|||
|
||||
void SplitInput::scaleChangedEvent(float scale)
|
||||
{
|
||||
// update the icon size of the emote button
|
||||
auto app = getApp();
|
||||
// update the icon size of the buttons
|
||||
this->updateEmoteButton();
|
||||
this->updateCancelReplyButton();
|
||||
|
||||
// set maximum height
|
||||
if (!this->hidden)
|
||||
|
@ -138,9 +183,11 @@ void SplitInput::scaleChangedEvent(float scale)
|
|||
this->setMaximumHeight(this->scaledMaxHeight());
|
||||
}
|
||||
this->ui_.textEdit->setFont(
|
||||
getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
||||
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
||||
this->ui_.textEditLength->setFont(
|
||||
getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
||||
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
||||
this->ui_.replyLabel->setFont(
|
||||
app->fonts->getFont(FontStyle::ChatMediumBold, this->scale()));
|
||||
}
|
||||
|
||||
void SplitInput::themeChangedEvent()
|
||||
|
@ -155,6 +202,7 @@ void SplitInput::themeChangedEvent()
|
|||
#endif
|
||||
|
||||
this->updateEmoteButton();
|
||||
this->updateCancelReplyButton();
|
||||
this->ui_.textEditLength->setPalette(palette);
|
||||
|
||||
this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet);
|
||||
|
@ -162,10 +210,19 @@ void SplitInput::themeChangedEvent()
|
|||
this->ui_.textEdit->setPalette(placeholderPalette);
|
||||
#endif
|
||||
|
||||
this->ui_.hbox->setMargin(
|
||||
this->ui_.vbox->setMargin(
|
||||
int((this->theme->isLightTheme() ? 4 : 2) * this->scale()));
|
||||
|
||||
this->ui_.emoteButton->getLabel().setStyleSheet("color: #000");
|
||||
|
||||
if (this->theme->isLightTheme())
|
||||
{
|
||||
this->ui_.replyLabel->setStyleSheet("color: #333");
|
||||
}
|
||||
else
|
||||
{
|
||||
this->ui_.replyLabel->setStyleSheet("color: #ccc");
|
||||
}
|
||||
}
|
||||
|
||||
void SplitInput::updateEmoteButton()
|
||||
|
@ -184,6 +241,24 @@ void SplitInput::updateEmoteButton()
|
|||
this->ui_.emoteButton->setFixedHeight(int(18 * scale));
|
||||
}
|
||||
|
||||
void SplitInput::updateCancelReplyButton()
|
||||
{
|
||||
float scale = this->scale();
|
||||
|
||||
QString text =
|
||||
QStringLiteral(
|
||||
"<img src=':/buttons/cancel.svg' width='%1' height='%1' />")
|
||||
.arg(QString::number(int(12 * scale)));
|
||||
|
||||
if (this->theme->isLightTheme())
|
||||
{
|
||||
text.replace("cancel", "cancelDark");
|
||||
}
|
||||
|
||||
this->ui_.cancelReplyButton->getLabel().setText(text);
|
||||
this->ui_.cancelReplyButton->setFixedHeight(int(12 * scale));
|
||||
}
|
||||
|
||||
void SplitInput::openEmotePopup()
|
||||
{
|
||||
if (!this->emotePopup_)
|
||||
|
@ -217,6 +292,79 @@ void SplitInput::openEmotePopup()
|
|||
this->emotePopup_->activateWindow();
|
||||
}
|
||||
|
||||
QString SplitInput::handleSendMessage(std::vector<QString> &arguments)
|
||||
{
|
||||
auto c = this->split_->getChannel();
|
||||
if (c == nullptr)
|
||||
return "";
|
||||
|
||||
if (!c->isTwitchChannel() || this->replyThread_ == nullptr)
|
||||
{
|
||||
// standard message send behavior
|
||||
QString message = ui_.textEdit->toPlainText();
|
||||
|
||||
message = message.replace('\n', ' ');
|
||||
QString sendMessage =
|
||||
getApp()->commands->execCommand(message, c, false);
|
||||
|
||||
c->sendMessage(sendMessage);
|
||||
|
||||
this->postMessageSend(message, arguments);
|
||||
return "";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Reply to message
|
||||
auto tc = dynamic_cast<TwitchChannel *>(c.get());
|
||||
if (!tc)
|
||||
{
|
||||
// this should not fail
|
||||
return "";
|
||||
}
|
||||
|
||||
QString message = this->ui_.textEdit->toPlainText();
|
||||
|
||||
if (this->enableInlineReplying_)
|
||||
{
|
||||
// Remove @username prefix that is inserted when doing inline replies
|
||||
message.remove(0, this->replyThread_->root()->displayName.length() +
|
||||
1); // remove "@username"
|
||||
|
||||
if (!message.isEmpty() && message.at(0) == ' ')
|
||||
{
|
||||
message.remove(0, 1); // remove possible space
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replace('\n', ' ');
|
||||
QString sendMessage =
|
||||
getApp()->commands->execCommand(message, c, false);
|
||||
|
||||
// Reply within TwitchChannel
|
||||
tc->sendReply(sendMessage, this->replyThread_->rootId());
|
||||
|
||||
this->postMessageSend(message, arguments);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
void SplitInput::postMessageSend(const QString &message,
|
||||
const std::vector<QString> &arguments)
|
||||
{
|
||||
// don't add duplicate messages and empty message to message history
|
||||
if ((this->prevMsg_.isEmpty() || !this->prevMsg_.endsWith(message)) &&
|
||||
!message.trimmed().isEmpty())
|
||||
{
|
||||
this->prevMsg_.append(message);
|
||||
}
|
||||
|
||||
if (arguments.empty() || arguments.at(0) != "keepInput")
|
||||
{
|
||||
this->clearInput();
|
||||
}
|
||||
this->prevIndex_ = this->prevMsg_.size();
|
||||
}
|
||||
|
||||
int SplitInput::scaledMaxHeight() const
|
||||
{
|
||||
return int(150 * this->scale());
|
||||
|
@ -302,37 +450,7 @@ void SplitInput::addShortcuts()
|
|||
}},
|
||||
{"sendMessage",
|
||||
[this](std::vector<QString> arguments) -> QString {
|
||||
auto c = this->split_->getChannel();
|
||||
if (c == nullptr)
|
||||
return "";
|
||||
|
||||
QString message = ui_.textEdit->toPlainText();
|
||||
|
||||
message = message.replace('\n', ' ');
|
||||
QString sendMessage =
|
||||
getApp()->commands->execCommand(message, c, false);
|
||||
|
||||
c->sendMessage(sendMessage);
|
||||
// don't add duplicate messages and empty message to message history
|
||||
if ((this->prevMsg_.isEmpty() ||
|
||||
!this->prevMsg_.endsWith(message)) &&
|
||||
!message.trimmed().isEmpty())
|
||||
{
|
||||
this->prevMsg_.append(message);
|
||||
}
|
||||
bool shouldClearInput = true;
|
||||
if (arguments.size() != 0 && arguments.at(0) == "keepInput")
|
||||
{
|
||||
shouldClearInput = false;
|
||||
}
|
||||
|
||||
if (shouldClearInput)
|
||||
{
|
||||
this->currMsg_ = QString();
|
||||
this->ui_.textEdit->setPlainText(QString());
|
||||
}
|
||||
this->prevIndex_ = this->prevMsg_.size();
|
||||
return "";
|
||||
return this->handleSendMessage(arguments);
|
||||
}},
|
||||
{"previousMessage",
|
||||
[this](std::vector<QString>) -> QString {
|
||||
|
@ -456,8 +574,7 @@ void SplitInput::addShortcuts()
|
|||
}},
|
||||
{"clear",
|
||||
[this](std::vector<QString>) -> QString {
|
||||
this->ui_.textEdit->setText("");
|
||||
this->ui_.textEdit->moveCursor(QTextCursor::Start);
|
||||
this->clearInput();
|
||||
return "";
|
||||
}},
|
||||
{"selectAll",
|
||||
|
@ -769,37 +886,71 @@ void SplitInput::editTextChanged()
|
|||
}
|
||||
|
||||
this->ui_.textEditLength->setText(labelText);
|
||||
|
||||
bool hasReply = false;
|
||||
if (this->enableInlineReplying_)
|
||||
{
|
||||
if (this->replyThread_ != nullptr)
|
||||
{
|
||||
// Check if the input still starts with @username. If not, don't reply.
|
||||
//
|
||||
// We need to verify that
|
||||
// 1. the @username prefix exists and
|
||||
// 2. if a character exists after the @username, it is a space
|
||||
QString replyPrefix = "@" + this->replyThread_->root()->displayName;
|
||||
if (!text.startsWith(replyPrefix) ||
|
||||
(text.length() > replyPrefix.length() &&
|
||||
text.at(replyPrefix.length()) != ' '))
|
||||
{
|
||||
this->replyThread_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide reply label if inline replies are possible
|
||||
hasReply = this->replyThread_ != nullptr;
|
||||
}
|
||||
|
||||
this->ui_.replyWrapper->setVisible(hasReply);
|
||||
this->ui_.replyLabel->setVisible(hasReply);
|
||||
this->ui_.cancelReplyButton->setVisible(hasReply);
|
||||
}
|
||||
|
||||
void SplitInput::paintEvent(QPaintEvent * /*event*/)
|
||||
{
|
||||
QPainter painter(this);
|
||||
|
||||
int s;
|
||||
QColor borderColor;
|
||||
|
||||
if (this->theme->isLightTheme())
|
||||
{
|
||||
int s = int(3 * this->scale());
|
||||
QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
|
||||
|
||||
painter.fillRect(rect, this->theme->splits.input.background);
|
||||
|
||||
painter.setPen(QColor("#ccc"));
|
||||
painter.drawRect(rect);
|
||||
s = int(3 * this->scale());
|
||||
borderColor = QColor("#ccc");
|
||||
}
|
||||
else
|
||||
{
|
||||
int s = int(1 * this->scale());
|
||||
QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
|
||||
|
||||
painter.fillRect(rect, this->theme->splits.input.background);
|
||||
|
||||
painter.setPen(QColor("#333"));
|
||||
painter.drawRect(rect);
|
||||
s = int(1 * this->scale());
|
||||
borderColor = QColor("#333");
|
||||
}
|
||||
|
||||
// int offset = 2;
|
||||
// painter.fillRect(offset, this->height() - offset, this->width() - 2 *
|
||||
// offset, 1,
|
||||
// getApp()->themes->splits.input.focusedLine);
|
||||
QMargins removeMargins(s - 1, s - 1, s, s);
|
||||
QRect baseRect = this->rect();
|
||||
|
||||
// completeAreaRect includes the reply label
|
||||
QRect completeAreaRect = baseRect.marginsRemoved(removeMargins);
|
||||
painter.fillRect(completeAreaRect, this->theme->splits.input.background);
|
||||
painter.setPen(borderColor);
|
||||
painter.drawRect(completeAreaRect);
|
||||
|
||||
if (this->enableInlineReplying_ && this->replyThread_ != nullptr)
|
||||
{
|
||||
// Move top of rect down to not include reply label
|
||||
baseRect.setTop(baseRect.top() + this->ui_.replyWrapper->height());
|
||||
|
||||
QRect onlyInputRect = baseRect.marginsRemoved(removeMargins);
|
||||
painter.setPen(borderColor);
|
||||
painter.drawRect(onlyInputRect);
|
||||
}
|
||||
}
|
||||
|
||||
void SplitInput::resizeEvent(QResizeEvent *)
|
||||
|
@ -814,4 +965,41 @@ void SplitInput::resizeEvent(QResizeEvent *)
|
|||
}
|
||||
}
|
||||
|
||||
void SplitInput::giveFocus(Qt::FocusReason reason)
|
||||
{
|
||||
this->ui_.textEdit->setFocus(reason);
|
||||
}
|
||||
|
||||
void SplitInput::setReply(std::shared_ptr<MessageThread> reply,
|
||||
bool showReplyingLabel)
|
||||
{
|
||||
this->replyThread_ = std::move(reply);
|
||||
|
||||
if (this->enableInlineReplying_)
|
||||
{
|
||||
// Only enable reply label if inline replying
|
||||
this->ui_.textEdit->setPlainText(
|
||||
"@" + this->replyThread_->root()->displayName + " ");
|
||||
this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock);
|
||||
this->ui_.replyLabel->setText("Replying to @" +
|
||||
this->replyThread_->root()->displayName);
|
||||
}
|
||||
}
|
||||
|
||||
void SplitInput::setPlaceholderText(const QString &text)
|
||||
{
|
||||
this->ui_.textEdit->setPlaceholderText(text);
|
||||
}
|
||||
|
||||
void SplitInput::clearInput()
|
||||
{
|
||||
this->currMsg_ = "";
|
||||
this->ui_.textEdit->setText("");
|
||||
this->ui_.textEdit->moveCursor(QTextCursor::Start);
|
||||
if (this->enableInlineReplying_)
|
||||
{
|
||||
this->replyThread_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
#include <QTextEdit>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include <memory>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
|
@ -18,6 +19,7 @@ class Split;
|
|||
class EmotePopup;
|
||||
class InputCompletionPopup;
|
||||
class EffectLabel;
|
||||
class MessageThread;
|
||||
class ResizingTextEdit;
|
||||
|
||||
class SplitInput : public BaseWidget
|
||||
|
@ -25,13 +27,19 @@ class SplitInput : public BaseWidget
|
|||
Q_OBJECT
|
||||
|
||||
public:
|
||||
SplitInput(Split *_chatWidget);
|
||||
SplitInput(Split *_chatWidget, bool enableInlineReplying = true);
|
||||
SplitInput(QWidget *parent, Split *_chatWidget,
|
||||
bool enableInlineReplying = true);
|
||||
|
||||
void clearSelection();
|
||||
bool isEditFirstWord() const;
|
||||
QString getInputText() const;
|
||||
void insertText(const QString &text);
|
||||
|
||||
void setReply(std::shared_ptr<MessageThread> reply,
|
||||
bool showInlineReplying = true);
|
||||
void setPlaceholderText(const QString &text);
|
||||
|
||||
/**
|
||||
* @brief Hide the widget
|
||||
*
|
||||
|
@ -64,7 +72,16 @@ protected:
|
|||
void paintEvent(QPaintEvent * /*event*/) override;
|
||||
void resizeEvent(QResizeEvent * /*event*/) override;
|
||||
|
||||
private:
|
||||
virtual void giveFocus(Qt::FocusReason reason);
|
||||
|
||||
QString handleSendMessage(std::vector<QString> &arguments);
|
||||
void postMessageSend(const QString &message,
|
||||
const std::vector<QString> &arguments);
|
||||
|
||||
/// Clears the input box, clears reply thread if inline replies are enabled
|
||||
void clearInput();
|
||||
|
||||
protected:
|
||||
void addShortcuts() override;
|
||||
void initLayout();
|
||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||
|
@ -78,6 +95,8 @@ private:
|
|||
void insertCompletionText(const QString &text);
|
||||
void openEmotePopup();
|
||||
|
||||
void updateCancelReplyButton();
|
||||
|
||||
// scaledMaxHeight returns the height in pixels that this widget can grow to
|
||||
// This does not take hidden into account, so callers must take hidden into account themselves
|
||||
int scaledMaxHeight() const;
|
||||
|
@ -92,8 +111,17 @@ private:
|
|||
EffectLabel *emoteButton;
|
||||
|
||||
QHBoxLayout *hbox;
|
||||
QVBoxLayout *vbox;
|
||||
|
||||
QWidget *replyWrapper;
|
||||
QHBoxLayout *replyHbox;
|
||||
QLabel *replyLabel;
|
||||
EffectLabel *cancelReplyButton;
|
||||
} ui_;
|
||||
|
||||
std::shared_ptr<MessageThread> replyThread_ = nullptr;
|
||||
bool enableInlineReplying_;
|
||||
|
||||
pajlada::Signals::SignalHolder managedConnections_;
|
||||
QStringList prevMsg_;
|
||||
QString currMsg_;
|
||||
|
@ -109,6 +137,7 @@ private slots:
|
|||
void editTextChanged();
|
||||
|
||||
friend class Split;
|
||||
friend class ReplyThreadPopup;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
||||
|
|
Loading…
Reference in a new issue