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
|
## 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)
|
- 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 option to display tabs on the right and bottom. (#3847)
|
||||||
- Minor: Added `is:first-msg` search option. (#3700)
|
- 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/addSplitDark.png</file>
|
||||||
<file>buttons/ban.png</file>
|
<file>buttons/ban.png</file>
|
||||||
<file>buttons/banRed.png</file>
|
<file>buttons/banRed.png</file>
|
||||||
|
<file>buttons/cancel.svg</file>
|
||||||
|
<file>buttons/cancelDark.svg</file>
|
||||||
<file>buttons/clearSearch.png</file>
|
<file>buttons/clearSearch.png</file>
|
||||||
<file>buttons/copyDark.png</file>
|
<file>buttons/copyDark.png</file>
|
||||||
<file>buttons/copyDark.svg</file>
|
<file>buttons/copyDark.svg</file>
|
||||||
|
@ -34,6 +36,10 @@
|
||||||
<file>buttons/modModeDisabled2.png</file>
|
<file>buttons/modModeDisabled2.png</file>
|
||||||
<file>buttons/modModeEnabled.png</file>
|
<file>buttons/modModeEnabled.png</file>
|
||||||
<file>buttons/modModeEnabled2.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/search.png</file>
|
||||||
<file>buttons/timeout.png</file>
|
<file>buttons/timeout.png</file>
|
||||||
<file>buttons/trashCan.png</file>
|
<file>buttons/trashCan.png</file>
|
||||||
|
|
|
@ -144,6 +144,8 @@ set(SOURCE_FILES
|
||||||
messages/MessageContainer.hpp
|
messages/MessageContainer.hpp
|
||||||
messages/MessageElement.cpp
|
messages/MessageElement.cpp
|
||||||
messages/MessageElement.hpp
|
messages/MessageElement.hpp
|
||||||
|
messages/MessageThread.cpp
|
||||||
|
messages/MessageThread.hpp
|
||||||
|
|
||||||
messages/SharedMessageBuilder.cpp
|
messages/SharedMessageBuilder.cpp
|
||||||
messages/SharedMessageBuilder.hpp
|
messages/SharedMessageBuilder.hpp
|
||||||
|
@ -347,6 +349,8 @@ set(SOURCE_FILES
|
||||||
widgets/BaseWidget.hpp
|
widgets/BaseWidget.hpp
|
||||||
widgets/BaseWindow.cpp
|
widgets/BaseWindow.cpp
|
||||||
widgets/BaseWindow.hpp
|
widgets/BaseWindow.hpp
|
||||||
|
widgets/DraggablePopup.cpp
|
||||||
|
widgets/DraggablePopup.hpp
|
||||||
widgets/FramelessEmbedWindow.cpp
|
widgets/FramelessEmbedWindow.cpp
|
||||||
widgets/FramelessEmbedWindow.hpp
|
widgets/FramelessEmbedWindow.hpp
|
||||||
widgets/Label.cpp
|
widgets/Label.cpp
|
||||||
|
@ -383,6 +387,8 @@ set(SOURCE_FILES
|
||||||
widgets/dialogs/NotificationPopup.hpp
|
widgets/dialogs/NotificationPopup.hpp
|
||||||
widgets/dialogs/QualityPopup.cpp
|
widgets/dialogs/QualityPopup.cpp
|
||||||
widgets/dialogs/QualityPopup.hpp
|
widgets/dialogs/QualityPopup.hpp
|
||||||
|
widgets/dialogs/ReplyThreadPopup.cpp
|
||||||
|
widgets/dialogs/ReplyThreadPopup.hpp
|
||||||
widgets/dialogs/SelectChannelDialog.cpp
|
widgets/dialogs/SelectChannelDialog.cpp
|
||||||
widgets/dialogs/SelectChannelDialog.hpp
|
widgets/dialogs/SelectChannelDialog.hpp
|
||||||
widgets/dialogs/SelectChannelFiltersDialog.cpp
|
widgets/dialogs/SelectChannelFiltersDialog.cpp
|
||||||
|
|
|
@ -32,6 +32,8 @@ Resources2::Resources2()
|
||||||
this->buttons.modModeDisabled2 = QPixmap(":/buttons/modModeDisabled2.png");
|
this->buttons.modModeDisabled2 = QPixmap(":/buttons/modModeDisabled2.png");
|
||||||
this->buttons.modModeEnabled = QPixmap(":/buttons/modModeEnabled.png");
|
this->buttons.modModeEnabled = QPixmap(":/buttons/modModeEnabled.png");
|
||||||
this->buttons.modModeEnabled2 = QPixmap(":/buttons/modModeEnabled2.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.search = QPixmap(":/buttons/search.png");
|
||||||
this->buttons.timeout = QPixmap(":/buttons/timeout.png");
|
this->buttons.timeout = QPixmap(":/buttons/timeout.png");
|
||||||
this->buttons.trashCan = QPixmap(":/buttons/trashCan.png");
|
this->buttons.trashCan = QPixmap(":/buttons/trashCan.png");
|
||||||
|
|
|
@ -39,6 +39,8 @@ public:
|
||||||
QPixmap modModeDisabled2;
|
QPixmap modModeDisabled2;
|
||||||
QPixmap modModeEnabled;
|
QPixmap modModeEnabled;
|
||||||
QPixmap modModeEnabled2;
|
QPixmap modModeEnabled2;
|
||||||
|
QPixmap replyDark;
|
||||||
|
QPixmap replyThreadDark;
|
||||||
QPixmap search;
|
QPixmap search;
|
||||||
QPixmap timeout;
|
QPixmap timeout;
|
||||||
QPixmap trashCan;
|
QPixmap trashCan;
|
||||||
|
|
|
@ -278,6 +278,13 @@ bool Channel::canSendMessage() const
|
||||||
return false;
|
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)
|
void Channel::sendMessage(const QString &message)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,9 @@ public:
|
||||||
// SIGNALS
|
// SIGNALS
|
||||||
pajlada::Signals::Signal<const QString &, const QString &, bool &>
|
pajlada::Signals::Signal<const QString &, const QString &, bool &>
|
||||||
sendMessageSignal;
|
sendMessageSignal;
|
||||||
|
pajlada::Signals::Signal<const QString &, const QString &, const QString &,
|
||||||
|
bool &>
|
||||||
|
sendReplySignal;
|
||||||
pajlada::Signals::Signal<MessagePtr &> messageRemovedFromStart;
|
pajlada::Signals::Signal<MessagePtr &> messageRemovedFromStart;
|
||||||
pajlada::Signals::Signal<MessagePtr &, boost::optional<MessageFlags>>
|
pajlada::Signals::Signal<MessagePtr &, boost::optional<MessageFlags>>
|
||||||
messageAppended;
|
messageAppended;
|
||||||
|
@ -84,6 +87,7 @@ public:
|
||||||
|
|
||||||
// CHANNEL INFO
|
// CHANNEL INFO
|
||||||
virtual bool canSendMessage() const;
|
virtual bool canSendMessage() const;
|
||||||
|
virtual bool isWritable() const; // whether split input will be usable
|
||||||
virtual void sendMessage(const QString &message);
|
virtual void sendMessage(const QString &message);
|
||||||
virtual bool isMod() const;
|
virtual bool isMod() const;
|
||||||
virtual bool isBroadcaster() const;
|
virtual bool isBroadcaster() const;
|
||||||
|
|
|
@ -51,6 +51,7 @@ using MessagePtr = std::shared_ptr<const Message>;
|
||||||
|
|
||||||
enum class CopyMode {
|
enum class CopyMode {
|
||||||
Everything,
|
Everything,
|
||||||
|
EverythingButReplies,
|
||||||
OnlyTextAndEmotes,
|
OnlyTextAndEmotes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
#include "util/StreamLink.hpp"
|
#include "util/StreamLink.hpp"
|
||||||
#include "util/Twitch.hpp"
|
#include "util/Twitch.hpp"
|
||||||
#include "widgets/Window.hpp"
|
#include "widgets/Window.hpp"
|
||||||
|
#include "widgets/dialogs/ReplyThreadPopup.hpp"
|
||||||
#include "widgets/dialogs/UserInfoPopup.hpp"
|
#include "widgets/dialogs/UserInfoPopup.hpp"
|
||||||
#include "widgets/splits/Split.hpp"
|
#include "widgets/splits/Split.hpp"
|
||||||
|
|
||||||
|
@ -687,7 +688,8 @@ void CommandController::initialize(Settings &, Paths &paths)
|
||||||
|
|
||||||
auto *userPopup = new UserInfoPopup(
|
auto *userPopup = new UserInfoPopup(
|
||||||
getSettings()->autoCloseUserPopup,
|
getSettings()->autoCloseUserPopup,
|
||||||
static_cast<QWidget *>(&(getApp()->windows->getMainWindow())));
|
static_cast<QWidget *>(&(getApp()->windows->getMainWindow())),
|
||||||
|
nullptr);
|
||||||
userPopup->setData(userName, channel);
|
userPopup->setData(userName, channel);
|
||||||
userPopup->move(QCursor::pos());
|
userPopup->move(QCursor::pos());
|
||||||
userPopup->show();
|
userPopup->show();
|
||||||
|
@ -1114,6 +1116,56 @@ void CommandController::initialize(Settings &, Paths &paths)
|
||||||
return "";
|
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
|
#ifndef NDEBUG
|
||||||
this->registerCommand(
|
this->registerCommand(
|
||||||
"/fakemsg",
|
"/fakemsg",
|
||||||
|
|
|
@ -82,6 +82,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel)
|
||||||
m->flags.has(MessageFlag::RedeemedChannelPointReward)},
|
m->flags.has(MessageFlag::RedeemedChannelPointReward)},
|
||||||
{"flags.first_message", m->flags.has(MessageFlag::FirstMessage)},
|
{"flags.first_message", m->flags.has(MessageFlag::FirstMessage)},
|
||||||
{"flags.whisper", m->flags.has(MessageFlag::Whisper)},
|
{"flags.whisper", m->flags.has(MessageFlag::Whisper)},
|
||||||
|
{"flags.reply", m->flags.has(MessageFlag::ReplyMessage)},
|
||||||
|
|
||||||
{"message.content", m->messageText},
|
{"message.content", m->messageText},
|
||||||
{"message.length", m->messageText.length()},
|
{"message.length", m->messageText.length()},
|
||||||
|
|
|
@ -25,6 +25,7 @@ static const QMap<QString, QString> validIdentifiersMap = {
|
||||||
{"flags.reward_message", "channel point reward message?"},
|
{"flags.reward_message", "channel point reward message?"},
|
||||||
{"flags.first_message", "first message?"},
|
{"flags.first_message", "first message?"},
|
||||||
{"flags.whisper", "whisper message?"},
|
{"flags.whisper", "whisper message?"},
|
||||||
|
{"flags.reply", "reply message?"},
|
||||||
{"message.content", "message text"},
|
{"message.content", "message text"},
|
||||||
{"message.length", "message length"}};
|
{"message.length", "message length"}};
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,8 @@ public:
|
||||||
JumpToChannel,
|
JumpToChannel,
|
||||||
Reconnect,
|
Reconnect,
|
||||||
CopyToClipboard,
|
CopyToClipboard,
|
||||||
|
ReplyToMessage,
|
||||||
|
ViewThread,
|
||||||
};
|
};
|
||||||
|
|
||||||
Link();
|
Link();
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
class MessageElement;
|
class MessageElement;
|
||||||
|
class MessageThread;
|
||||||
|
|
||||||
enum class MessageFlag : uint32_t {
|
enum class MessageFlag : uint32_t {
|
||||||
None = 0,
|
None = 0,
|
||||||
|
@ -40,6 +41,7 @@ enum class MessageFlag : uint32_t {
|
||||||
RedeemedChannelPointReward = (1 << 21),
|
RedeemedChannelPointReward = (1 << 21),
|
||||||
ShowInMentions = (1 << 22),
|
ShowInMentions = (1 << 22),
|
||||||
FirstMessage = (1 << 23),
|
FirstMessage = (1 << 23),
|
||||||
|
ReplyMessage = (1 << 24),
|
||||||
};
|
};
|
||||||
using MessageFlags = FlagsEnum<MessageFlag>;
|
using MessageFlags = FlagsEnum<MessageFlag>;
|
||||||
|
|
||||||
|
@ -68,6 +70,10 @@ struct Message : boost::noncopyable {
|
||||||
std::vector<Badge> badges;
|
std::vector<Badge> badges;
|
||||||
std::unordered_map<QString, QString> badgeInfos;
|
std::unordered_map<QString, QString> badgeInfos;
|
||||||
std::shared_ptr<QColor> highlightColor;
|
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;
|
uint32_t count = 1;
|
||||||
std::vector<std::unique_ptr<MessageElement>> elements;
|
std::vector<std::unique_ptr<MessageElement>> elements;
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
#include "messages/Emote.hpp"
|
#include "messages/Emote.hpp"
|
||||||
#include "messages/layouts/MessageLayoutContainer.hpp"
|
#include "messages/layouts/MessageLayoutContainer.hpp"
|
||||||
#include "messages/layouts/MessageLayoutElement.hpp"
|
#include "messages/layouts/MessageLayoutElement.hpp"
|
||||||
|
#include "providers/emoji/Emojis.hpp"
|
||||||
|
#include "singletons/Emotes.hpp"
|
||||||
#include "singletons/Settings.hpp"
|
#include "singletons/Settings.hpp"
|
||||||
#include "singletons/Theme.hpp"
|
#include "singletons/Theme.hpp"
|
||||||
#include "util/DebugCount.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
|
// EMOTE
|
||||||
EmoteElement::EmoteElement(const EmotePtr &emote, MessageElementFlags flags,
|
EmoteElement::EmoteElement(const EmotePtr &emote, MessageElementFlags flags,
|
||||||
const MessageColor &textElementColor)
|
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
|
// TIMESTAMP
|
||||||
TimestampElement::TimestampElement(QTime time)
|
TimestampElement::TimestampElement(QTime time)
|
||||||
: MessageElement(MessageElementFlag::Timestamp)
|
: 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
|
} // namespace chatterino
|
||||||
|
|
|
@ -126,6 +126,12 @@ enum class MessageElementFlag : int64_t {
|
||||||
// e.g. BTTV's SoSnowy during christmas season
|
// e.g. BTTV's SoSnowy during christmas season
|
||||||
ZeroWidthEmote = (1LL << 31),
|
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 |
|
Default = Timestamp | Badges | Username | BitsStatic | FfzEmoteImage |
|
||||||
BttvEmoteImage | TwitchEmoteImage | BitsAmount | Text |
|
BttvEmoteImage | TwitchEmoteImage | BitsAmount | Text |
|
||||||
AlwaysShow,
|
AlwaysShow,
|
||||||
|
@ -209,6 +215,22 @@ private:
|
||||||
ImagePtr image_;
|
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
|
// contains a text, it will split it into words
|
||||||
class TextElement : public MessageElement
|
class TextElement : public MessageElement
|
||||||
{
|
{
|
||||||
|
@ -232,6 +254,29 @@ private:
|
||||||
std::vector<Word> words_;
|
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 :
|
// contains emote data and will pick the emote based on :
|
||||||
// a) are images for the emote type enabled
|
// a) are images for the emote type enabled
|
||||||
// b) which size it wants
|
// b) which size it wants
|
||||||
|
@ -355,4 +400,18 @@ public:
|
||||||
private:
|
private:
|
||||||
ImageSet images_;
|
ImageSet images_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class ReplyCurveElement : public MessageElement
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ReplyCurveElement();
|
||||||
|
|
||||||
|
void addToContainer(MessageLayoutContainer &container,
|
||||||
|
MessageElementFlags flags) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
int neededMargin_;
|
||||||
|
QSize size_;
|
||||||
|
};
|
||||||
|
|
||||||
} // namespace chatterino
|
} // 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();
|
return this->message_.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MessagePtr &MessageLayout::getMessagePtr() const
|
||||||
|
{
|
||||||
|
return this->message_;
|
||||||
|
}
|
||||||
|
|
||||||
// Height
|
// Height
|
||||||
int MessageLayout::getHeight() const
|
int MessageLayout::getHeight() const
|
||||||
{
|
{
|
||||||
|
@ -147,6 +152,12 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this->renderReplies_ &&
|
||||||
|
element->getFlags().has(MessageElementFlag::RepliedMessage))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
element->addToContainer(*this->container_, flags);
|
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);
|
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
|
} // namespace chatterino
|
||||||
|
|
|
@ -37,6 +37,7 @@ public:
|
||||||
~MessageLayout();
|
~MessageLayout();
|
||||||
|
|
||||||
const Message *getMessage();
|
const Message *getMessage();
|
||||||
|
const MessagePtr &getMessagePtr() const;
|
||||||
|
|
||||||
int getHeight() const;
|
int getHeight() const;
|
||||||
|
|
||||||
|
@ -62,6 +63,8 @@ public:
|
||||||
|
|
||||||
// Misc
|
// Misc
|
||||||
bool isDisabled() const;
|
bool isDisabled() const;
|
||||||
|
bool isReplyable() const;
|
||||||
|
void setRenderReplies(bool render);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// variables
|
// variables
|
||||||
|
@ -69,6 +72,7 @@ private:
|
||||||
std::shared_ptr<MessageLayoutContainer> container_;
|
std::shared_ptr<MessageLayoutContainer> container_;
|
||||||
std::shared_ptr<QPixmap> buffer_{};
|
std::shared_ptr<QPixmap> buffer_{};
|
||||||
bool bufferValid_ = false;
|
bool bufferValid_ = false;
|
||||||
|
bool renderReplies_ = true;
|
||||||
|
|
||||||
int height_ = 0;
|
int height_ = 0;
|
||||||
|
|
||||||
|
|
|
@ -661,6 +661,14 @@ void MessageLayoutContainer::addSelectionText(QString &str, int from, int to,
|
||||||
|
|
||||||
for (auto &element : this->elements_)
|
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 (copymode == CopyMode::OnlyTextAndEmotes)
|
||||||
{
|
{
|
||||||
if (element->getCreator().getFlags().hasAny(
|
if (element->getCreator().getFlags().hasAny(
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
|
|
||||||
namespace chatterino {
|
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
|
// 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
|
} // namespace chatterino
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPen>
|
||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
#include <QRect>
|
#include <QRect>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
@ -93,6 +94,23 @@ private:
|
||||||
QColor color_;
|
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
|
// TEXT
|
||||||
class TextLayoutElement : public MessageLayoutElement
|
class TextLayoutElement : public MessageLayoutElement
|
||||||
{
|
{
|
||||||
|
@ -142,4 +160,24 @@ private:
|
||||||
QString line2;
|
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
|
} // namespace chatterino
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
#include "providers/twitch/TwitchChannel.hpp"
|
#include "providers/twitch/TwitchChannel.hpp"
|
||||||
#include "providers/twitch/TwitchHelpers.hpp"
|
#include "providers/twitch/TwitchHelpers.hpp"
|
||||||
#include "providers/twitch/TwitchIrcServer.hpp"
|
#include "providers/twitch/TwitchIrcServer.hpp"
|
||||||
#include "providers/twitch/TwitchMessageBuilder.hpp"
|
|
||||||
#include "singletons/Resources.hpp"
|
#include "singletons/Resources.hpp"
|
||||||
#include "singletons/Settings.hpp"
|
#include "singletons/Settings.hpp"
|
||||||
#include "singletons/WindowManager.hpp"
|
#include "singletons/WindowManager.hpp"
|
||||||
|
@ -19,6 +18,7 @@
|
||||||
|
|
||||||
#include <IrcMessage>
|
#include <IrcMessage>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
@ -242,6 +242,87 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message,
|
||||||
false, message->isAction());
|
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,
|
void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
|
||||||
const QString &target,
|
const QString &target,
|
||||||
const QString &content,
|
const QString &content,
|
||||||
|
@ -276,7 +357,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
|
||||||
auto channel = dynamic_cast<TwitchChannel *>(chan.get());
|
auto channel = dynamic_cast<TwitchChannel *>(chan.get());
|
||||||
|
|
||||||
const auto &tags = _message->tags();
|
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();
|
const auto rewardId = it.value().toString();
|
||||||
if (!channel->isChannelPointRewardKnown(rewardId))
|
if (!channel->isChannelPointRewardKnown(rewardId))
|
||||||
|
@ -301,6 +382,31 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
|
||||||
|
|
||||||
TwitchMessageBuilder builder(chan.get(), _message, args, content, isAction);
|
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 || !builder.isIgnored())
|
||||||
{
|
{
|
||||||
if (isSub)
|
if (isSub)
|
||||||
|
|
|
@ -3,6 +3,10 @@
|
||||||
#include <IrcMessage>
|
#include <IrcMessage>
|
||||||
#include "common/Channel.hpp"
|
#include "common/Channel.hpp"
|
||||||
#include "messages/Message.hpp"
|
#include "messages/Message.hpp"
|
||||||
|
#include "providers/twitch/TwitchChannel.hpp"
|
||||||
|
#include "providers/twitch/TwitchMessageBuilder.hpp"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
|
|
||||||
|
@ -20,6 +24,10 @@ public:
|
||||||
std::vector<MessagePtr> parseMessage(Channel *channel,
|
std::vector<MessagePtr> parseMessage(Channel *channel,
|
||||||
Communi::IrcMessage *message);
|
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
|
// parsePrivMessage arses a single IRC PRIVMSG into 0-1 Chatterino messages
|
||||||
std::vector<MessagePtr> parsePrivMessage(
|
std::vector<MessagePtr> parsePrivMessage(
|
||||||
Channel *channel, Communi::IrcPrivateMessage *message);
|
Channel *channel, Communi::IrcPrivateMessage *message);
|
||||||
|
@ -59,6 +67,10 @@ private:
|
||||||
void addMessage(Communi::IrcMessage *message, const QString &target,
|
void addMessage(Communi::IrcMessage *message, const QString &target,
|
||||||
const QString &content, TwitchIrcServer &server,
|
const QString &content, TwitchIrcServer &server,
|
||||||
bool isResub, bool isAction);
|
bool isResub, bool isAction);
|
||||||
|
|
||||||
|
void populateReply(TwitchChannel *channel, Communi::IrcMessage *message,
|
||||||
|
const std::vector<MessagePtr> &otherLoaded,
|
||||||
|
TwitchMessageBuilder &builder);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace chatterino
|
} // namespace chatterino
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
#include "providers/twitch/TwitchChannel.hpp"
|
#include "providers/twitch/TwitchChannel.hpp"
|
||||||
|
|
||||||
#include "Application.hpp"
|
|
||||||
#include "common/Common.hpp"
|
#include "common/Common.hpp"
|
||||||
#include "common/Env.hpp"
|
#include "common/Env.hpp"
|
||||||
#include "common/NetworkRequest.hpp"
|
#include "common/NetworkRequest.hpp"
|
||||||
|
@ -182,12 +181,34 @@ TwitchChannel::TwitchChannel(const QString &name)
|
||||||
this->refreshBTTVChannelEmotes(false);
|
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
|
// timers
|
||||||
QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [=] {
|
QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [=] {
|
||||||
this->refreshChatters();
|
this->refreshChatters();
|
||||||
});
|
});
|
||||||
this->chattersListTimer_.start(5 * 60 * 1000);
|
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
|
// debugging
|
||||||
#if 0
|
#if 0
|
||||||
for (int i = 0; i < 1000; i++) {
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
@ -311,24 +332,15 @@ boost::optional<ChannelPointReward> TwitchChannel::channelPointReward(
|
||||||
return it->second;
|
return it->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwitchChannel::sendMessage(const QString &message)
|
void TwitchChannel::showLoginMessage()
|
||||||
{
|
{
|
||||||
auto app = getApp();
|
|
||||||
|
|
||||||
if (!app->accounts->twitch.isLoggedIn())
|
|
||||||
{
|
|
||||||
if (message.isEmpty())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto linkColor = MessageColor(MessageColor::Link);
|
const auto linkColor = MessageColor(MessageColor::Link);
|
||||||
const auto accountsLink = Link(Link::OpenAccountsPage, QString());
|
const auto accountsLink = Link(Link::OpenAccountsPage, QString());
|
||||||
const auto currentUser = getApp()->accounts->twitch.getCurrent();
|
const auto currentUser = getApp()->accounts->twitch.getCurrent();
|
||||||
const auto expirationText =
|
const auto expirationText =
|
||||||
QString("You need to log in to send messages. You can link your "
|
QStringLiteral("You need to log in to send messages. You can link your "
|
||||||
"Twitch account");
|
"Twitch account");
|
||||||
const auto loginPromptText = QString("in the settings.");
|
const auto loginPromptText = QStringLiteral("in the settings.");
|
||||||
|
|
||||||
auto builder = MessageBuilder();
|
auto builder = MessageBuilder();
|
||||||
builder.message().flags.set(MessageFlag::System);
|
builder.message().flags.set(MessageFlag::System);
|
||||||
|
@ -343,14 +355,11 @@ void TwitchChannel::sendMessage(const QString &message)
|
||||||
->setLink(accountsLink);
|
->setLink(accountsLink);
|
||||||
|
|
||||||
this->addMessage(builder.release());
|
this->addMessage(builder.release());
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
qCDebug(chatterinoTwitch)
|
QString TwitchChannel::prepareMessage(const QString &message) const
|
||||||
<< "[TwitchChannel" << this->getName() << "] Send message:" << message;
|
{
|
||||||
|
auto app = getApp();
|
||||||
// Do last message processing
|
|
||||||
QString parsedMessage = app->emotes->emojis.replaceShortCodes(message);
|
QString parsedMessage = app->emotes->emojis.replaceShortCodes(message);
|
||||||
|
|
||||||
// This is to make sure that combined emoji go through properly, see
|
// 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())
|
if (parsedMessage.isEmpty())
|
||||||
{
|
{
|
||||||
return;
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this->hasHighRateLimit())
|
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;
|
bool messageSent = false;
|
||||||
this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent);
|
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
|
bool TwitchChannel::isMod() const
|
||||||
{
|
{
|
||||||
return this->mod_;
|
return this->mod_;
|
||||||
|
@ -794,8 +863,10 @@ void TwitchChannel::loadRecentMessages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (auto builtMessage :
|
auto builtMessages = handler.parseMessageWithReply(
|
||||||
handler.parseMessage(shared.get(), message))
|
shared.get(), message, allBuiltMessages);
|
||||||
|
|
||||||
|
for (auto builtMessage : builtMessages)
|
||||||
{
|
{
|
||||||
builtMessage->flags.set(MessageFlag::RecentMessage);
|
builtMessage->flags.set(MessageFlag::RecentMessage);
|
||||||
allBuiltMessages.emplace_back(builtMessage);
|
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()
|
void TwitchChannel::refreshBadges()
|
||||||
{
|
{
|
||||||
auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" +
|
auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" +
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "Application.hpp"
|
||||||
#include "common/Aliases.hpp"
|
#include "common/Aliases.hpp"
|
||||||
#include "common/Atomic.hpp"
|
#include "common/Atomic.hpp"
|
||||||
#include "common/Channel.hpp"
|
#include "common/Channel.hpp"
|
||||||
|
@ -7,6 +8,7 @@
|
||||||
#include "common/ChatterSet.hpp"
|
#include "common/ChatterSet.hpp"
|
||||||
#include "common/Outcome.hpp"
|
#include "common/Outcome.hpp"
|
||||||
#include "common/UniqueAccess.hpp"
|
#include "common/UniqueAccess.hpp"
|
||||||
|
#include "messages/MessageThread.hpp"
|
||||||
#include "providers/twitch/ChannelPointReward.hpp"
|
#include "providers/twitch/ChannelPointReward.hpp"
|
||||||
#include "providers/twitch/TwitchEmotes.hpp"
|
#include "providers/twitch/TwitchEmotes.hpp"
|
||||||
#include "providers/twitch/api/Helix.hpp"
|
#include "providers/twitch/api/Helix.hpp"
|
||||||
|
@ -74,12 +76,15 @@ public:
|
||||||
int slowMode = 0;
|
int slowMode = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
explicit TwitchChannel(const QString &channelName);
|
||||||
|
|
||||||
void initialize();
|
void initialize();
|
||||||
|
|
||||||
// Channel methods
|
// Channel methods
|
||||||
virtual bool isEmpty() const override;
|
virtual bool isEmpty() const override;
|
||||||
virtual bool canSendMessage() const override;
|
virtual bool canSendMessage() const override;
|
||||||
virtual void sendMessage(const QString &message) override;
|
virtual void sendMessage(const QString &message) override;
|
||||||
|
virtual void sendReply(const QString &message, const QString &replyId);
|
||||||
virtual bool isMod() const override;
|
virtual bool isMod() const override;
|
||||||
bool isVip() const;
|
bool isVip() const;
|
||||||
bool isStaff() const;
|
bool isStaff() const;
|
||||||
|
@ -118,6 +123,17 @@ public:
|
||||||
// Cheers
|
// Cheers
|
||||||
boost::optional<CheerEmote> cheerEmote(const QString &string);
|
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
|
// Signals
|
||||||
pajlada::Signals::NoArgSignal roomIdChanged;
|
pajlada::Signals::NoArgSignal roomIdChanged;
|
||||||
pajlada::Signals::NoArgSignal userStateChanged;
|
pajlada::Signals::NoArgSignal userStateChanged;
|
||||||
|
@ -138,9 +154,6 @@ private:
|
||||||
QString localizedName;
|
QString localizedName;
|
||||||
} nameOptions;
|
} nameOptions;
|
||||||
|
|
||||||
protected:
|
|
||||||
explicit TwitchChannel(const QString &channelName);
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Methods
|
// Methods
|
||||||
void refreshLiveStatus();
|
void refreshLiveStatus();
|
||||||
|
@ -151,6 +164,8 @@ private:
|
||||||
void refreshCheerEmotes();
|
void refreshCheerEmotes();
|
||||||
void loadRecentMessages();
|
void loadRecentMessages();
|
||||||
void fetchDisplayName();
|
void fetchDisplayName();
|
||||||
|
void cleanUpReplyThreads();
|
||||||
|
void showLoginMessage();
|
||||||
|
|
||||||
void setLive(bool newLiveStatus);
|
void setLive(bool newLiveStatus);
|
||||||
void setMod(bool value);
|
void setMod(bool value);
|
||||||
|
@ -164,6 +179,8 @@ private:
|
||||||
const QString &getDisplayName() const override;
|
const QString &getDisplayName() const override;
|
||||||
const QString &getLocalizedName() const override;
|
const QString &getLocalizedName() const override;
|
||||||
|
|
||||||
|
QString prepareMessage(const QString &message) const;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const QString subscriptionUrl_;
|
const QString subscriptionUrl_;
|
||||||
const QString channelUrl_;
|
const QString channelUrl_;
|
||||||
|
@ -171,6 +188,7 @@ private:
|
||||||
int chatterCount_;
|
int chatterCount_;
|
||||||
UniqueAccess<StreamStatus> streamStatus_;
|
UniqueAccess<StreamStatus> streamStatus_;
|
||||||
UniqueAccess<RoomModes> roomModes_;
|
UniqueAccess<RoomModes> roomModes_;
|
||||||
|
std::unordered_map<QString, std::weak_ptr<MessageThread>> threads_;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
Atomic<std::shared_ptr<const EmoteMap>> bttvEmotes_;
|
Atomic<std::shared_ptr<const EmoteMap>> bttvEmotes_;
|
||||||
|
@ -194,6 +212,7 @@ private:
|
||||||
QString lastSentMessage_;
|
QString lastSentMessage_;
|
||||||
QObject lifetimeGuard_;
|
QObject lifetimeGuard_;
|
||||||
QTimer chattersListTimer_;
|
QTimer chattersListTimer_;
|
||||||
|
QTimer threadClearTimer_;
|
||||||
QElapsedTimer titleRefreshedTimer_;
|
QElapsedTimer titleRefreshedTimer_;
|
||||||
QElapsedTimer clipCreationTimer_;
|
QElapsedTimer clipCreationTimer_;
|
||||||
bool isClipCreationInProgress{false};
|
bool isClipCreationInProgress{false};
|
||||||
|
|
|
@ -121,6 +121,11 @@ std::shared_ptr<Channel> TwitchIrcServer::createChannel(
|
||||||
[this, channel = channel.get()](auto &chan, auto &msg, bool &sent) {
|
[this, channel = channel.get()](auto &chan, auto &msg, bool &sent) {
|
||||||
this->onMessageSendRequested(channel, msg, 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);
|
return std::shared_ptr<Channel>(channel);
|
||||||
}
|
}
|
||||||
|
@ -370,17 +375,11 @@ bool TwitchIrcServer::hasSeparateWriteConnection() const
|
||||||
// return getSettings()->twitchSeperateWriteConnection;
|
// return getSettings()->twitchSeperateWriteConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
|
bool TwitchIrcServer::prepareToSend(TwitchChannel *channel)
|
||||||
const QString &message, bool &sent)
|
|
||||||
{
|
|
||||||
sent = false;
|
|
||||||
|
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> guard(this->lastMessageMutex_);
|
std::lock_guard<std::mutex> guard(this->lastMessageMutex_);
|
||||||
|
|
||||||
// std::queue<std::chrono::steady_clock::time_point>
|
auto &lastMessage = channel->hasHighRateLimit() ? this->lastMessageMod_
|
||||||
auto &lastMessage = channel->hasHighRateLimit()
|
|
||||||
? this->lastMessageMod_
|
|
||||||
: this->lastMessagePleb_;
|
: this->lastMessagePleb_;
|
||||||
size_t maxMessageCount = channel->hasHighRateLimit() ? 99 : 19;
|
size_t maxMessageCount = channel->hasHighRateLimit() ? 99 : 19;
|
||||||
auto minMessageOffset = (channel->hasHighRateLimit() ? 100ms : 1100ms);
|
auto minMessageOffset = (channel->hasHighRateLimit() ? 100ms : 1100ms);
|
||||||
|
@ -399,7 +398,7 @@ void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
|
||||||
|
|
||||||
this->lastErrorTimeSpeed_ = now;
|
this->lastErrorTimeSpeed_ = now;
|
||||||
}
|
}
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove messages older than 30 seconds
|
// remove messages older than 30 seconds
|
||||||
|
@ -420,16 +419,46 @@ void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
|
||||||
|
|
||||||
this->lastErrorTimeAmount_ = now;
|
this->lastErrorTimeAmount_ = now;
|
||||||
}
|
}
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastMessage.push(now);
|
lastMessage.push(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel,
|
||||||
|
const QString &message, bool &sent)
|
||||||
|
{
|
||||||
|
sent = false;
|
||||||
|
|
||||||
|
bool canSend = this->prepareToSend(channel);
|
||||||
|
if (!canSend)
|
||||||
|
{
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this->sendMessage(channel->getName(), message);
|
this->sendMessage(channel->getName(), message);
|
||||||
sent = true;
|
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
|
const BttvEmotes &TwitchIrcServer::getBttvEmotes() const
|
||||||
{
|
{
|
||||||
return this->bttv;
|
return this->bttv;
|
||||||
|
|
|
@ -67,6 +67,10 @@ protected:
|
||||||
private:
|
private:
|
||||||
void onMessageSendRequested(TwitchChannel *channel, const QString &message,
|
void onMessageSendRequested(TwitchChannel *channel, const QString &message,
|
||||||
bool &sent);
|
bool &sent);
|
||||||
|
void onReplySendRequested(TwitchChannel *channel, const QString &message,
|
||||||
|
const QString &replyId, bool &sent);
|
||||||
|
|
||||||
|
bool prepareToSend(TwitchChannel *channel);
|
||||||
|
|
||||||
std::mutex lastMessageMutex_;
|
std::mutex lastMessageMutex_;
|
||||||
std::queue<std::chrono::steady_clock::time_point> lastMessagePleb_;
|
std::queue<std::chrono::steady_clock::time_point> lastMessagePleb_;
|
||||||
|
|
|
@ -48,6 +48,66 @@ const QSet<QString> zeroWidthEmotes{
|
||||||
|
|
||||||
namespace chatterino {
|
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(
|
TwitchMessageBuilder::TwitchMessageBuilder(
|
||||||
Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage,
|
Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage,
|
||||||
const MessageParseArgs &_args)
|
const MessageParseArgs &_args)
|
||||||
|
@ -138,6 +198,70 @@ MessagePtr TwitchMessageBuilder::build()
|
||||||
this->message().flags.set(MessageFlag::FirstMessage);
|
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
|
// timestamp
|
||||||
this->message().serverReceivedTime = calculateMessageTime(this->ircMessage);
|
this->message().serverReceivedTime = calculateMessageTime(this->ircMessage);
|
||||||
this->emplace<TimestampElement>(this->message().serverReceivedTime.time());
|
this->emplace<TimestampElement>(this->message().serverReceivedTime.time());
|
||||||
|
@ -217,6 +341,23 @@ MessagePtr TwitchMessageBuilder::build()
|
||||||
ColorProvider::instance().color(ColorType::Whisper);
|
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();
|
return this->release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -559,53 +700,7 @@ void TwitchMessageBuilder::appendUsername()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hasLocalizedName = !localizedName.isEmpty();
|
QString usernameText = stylizeUsername(username, this->message());
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this->args.isSentWhisper)
|
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
|
} // namespace chatterino
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "common/Aliases.hpp"
|
#include "common/Aliases.hpp"
|
||||||
#include "common/Outcome.hpp"
|
#include "common/Outcome.hpp"
|
||||||
|
#include "messages/MessageThread.hpp"
|
||||||
#include "messages/SharedMessageBuilder.hpp"
|
#include "messages/SharedMessageBuilder.hpp"
|
||||||
#include "providers/twitch/ChannelPointReward.hpp"
|
#include "providers/twitch/ChannelPointReward.hpp"
|
||||||
#include "providers/twitch/PubSubActions.hpp"
|
#include "providers/twitch/PubSubActions.hpp"
|
||||||
|
@ -45,6 +46,8 @@ public:
|
||||||
void triggerHighlights() override;
|
void triggerHighlights() override;
|
||||||
MessagePtr build() override;
|
MessagePtr build() override;
|
||||||
|
|
||||||
|
void setThread(std::shared_ptr<MessageThread> thread);
|
||||||
|
|
||||||
static void appendChannelPointRewardMessage(
|
static void appendChannelPointRewardMessage(
|
||||||
const ChannelPointReward &reward, MessageBuilder *builder, bool isMod,
|
const ChannelPointReward &reward, MessageBuilder *builder, bool isMod,
|
||||||
bool isBroadcaster);
|
bool isBroadcaster);
|
||||||
|
@ -105,6 +108,7 @@ private:
|
||||||
int bitsLeft;
|
int bitsLeft;
|
||||||
bool bitsStacked = false;
|
bool bitsStacked = false;
|
||||||
bool historicalMessage_ = false;
|
bool historicalMessage_ = false;
|
||||||
|
std::shared_ptr<MessageThread> thread_;
|
||||||
|
|
||||||
QString userId_;
|
QString userId_;
|
||||||
bool senderIsBroadcaster{};
|
bool senderIsBroadcaster{};
|
||||||
|
|
|
@ -103,6 +103,7 @@ public:
|
||||||
|
|
||||||
// BoolSetting collapseLongMessages =
|
// BoolSetting collapseLongMessages =
|
||||||
// {"/appearance/messages/collapseLongMessages", false};
|
// {"/appearance/messages/collapseLongMessages", false};
|
||||||
|
BoolSetting showReplyButton = {"/appearance/showReplyButton", false};
|
||||||
IntSetting collpseMessagesMinLines = {
|
IntSetting collpseMessagesMinLines = {
|
||||||
"/appearance/messages/collapseMessagesMinLines", 0};
|
"/appearance/messages/collapseMessagesMinLines", 0};
|
||||||
BoolSetting alternateMessages = {
|
BoolSetting alternateMessages = {
|
||||||
|
@ -158,6 +159,8 @@ public:
|
||||||
FloatSetting mouseScrollMultiplier = {"/behaviour/mouseScrollMultiplier",
|
FloatSetting mouseScrollMultiplier = {"/behaviour/mouseScrollMultiplier",
|
||||||
1.0};
|
1.0};
|
||||||
BoolSetting autoCloseUserPopup = {"/behaviour/autoCloseUserPopup", true};
|
BoolSetting autoCloseUserPopup = {"/behaviour/autoCloseUserPopup", true};
|
||||||
|
BoolSetting autoCloseThreadPopup = {"/behaviour/autoCloseThreadPopup",
|
||||||
|
false};
|
||||||
// BoolSetting twitchSeperateWriteConnection =
|
// BoolSetting twitchSeperateWriteConnection =
|
||||||
// {"/behaviour/twitchSeperateWriteConnection", false};
|
// {"/behaviour/twitchSeperateWriteConnection", false};
|
||||||
|
|
||||||
|
|
|
@ -113,6 +113,7 @@ WindowManager::WindowManager()
|
||||||
this->wordFlagsListener_.addSetting(settings->enableEmoteImages);
|
this->wordFlagsListener_.addSetting(settings->enableEmoteImages);
|
||||||
this->wordFlagsListener_.addSetting(settings->boldUsernames);
|
this->wordFlagsListener_.addSetting(settings->boldUsernames);
|
||||||
this->wordFlagsListener_.addSetting(settings->lowercaseDomains);
|
this->wordFlagsListener_.addSetting(settings->lowercaseDomains);
|
||||||
|
this->wordFlagsListener_.addSetting(settings->showReplyButton);
|
||||||
this->wordFlagsListener_.setCB([this] {
|
this->wordFlagsListener_.setCB([this] {
|
||||||
this->updateWordTypeMask();
|
this->updateWordTypeMask();
|
||||||
});
|
});
|
||||||
|
@ -182,6 +183,10 @@ void WindowManager::updateWordTypeMask()
|
||||||
// username
|
// username
|
||||||
flags.set(MEF::Username);
|
flags.set(MEF::Username);
|
||||||
|
|
||||||
|
// replies
|
||||||
|
flags.set(MEF::RepliedMessage);
|
||||||
|
flags.set(settings->showReplyButton ? MEF::ReplyButton : MEF::None);
|
||||||
|
|
||||||
// misc
|
// misc
|
||||||
flags.set(MEF::AlwaysShow);
|
flags.set(MEF::AlwaysShow);
|
||||||
flags.set(MEF::Collapsed);
|
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 =
|
LimitedQueueSnapshot<MessagePtr> snapshot =
|
||||||
channel->getMessageSnapshot();
|
channel->getMessageSnapshot();
|
||||||
|
|
||||||
ChannelPtr channelPtr(
|
ChannelPtr channelPtr;
|
||||||
new Channel(channel->getName(), Channel::Type::None));
|
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++)
|
for (size_t i = 0; i < snapshot.size(); i++)
|
||||||
{
|
{
|
||||||
|
@ -118,33 +126,14 @@ namespace {
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
#ifdef Q_OS_LINUX
|
UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent,
|
||||||
FlagsEnum<BaseWindow::Flags> userInfoPopupFlags{BaseWindow::Dialog,
|
Split *split)
|
||||||
BaseWindow::EnableCustomFrame};
|
: DraggablePopup(closeAutomatically, parent)
|
||||||
FlagsEnum<BaseWindow::Flags> userInfoPopupFlagsCloseAutomatically{
|
, split_(split)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
this->setWindowTitle("Usercard");
|
this->setWindowTitle("Usercard");
|
||||||
this->setStayInScreenRect(true);
|
this->setStayInScreenRect(true);
|
||||||
|
|
||||||
if (closeAutomatically)
|
|
||||||
this->setActionOnFocusLoss(BaseWindow::Delete);
|
|
||||||
else
|
|
||||||
this->setAttribute(Qt::WA_DeleteOnClose);
|
|
||||||
|
|
||||||
HotkeyController::HotkeyMap actions{
|
HotkeyController::HotkeyMap actions{
|
||||||
{"delete",
|
{"delete",
|
||||||
[this](std::vector<QString>) -> QString {
|
[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 = new Label("No recent messages");
|
||||||
this->ui_.noMessagesLabel->setVisible(false);
|
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->setMinimumSize(400, 275);
|
||||||
this->ui_.latestMessages->setSizePolicy(QSizePolicy::Expanding,
|
this->ui_.latestMessages->setSizePolicy(QSizePolicy::Expanding,
|
||||||
QSizePolicy::Expanding);
|
QSizePolicy::Expanding);
|
||||||
|
@ -510,21 +500,6 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
|
||||||
|
|
||||||
this->installEvents();
|
this->installEvents();
|
||||||
this->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Policy::Ignored);
|
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()
|
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()
|
void UserInfoPopup::installEvents()
|
||||||
{
|
{
|
||||||
std::shared_ptr<bool> ignoreNext = std::make_shared<bool>(false);
|
std::shared_ptr<bool> ignoreNext = std::make_shared<bool>(false);
|
||||||
|
@ -765,7 +710,7 @@ void UserInfoPopup::updateLatestMessages()
|
||||||
|
|
||||||
void UserInfoPopup::updateUserData()
|
void UserInfoPopup::updateUserData()
|
||||||
{
|
{
|
||||||
std::weak_ptr<bool> hack = this->hack_;
|
std::weak_ptr<bool> hack = this->lifetimeHack_;
|
||||||
auto currentUser = getApp()->accounts->twitch.getCurrent();
|
auto currentUser = getApp()->accounts->twitch.getCurrent();
|
||||||
|
|
||||||
const auto onUserFetchFailed = [this, hack] {
|
const auto onUserFetchFailed = [this, hack] {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "widgets/BaseWindow.hpp"
|
#include "widgets/DraggablePopup.hpp"
|
||||||
#include "widgets/helper/ChannelView.hpp"
|
#include "widgets/helper/ChannelView.hpp"
|
||||||
|
|
||||||
#include <pajlada/signals/scoped-connection.hpp>
|
#include <pajlada/signals/scoped-connection.hpp>
|
||||||
|
@ -16,12 +16,13 @@ class Channel;
|
||||||
using ChannelPtr = std::shared_ptr<Channel>;
|
using ChannelPtr = std::shared_ptr<Channel>;
|
||||||
class Label;
|
class Label;
|
||||||
|
|
||||||
class UserInfoPopup final : public BaseWindow
|
class UserInfoPopup final : public DraggablePopup
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
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 &channel);
|
||||||
void setData(const QString &name, const ChannelPtr &contextChannel,
|
void setData(const QString &name, const ChannelPtr &contextChannel,
|
||||||
|
@ -30,9 +31,6 @@ public:
|
||||||
protected:
|
protected:
|
||||||
virtual void themeChangedEvent() override;
|
virtual void themeChangedEvent() override;
|
||||||
virtual void scaleChangedEvent(float scale) override;
|
virtual void scaleChangedEvent(float scale) override;
|
||||||
void mousePressEvent(QMouseEvent *event) override;
|
|
||||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
|
||||||
void mouseMoveEvent(QMouseEvent *event) override;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void installEvents();
|
void installEvents();
|
||||||
|
@ -43,6 +41,8 @@ private:
|
||||||
bool isMod_;
|
bool isMod_;
|
||||||
bool isBroadcaster_;
|
bool isBroadcaster_;
|
||||||
|
|
||||||
|
Split *split_;
|
||||||
|
|
||||||
QString userName_;
|
QString userName_;
|
||||||
QString userId_;
|
QString userId_;
|
||||||
QString avatarUrl_;
|
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.
|
// 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_;
|
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_;
|
pajlada::Signals::NoArgSignal userStateChanged_;
|
||||||
|
|
||||||
std::unique_ptr<pajlada::Signals::ScopedConnection> refreshConnection_;
|
std::unique_ptr<pajlada::Signals::ScopedConnection> refreshConnection_;
|
||||||
|
|
||||||
std::shared_ptr<bool> hack_;
|
|
||||||
|
|
||||||
struct {
|
struct {
|
||||||
Button *avatarButton = nullptr;
|
Button *avatarButton = nullptr;
|
||||||
Button *localizedNameCopyButton = nullptr;
|
Button *localizedNameCopyButton = nullptr;
|
||||||
|
|
|
@ -44,6 +44,7 @@
|
||||||
#include "widgets/Scrollbar.hpp"
|
#include "widgets/Scrollbar.hpp"
|
||||||
#include "widgets/TooltipWidget.hpp"
|
#include "widgets/TooltipWidget.hpp"
|
||||||
#include "widgets/Window.hpp"
|
#include "widgets/Window.hpp"
|
||||||
|
#include "widgets/dialogs/ReplyThreadPopup.hpp"
|
||||||
#include "widgets/dialogs/SettingsDialog.hpp"
|
#include "widgets/dialogs/SettingsDialog.hpp"
|
||||||
#include "widgets/dialogs/UserInfoPopup.hpp"
|
#include "widgets/dialogs/UserInfoPopup.hpp"
|
||||||
#include "widgets/helper/EffectLabel.hpp"
|
#include "widgets/helper/EffectLabel.hpp"
|
||||||
|
@ -119,9 +120,11 @@ namespace {
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ChannelView::ChannelView(BaseWidget *parent)
|
ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context)
|
||||||
: BaseWidget(parent)
|
: BaseWidget(parent)
|
||||||
, scrollBar_(new Scrollbar(this))
|
, scrollBar_(new Scrollbar(this))
|
||||||
|
, split_(split)
|
||||||
|
, context_(context)
|
||||||
{
|
{
|
||||||
this->setMouseTracking(true);
|
this->setMouseTracking(true);
|
||||||
|
|
||||||
|
@ -160,7 +163,7 @@ ChannelView::ChannelView(BaseWidget *parent)
|
||||||
// and tabbing to it from another widget. I don't currently know
|
// and tabbing to it from another widget. I don't currently know
|
||||||
// of any place where you can, or where it would make sense,
|
// of any place where you can, or where it would make sense,
|
||||||
// to tab to a ChannelVieChannelView
|
// to tab to a ChannelVieChannelView
|
||||||
this->setFocusPolicy(Qt::FocusPolicy::StrongFocus);
|
this->setFocusPolicy(Qt::FocusPolicy::ClickFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChannelView::initializeLayout()
|
void ChannelView::initializeLayout()
|
||||||
|
@ -1019,6 +1022,17 @@ MessageElementFlags ChannelView::getFlags() const
|
||||||
if (this->sourceChannel_ == app->twitch->mentionsChannel)
|
if (this->sourceChannel_ == app->twitch->mentionsChannel)
|
||||||
flags.set(MessageElementFlag::ChannelName);
|
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;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1983,10 +1997,27 @@ void ChannelView::addMessageContextMenuItems(
|
||||||
|
|
||||||
menu.addAction("Copy full message", [layout] {
|
menu.addAction("Copy full message", [layout] {
|
||||||
QString copyString;
|
QString copyString;
|
||||||
layout->addSelectionText(copyString);
|
layout->addSelectionText(copyString, 0, INT_MAX,
|
||||||
|
CopyMode::EverythingButReplies);
|
||||||
|
|
||||||
crossPlatformCopy(copyString);
|
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(
|
void ChannelView::addTwitchLinkContextMenuItems(
|
||||||
|
@ -2224,8 +2255,8 @@ void ChannelView::showUserInfoPopup(const QString &userName,
|
||||||
{
|
{
|
||||||
auto *userCardParent =
|
auto *userCardParent =
|
||||||
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()));
|
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()));
|
||||||
auto *userPopup =
|
auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup,
|
||||||
new UserInfoPopup(getSettings()->autoCloseUserPopup, userCardParent);
|
userCardParent, this->split_);
|
||||||
|
|
||||||
auto contextChannel =
|
auto contextChannel =
|
||||||
getApp()->twitch->getChannelOrEmpty(alternativePopoutChannel);
|
getApp()->twitch->getChannelOrEmpty(alternativePopoutChannel);
|
||||||
|
@ -2351,6 +2382,14 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link,
|
||||||
this->underlyingChannel_.get()->reconnect();
|
this->underlyingChannel_.get()->reconnect();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case Link::ReplyToMessage: {
|
||||||
|
this->setInputReply(layout->getMessagePtr());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Link::ViewThread: {
|
||||||
|
this->showReplyThreadPopup(layout->getMessagePtr());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:;
|
default:;
|
||||||
}
|
}
|
||||||
|
@ -2473,4 +2512,106 @@ void ChannelView::scrollUpdateRequested()
|
||||||
this->scrollBar_->offset(multiplier * offset);
|
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
|
} // namespace chatterino
|
||||||
|
|
|
@ -39,6 +39,7 @@ class Scrollbar;
|
||||||
class EffectLabel;
|
class EffectLabel;
|
||||||
struct Link;
|
struct Link;
|
||||||
class MessageLayoutElement;
|
class MessageLayoutElement;
|
||||||
|
class Split;
|
||||||
|
|
||||||
enum class PauseReason {
|
enum class PauseReason {
|
||||||
Mouse,
|
Mouse,
|
||||||
|
@ -61,7 +62,15 @@ class ChannelView final : public BaseWidget
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
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();
|
void queueUpdate();
|
||||||
Scrollbar &getScrollBar();
|
Scrollbar &getScrollBar();
|
||||||
|
@ -99,6 +108,8 @@ public:
|
||||||
|
|
||||||
void clearMessages();
|
void clearMessages();
|
||||||
|
|
||||||
|
Context getContext() const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Creates and shows a UserInfoPopup dialog
|
* @brief Creates and shows a UserInfoPopup dialog
|
||||||
*
|
*
|
||||||
|
@ -196,6 +207,10 @@ private:
|
||||||
void enableScrolling(const QPointF &scrollStart);
|
void enableScrolling(const QPointF &scrollStart);
|
||||||
void disableScrolling();
|
void disableScrolling();
|
||||||
|
|
||||||
|
void setInputReply(const MessagePtr &message);
|
||||||
|
void showReplyThreadPopup(const MessagePtr &message);
|
||||||
|
bool canReplyToMessages() const;
|
||||||
|
|
||||||
QTimer *layoutCooldown_;
|
QTimer *layoutCooldown_;
|
||||||
bool layoutQueued_;
|
bool layoutQueued_;
|
||||||
|
|
||||||
|
@ -221,6 +236,7 @@ private:
|
||||||
ChannelPtr channel_ = nullptr;
|
ChannelPtr channel_ = nullptr;
|
||||||
ChannelPtr underlyingChannel_ = nullptr;
|
ChannelPtr underlyingChannel_ = nullptr;
|
||||||
ChannelPtr sourceChannel_ = nullptr;
|
ChannelPtr sourceChannel_ = nullptr;
|
||||||
|
Split *split_ = nullptr;
|
||||||
|
|
||||||
Scrollbar *scrollBar_;
|
Scrollbar *scrollBar_;
|
||||||
EffectLabel *goToBottom_;
|
EffectLabel *goToBottom_;
|
||||||
|
@ -264,6 +280,8 @@ private:
|
||||||
Selection selection_;
|
Selection selection_;
|
||||||
bool selecting_ = false;
|
bool selecting_ = false;
|
||||||
|
|
||||||
|
const Context context_;
|
||||||
|
|
||||||
LimitedQueue<MessageLayoutPtr> messages_;
|
LimitedQueue<MessageLayoutPtr> messages_;
|
||||||
|
|
||||||
pajlada::Signals::SignalHolder signalHolder_;
|
pajlada::Signals::SignalHolder signalHolder_;
|
||||||
|
|
|
@ -49,8 +49,9 @@ ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName,
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchPopup::SearchPopup(QWidget *parent)
|
SearchPopup::SearchPopup(QWidget *parent, Split *split)
|
||||||
: BasePopup({}, parent)
|
: BasePopup({}, parent)
|
||||||
|
, split_(split)
|
||||||
{
|
{
|
||||||
this->initLayout();
|
this->initLayout();
|
||||||
this->resize(400, 600);
|
this->resize(400, 600);
|
||||||
|
@ -238,7 +239,8 @@ void SearchPopup::initLayout()
|
||||||
|
|
||||||
// CHANNELVIEW
|
// CHANNELVIEW
|
||||||
{
|
{
|
||||||
this->channelView_ = new ChannelView(this);
|
this->channelView_ = new ChannelView(this, this->split_,
|
||||||
|
ChannelView::Context::Search);
|
||||||
|
|
||||||
layout1->addWidget(this->channelView_);
|
layout1->addWidget(this->channelView_);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
#include "messages/LimitedQueueSnapshot.hpp"
|
#include "messages/LimitedQueueSnapshot.hpp"
|
||||||
#include "messages/search/MessagePredicate.hpp"
|
#include "messages/search/MessagePredicate.hpp"
|
||||||
#include "widgets/BasePopup.hpp"
|
#include "widgets/BasePopup.hpp"
|
||||||
|
#include "widgets/splits/Split.hpp"
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
|
@ -15,7 +16,7 @@ namespace chatterino {
|
||||||
class SearchPopup : public BasePopup
|
class SearchPopup : public BasePopup
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
SearchPopup(QWidget *parent);
|
SearchPopup(QWidget *parent, Split *split = nullptr);
|
||||||
|
|
||||||
virtual void addChannel(ChannelView &channel);
|
virtual void addChannel(ChannelView &channel);
|
||||||
|
|
||||||
|
@ -58,6 +59,7 @@ private:
|
||||||
QLineEdit *searchInput_{};
|
QLineEdit *searchInput_{};
|
||||||
ChannelView *channelView_{};
|
ChannelView *channelView_{};
|
||||||
QString channelName_{};
|
QString channelName_{};
|
||||||
|
Split *split_ = nullptr;
|
||||||
QList<std::reference_wrapper<ChannelView>> searchChannels_;
|
QList<std::reference_wrapper<ChannelView>> searchChannels_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -185,6 +185,7 @@ void GeneralPage::initLayout(GeneralPageView &layout)
|
||||||
tabDirectionDropdown->setMinimumWidth(
|
tabDirectionDropdown->setMinimumWidth(
|
||||||
tabDirectionDropdown->minimumSizeHint().width());
|
tabDirectionDropdown->minimumSizeHint().width());
|
||||||
|
|
||||||
|
layout.addCheckbox("Show message reply button", s.showReplyButton);
|
||||||
layout.addCheckbox("Show tab close button", s.showTabCloseButton);
|
layout.addCheckbox("Show tab close button", s.showTabCloseButton);
|
||||||
layout.addCheckbox("Always on top", s.windowTopMost);
|
layout.addCheckbox("Always on top", s.windowTopMost);
|
||||||
#ifdef USEWINSDK
|
#ifdef USEWINSDK
|
||||||
|
@ -641,6 +642,9 @@ void GeneralPage::initLayout(GeneralPageView &layout)
|
||||||
layout.addCheckbox("Show parted users (< 1000 chatters)", s.showParts);
|
layout.addCheckbox("Show parted users (< 1000 chatters)", s.showParts);
|
||||||
layout.addCheckbox("Automatically close user popup when it loses focus",
|
layout.addCheckbox("Automatically close user popup when it loses focus",
|
||||||
s.autoCloseUserPopup);
|
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("Lowercase domains (anti-phishing)", s.lowercaseDomains);
|
||||||
layout.addCheckbox("Bold @usernames", s.boldUsernames);
|
layout.addCheckbox("Bold @usernames", s.boldUsernames);
|
||||||
layout.addCheckbox("Color @usernames", s.colorUsernames);
|
layout.addCheckbox("Color @usernames", s.colorUsernames);
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
#include "controllers/commands/CommandController.hpp"
|
#include "controllers/commands/CommandController.hpp"
|
||||||
#include "controllers/hotkeys/HotkeyController.hpp"
|
#include "controllers/hotkeys/HotkeyController.hpp"
|
||||||
#include "controllers/notifications/NotificationController.hpp"
|
#include "controllers/notifications/NotificationController.hpp"
|
||||||
|
#include "messages/MessageThread.hpp"
|
||||||
#include "providers/twitch/EmoteValue.hpp"
|
#include "providers/twitch/EmoteValue.hpp"
|
||||||
#include "providers/twitch/TwitchChannel.hpp"
|
#include "providers/twitch/TwitchChannel.hpp"
|
||||||
#include "providers/twitch/TwitchIrcServer.hpp"
|
#include "providers/twitch/TwitchIrcServer.hpp"
|
||||||
|
@ -85,7 +86,7 @@ Split::Split(QWidget *parent)
|
||||||
, channel_(Channel::getEmpty())
|
, channel_(Channel::getEmpty())
|
||||||
, vbox_(new QVBoxLayout(this))
|
, vbox_(new QVBoxLayout(this))
|
||||||
, header_(new SplitHeader(this))
|
, header_(new SplitHeader(this))
|
||||||
, view_(new ChannelView(this))
|
, view_(new ChannelView(this, this))
|
||||||
, input_(new SplitInput(this))
|
, input_(new SplitInput(this))
|
||||||
, overlay_(new SplitOverlay(this))
|
, overlay_(new SplitOverlay(this))
|
||||||
{
|
{
|
||||||
|
@ -1164,7 +1165,7 @@ const QList<QUuid> Split::getFilters() const
|
||||||
|
|
||||||
void Split::showSearch(bool singleChannel)
|
void Split::showSearch(bool singleChannel)
|
||||||
{
|
{
|
||||||
auto *popup = new SearchPopup(this);
|
auto *popup = new SearchPopup(this, this);
|
||||||
popup->setAttribute(Qt::WA_DeleteOnClose);
|
popup->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
|
|
||||||
if (singleChannel)
|
if (singleChannel)
|
||||||
|
@ -1269,6 +1270,11 @@ void Split::drag()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Split::setInputReply(const std::shared_ptr<MessageThread> &reply)
|
||||||
|
{
|
||||||
|
this->input_->setReply(reply);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace chatterino
|
} // namespace chatterino
|
||||||
|
|
||||||
QDebug operator<<(QDebug dbg, const chatterino::Split &split)
|
QDebug operator<<(QDebug dbg, const chatterino::Split &split)
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
|
|
||||||
class ChannelView;
|
class ChannelView;
|
||||||
|
class MessageThread;
|
||||||
class SplitHeader;
|
class SplitHeader;
|
||||||
class SplitInput;
|
class SplitInput;
|
||||||
class SplitContainer;
|
class SplitContainer;
|
||||||
|
@ -74,6 +75,8 @@ public:
|
||||||
|
|
||||||
void setContainer(SplitContainer *container);
|
void setContainer(SplitContainer *container);
|
||||||
|
|
||||||
|
void setInputReply(const std::shared_ptr<MessageThread> &reply);
|
||||||
|
|
||||||
static pajlada::Signals::Signal<Qt::KeyboardModifiers>
|
static pajlada::Signals::Signal<Qt::KeyboardModifiers>
|
||||||
modifierStatusChanged;
|
modifierStatusChanged;
|
||||||
static Qt::KeyboardModifiers modifierStatus;
|
static Qt::KeyboardModifiers modifierStatus;
|
||||||
|
|
|
@ -23,15 +23,24 @@
|
||||||
#include "widgets/splits/SplitContainer.hpp"
|
#include "widgets/splits/SplitContainer.hpp"
|
||||||
#include "widgets/splits/SplitInput.hpp"
|
#include "widgets/splits/SplitInput.hpp"
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
#include <QCompleter>
|
#include <QCompleter>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
const int TWITCH_MESSAGE_LIMIT = 500;
|
const int TWITCH_MESSAGE_LIMIT = 500;
|
||||||
|
|
||||||
SplitInput::SplitInput(Split *_chatWidget)
|
SplitInput::SplitInput(Split *_chatWidget, bool enableInlineReplying)
|
||||||
: BaseWidget(_chatWidget)
|
: SplitInput(_chatWidget, _chatWidget, enableInlineReplying)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitInput::SplitInput(QWidget *parent, Split *_chatWidget,
|
||||||
|
bool enableInlineReplying)
|
||||||
|
: BaseWidget(parent)
|
||||||
, split_(_chatWidget)
|
, split_(_chatWidget)
|
||||||
|
, enableInlineReplying_(enableInlineReplying)
|
||||||
{
|
{
|
||||||
this->installEventFilter(this);
|
this->installEventFilter(this);
|
||||||
this->initLayout();
|
this->initLayout();
|
||||||
|
@ -66,17 +75,43 @@ void SplitInput::initLayout()
|
||||||
LayoutCreator<SplitInput> layoutCreator(this);
|
LayoutCreator<SplitInput> layoutCreator(this);
|
||||||
|
|
||||||
auto layout =
|
auto layout =
|
||||||
layoutCreator.setLayoutType<QHBoxLayout>().withoutMargin().assign(
|
layoutCreator.setLayoutType<QVBoxLayout>().withoutMargin().assign(
|
||||||
&this->ui_.hbox);
|
&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
|
// input
|
||||||
auto textEdit =
|
auto textEdit =
|
||||||
layout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
|
hboxLayout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
|
||||||
connect(textEdit.getElement(), &ResizingTextEdit::textChanged, this,
|
connect(textEdit.getElement(), &ResizingTextEdit::textChanged, this,
|
||||||
&SplitInput::editTextChanged);
|
&SplitInput::editTextChanged);
|
||||||
|
|
||||||
// right box
|
// right box
|
||||||
auto box = layout.emplace<QVBoxLayout>().withoutMargin();
|
auto box = hboxLayout.emplace<QVBoxLayout>().withoutMargin();
|
||||||
box->setSpacing(0);
|
box->setSpacing(0);
|
||||||
{
|
{
|
||||||
auto textEditLength =
|
auto textEditLength =
|
||||||
|
@ -102,6 +137,8 @@ void SplitInput::initLayout()
|
||||||
this->managedConnections_.managedConnect(app->fonts->fontChanged, [=]() {
|
this->managedConnections_.managedConnect(app->fonts->fontChanged, [=]() {
|
||||||
this->ui_.textEdit->setFont(
|
this->ui_.textEdit->setFont(
|
||||||
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
||||||
|
this->ui_.replyLabel->setFont(
|
||||||
|
app->fonts->getFont(FontStyle::ChatMediumBold, this->scale()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// open emote popup
|
// open emote popup
|
||||||
|
@ -109,6 +146,12 @@ void SplitInput::initLayout()
|
||||||
this->openEmotePopup();
|
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
|
// clear channelview selection when selecting in the input
|
||||||
QObject::connect(this->ui_.textEdit, &QTextEdit::copyAvailable,
|
QObject::connect(this->ui_.textEdit, &QTextEdit::copyAvailable,
|
||||||
[this](bool available) {
|
[this](bool available) {
|
||||||
|
@ -129,8 +172,10 @@ void SplitInput::initLayout()
|
||||||
|
|
||||||
void SplitInput::scaleChangedEvent(float scale)
|
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->updateEmoteButton();
|
||||||
|
this->updateCancelReplyButton();
|
||||||
|
|
||||||
// set maximum height
|
// set maximum height
|
||||||
if (!this->hidden)
|
if (!this->hidden)
|
||||||
|
@ -138,9 +183,11 @@ void SplitInput::scaleChangedEvent(float scale)
|
||||||
this->setMaximumHeight(this->scaledMaxHeight());
|
this->setMaximumHeight(this->scaledMaxHeight());
|
||||||
}
|
}
|
||||||
this->ui_.textEdit->setFont(
|
this->ui_.textEdit->setFont(
|
||||||
getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
|
||||||
this->ui_.textEditLength->setFont(
|
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()
|
void SplitInput::themeChangedEvent()
|
||||||
|
@ -155,6 +202,7 @@ void SplitInput::themeChangedEvent()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
this->updateEmoteButton();
|
this->updateEmoteButton();
|
||||||
|
this->updateCancelReplyButton();
|
||||||
this->ui_.textEditLength->setPalette(palette);
|
this->ui_.textEditLength->setPalette(palette);
|
||||||
|
|
||||||
this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet);
|
this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet);
|
||||||
|
@ -162,10 +210,19 @@ void SplitInput::themeChangedEvent()
|
||||||
this->ui_.textEdit->setPalette(placeholderPalette);
|
this->ui_.textEdit->setPalette(placeholderPalette);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
this->ui_.hbox->setMargin(
|
this->ui_.vbox->setMargin(
|
||||||
int((this->theme->isLightTheme() ? 4 : 2) * this->scale()));
|
int((this->theme->isLightTheme() ? 4 : 2) * this->scale()));
|
||||||
|
|
||||||
this->ui_.emoteButton->getLabel().setStyleSheet("color: #000");
|
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()
|
void SplitInput::updateEmoteButton()
|
||||||
|
@ -184,6 +241,24 @@ void SplitInput::updateEmoteButton()
|
||||||
this->ui_.emoteButton->setFixedHeight(int(18 * scale));
|
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()
|
void SplitInput::openEmotePopup()
|
||||||
{
|
{
|
||||||
if (!this->emotePopup_)
|
if (!this->emotePopup_)
|
||||||
|
@ -217,6 +292,79 @@ void SplitInput::openEmotePopup()
|
||||||
this->emotePopup_->activateWindow();
|
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
|
int SplitInput::scaledMaxHeight() const
|
||||||
{
|
{
|
||||||
return int(150 * this->scale());
|
return int(150 * this->scale());
|
||||||
|
@ -302,37 +450,7 @@ void SplitInput::addShortcuts()
|
||||||
}},
|
}},
|
||||||
{"sendMessage",
|
{"sendMessage",
|
||||||
[this](std::vector<QString> arguments) -> QString {
|
[this](std::vector<QString> arguments) -> QString {
|
||||||
auto c = this->split_->getChannel();
|
return this->handleSendMessage(arguments);
|
||||||
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 "";
|
|
||||||
}},
|
}},
|
||||||
{"previousMessage",
|
{"previousMessage",
|
||||||
[this](std::vector<QString>) -> QString {
|
[this](std::vector<QString>) -> QString {
|
||||||
|
@ -456,8 +574,7 @@ void SplitInput::addShortcuts()
|
||||||
}},
|
}},
|
||||||
{"clear",
|
{"clear",
|
||||||
[this](std::vector<QString>) -> QString {
|
[this](std::vector<QString>) -> QString {
|
||||||
this->ui_.textEdit->setText("");
|
this->clearInput();
|
||||||
this->ui_.textEdit->moveCursor(QTextCursor::Start);
|
|
||||||
return "";
|
return "";
|
||||||
}},
|
}},
|
||||||
{"selectAll",
|
{"selectAll",
|
||||||
|
@ -769,37 +886,71 @@ void SplitInput::editTextChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
this->ui_.textEditLength->setText(labelText);
|
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*/)
|
void SplitInput::paintEvent(QPaintEvent * /*event*/)
|
||||||
{
|
{
|
||||||
QPainter painter(this);
|
QPainter painter(this);
|
||||||
|
|
||||||
|
int s;
|
||||||
|
QColor borderColor;
|
||||||
|
|
||||||
if (this->theme->isLightTheme())
|
if (this->theme->isLightTheme())
|
||||||
{
|
{
|
||||||
int s = int(3 * this->scale());
|
s = int(3 * this->scale());
|
||||||
QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
|
borderColor = QColor("#ccc");
|
||||||
|
|
||||||
painter.fillRect(rect, this->theme->splits.input.background);
|
|
||||||
|
|
||||||
painter.setPen(QColor("#ccc"));
|
|
||||||
painter.drawRect(rect);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
int s = int(1 * this->scale());
|
s = int(1 * this->scale());
|
||||||
QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
|
borderColor = QColor("#333");
|
||||||
|
|
||||||
painter.fillRect(rect, this->theme->splits.input.background);
|
|
||||||
|
|
||||||
painter.setPen(QColor("#333"));
|
|
||||||
painter.drawRect(rect);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// int offset = 2;
|
QMargins removeMargins(s - 1, s - 1, s, s);
|
||||||
// painter.fillRect(offset, this->height() - offset, this->width() - 2 *
|
QRect baseRect = this->rect();
|
||||||
// offset, 1,
|
|
||||||
// getApp()->themes->splits.input.focusedLine);
|
// 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 *)
|
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
|
} // namespace chatterino
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
#include <QTextEdit>
|
#include <QTextEdit>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
|
|
||||||
|
@ -18,6 +19,7 @@ class Split;
|
||||||
class EmotePopup;
|
class EmotePopup;
|
||||||
class InputCompletionPopup;
|
class InputCompletionPopup;
|
||||||
class EffectLabel;
|
class EffectLabel;
|
||||||
|
class MessageThread;
|
||||||
class ResizingTextEdit;
|
class ResizingTextEdit;
|
||||||
|
|
||||||
class SplitInput : public BaseWidget
|
class SplitInput : public BaseWidget
|
||||||
|
@ -25,13 +27,19 @@ class SplitInput : public BaseWidget
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
SplitInput(Split *_chatWidget);
|
SplitInput(Split *_chatWidget, bool enableInlineReplying = true);
|
||||||
|
SplitInput(QWidget *parent, Split *_chatWidget,
|
||||||
|
bool enableInlineReplying = true);
|
||||||
|
|
||||||
void clearSelection();
|
void clearSelection();
|
||||||
bool isEditFirstWord() const;
|
bool isEditFirstWord() const;
|
||||||
QString getInputText() const;
|
QString getInputText() const;
|
||||||
void insertText(const QString &text);
|
void insertText(const QString &text);
|
||||||
|
|
||||||
|
void setReply(std::shared_ptr<MessageThread> reply,
|
||||||
|
bool showInlineReplying = true);
|
||||||
|
void setPlaceholderText(const QString &text);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Hide the widget
|
* @brief Hide the widget
|
||||||
*
|
*
|
||||||
|
@ -64,7 +72,16 @@ protected:
|
||||||
void paintEvent(QPaintEvent * /*event*/) override;
|
void paintEvent(QPaintEvent * /*event*/) override;
|
||||||
void resizeEvent(QResizeEvent * /*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 addShortcuts() override;
|
||||||
void initLayout();
|
void initLayout();
|
||||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||||
|
@ -78,6 +95,8 @@ private:
|
||||||
void insertCompletionText(const QString &text);
|
void insertCompletionText(const QString &text);
|
||||||
void openEmotePopup();
|
void openEmotePopup();
|
||||||
|
|
||||||
|
void updateCancelReplyButton();
|
||||||
|
|
||||||
// scaledMaxHeight returns the height in pixels that this widget can grow to
|
// 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
|
// This does not take hidden into account, so callers must take hidden into account themselves
|
||||||
int scaledMaxHeight() const;
|
int scaledMaxHeight() const;
|
||||||
|
@ -92,8 +111,17 @@ private:
|
||||||
EffectLabel *emoteButton;
|
EffectLabel *emoteButton;
|
||||||
|
|
||||||
QHBoxLayout *hbox;
|
QHBoxLayout *hbox;
|
||||||
|
QVBoxLayout *vbox;
|
||||||
|
|
||||||
|
QWidget *replyWrapper;
|
||||||
|
QHBoxLayout *replyHbox;
|
||||||
|
QLabel *replyLabel;
|
||||||
|
EffectLabel *cancelReplyButton;
|
||||||
} ui_;
|
} ui_;
|
||||||
|
|
||||||
|
std::shared_ptr<MessageThread> replyThread_ = nullptr;
|
||||||
|
bool enableInlineReplying_;
|
||||||
|
|
||||||
pajlada::Signals::SignalHolder managedConnections_;
|
pajlada::Signals::SignalHolder managedConnections_;
|
||||||
QStringList prevMsg_;
|
QStringList prevMsg_;
|
||||||
QString currMsg_;
|
QString currMsg_;
|
||||||
|
@ -109,6 +137,7 @@ private slots:
|
||||||
void editTextChanged();
|
void editTextChanged();
|
||||||
|
|
||||||
friend class Split;
|
friend class Split;
|
||||||
|
friend class ReplyThreadPopup;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace chatterino
|
} // namespace chatterino
|
||||||
|
|
Loading…
Reference in a new issue