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

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Daniel Sage
2022-07-31 06:45:25 -04:00
committed by GitHub
parent a280089693
commit 20c974fdab
53 changed files with 2022 additions and 310 deletions
+90
View File
@@ -0,0 +1,90 @@
#include "DraggablePopup.hpp"
#include <QMouseEvent>
#include <chrono>
namespace chatterino {
namespace {
#ifdef Q_OS_LINUX
FlagsEnum<BaseWindow::Flags> popupFlags{BaseWindow::Dialog,
BaseWindow::EnableCustomFrame};
FlagsEnum<BaseWindow::Flags> popupFlagsCloseAutomatically{
BaseWindow::EnableCustomFrame};
#else
FlagsEnum<BaseWindow::Flags> popupFlags{BaseWindow::EnableCustomFrame};
FlagsEnum<BaseWindow::Flags> popupFlagsCloseAutomatically{
BaseWindow::EnableCustomFrame, BaseWindow::Frameless,
BaseWindow::FramelessDraggable};
#endif
} // namespace
DraggablePopup::DraggablePopup(bool closeAutomatically, QWidget *parent)
: BaseWindow(closeAutomatically ? popupFlagsCloseAutomatically : popupFlags,
parent)
, lifetimeHack_(std::make_shared<bool>(false))
, dragTimer_(this)
{
if (closeAutomatically)
{
this->setActionOnFocusLoss(BaseWindow::Delete);
}
else
{
this->setAttribute(Qt::WA_DeleteOnClose);
}
// Update the window position according to this->requestedDragPos_ on every trigger
this->dragTimer_.callOnTimeout(
[this, hack = std::weak_ptr<bool>(this->lifetimeHack_)] {
if (!hack.lock())
{
// Ensure this timer is never called after the object has been destroyed
return;
}
if (!this->isMoving_)
{
return;
}
this->move(this->requestedDragPos_);
});
}
void DraggablePopup::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::MouseButton::LeftButton)
{
this->dragTimer_.start(std::chrono::milliseconds(17));
this->startPosDrag_ = event->pos();
this->movingRelativePos = event->localPos();
}
}
void DraggablePopup::mouseReleaseEvent(QMouseEvent *event)
{
this->dragTimer_.stop();
this->isMoving_ = false;
}
void DraggablePopup::mouseMoveEvent(QMouseEvent *event)
{
// Drag the window by the amount changed from inital position
// Note that we provide a few *units* of deadzone so people don't
// start dragging the window if they are slow at clicking.
auto movePos = event->pos() - this->startPosDrag_;
if (this->isMoving_ || movePos.manhattanLength() > 10.0)
{
this->requestedDragPos_ =
(event->screenPos() - this->movingRelativePos).toPoint();
this->isMoving_ = true;
}
}
} // namespace chatterino
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "widgets/BaseWindow.hpp"
#include <QPoint>
#include <QTimer>
#include <memory>
namespace chatterino {
class DraggablePopup : public BaseWindow
{
Q_OBJECT
public:
/// DraggablePopup implements the automatic dragging behavior when clicking
/// anywhere in the window (that doesn't have some other widget).
///
/// If closeAutomatically is set, the window will close when losing focus,
/// and the window will be frameless.
DraggablePopup(bool closeAutomatically, QWidget *parent);
protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
// lifetimeHack_ is used to check that the window hasn't been destroyed yet
std::shared_ptr<bool> lifetimeHack_;
private:
// isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_)
bool isMoving_ = false;
// startPosDrag_ is the coordinates where the user originally pressed the mouse button down to start dragging
QPoint startPosDrag_;
// requestDragPos_ is the final screen coordinates where the widget should be moved to.
// Takes the relative position of where the user originally clicked the widget into account
QPoint requestedDragPos_;
// dragTimer_ is called ~60 times per second once the user has initiated dragging
QTimer dragTimer_;
};
} // namespace chatterino
+193
View File
@@ -0,0 +1,193 @@
#include "ReplyThreadPopup.hpp"
#include "Application.hpp"
#include "common/Channel.hpp"
#include "common/QLogging.hpp"
#include "controllers/hotkeys/HotkeyController.hpp"
#include "messages/MessageThread.hpp"
#include "util/LayoutCreator.hpp"
#include "widgets/Scrollbar.hpp"
#include "widgets/helper/ChannelView.hpp"
#include "widgets/helper/ResizingTextEdit.hpp"
#include "widgets/splits/Split.hpp"
#include "widgets/splits/SplitInput.hpp"
const QString TEXT_TITLE("Reply Thread - @%1 in #%2");
namespace chatterino {
ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent,
Split *split)
: DraggablePopup(closeAutomatically, parent)
, split_(split)
{
this->setWindowTitle(QStringLiteral("Reply Thread"));
this->setStayInScreenRect(true);
HotkeyController::HotkeyMap actions{
{"delete",
[this](std::vector<QString>) -> QString {
this->deleteLater();
return "";
}},
{"scrollPage",
[this](std::vector<QString> arguments) -> QString {
if (arguments.empty())
{
qCWarning(chatterinoHotkeys)
<< "scrollPage hotkey called without arguments!";
return "scrollPage hotkey called without arguments!";
}
auto direction = arguments.at(0);
auto &scrollbar = this->ui_.threadView->getScrollBar();
if (direction == "up")
{
scrollbar.offset(-scrollbar.getLargeChange());
}
else if (direction == "down")
{
scrollbar.offset(scrollbar.getLargeChange());
}
else
{
qCWarning(chatterinoHotkeys) << "Unknown scroll direction";
}
return "";
}},
// these actions make no sense in the context of a reply thread, so they aren't implemented
{"execModeratorAction", nullptr},
{"reject", nullptr},
{"accept", nullptr},
{"openTab", nullptr},
{"search", nullptr},
};
this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory(
HotkeyCategory::PopupWindow, actions, this);
auto layout = LayoutCreator<QWidget>(this->getLayoutContainer())
.setLayoutType<QVBoxLayout>();
// initialize UI
this->ui_.threadView =
new ChannelView(this, this->split_, ChannelView::Context::ReplyThread);
this->ui_.threadView->setMinimumSize(400, 100);
this->ui_.threadView->setSizePolicy(QSizePolicy::Expanding,
QSizePolicy::Expanding);
this->ui_.threadView->mouseDown.connect([this](QMouseEvent *) {
this->giveFocus(Qt::MouseFocusReason);
});
// Create SplitInput with inline replying disabled
this->ui_.replyInput = new SplitInput(this, this->split_, false);
this->bSignals_.emplace_back(
getApp()->accounts->twitch.currentUserChanged.connect([this] {
this->updateInputUI();
}));
layout->setSpacing(0);
// provide draggable margin if frameless
layout->setMargin(closeAutomatically ? 15 : 1);
layout->addWidget(this->ui_.threadView, 1);
layout->addWidget(this->ui_.replyInput);
}
void ReplyThreadPopup::setThread(std::shared_ptr<MessageThread> thread)
{
this->thread_ = std::move(thread);
this->ui_.replyInput->setReply(this->thread_);
this->addMessagesFromThread();
this->updateInputUI();
}
void ReplyThreadPopup::addMessagesFromThread()
{
this->ui_.threadView->clearMessages();
if (!this->thread_)
{
return;
}
const auto &sourceChannel = this->split_->getChannel();
this->setWindowTitle(TEXT_TITLE.arg(this->thread_->root()->loginName,
sourceChannel->getName()));
ChannelPtr virtualChannel;
if (sourceChannel->isTwitchChannel())
{
virtualChannel =
std::make_shared<TwitchChannel>(sourceChannel->getName());
}
else
{
virtualChannel = std::make_shared<Channel>(sourceChannel->getName(),
Channel::Type::None);
}
this->ui_.threadView->setChannel(virtualChannel);
this->ui_.threadView->setSourceChannel(sourceChannel);
virtualChannel->addMessage(this->thread_->root());
for (const auto &msgRef : this->thread_->replies())
{
if (auto msg = msgRef.lock())
{
virtualChannel->addMessage(msg);
}
}
this->messageConnection_ =
std::make_unique<pajlada::Signals::ScopedConnection>(
sourceChannel->messageAppended.connect(
[this, virtualChannel](MessagePtr &message, auto) {
if (message->replyThread == this->thread_)
{
// same reply thread, add message
virtualChannel->addMessage(message);
}
}));
}
void ReplyThreadPopup::updateInputUI()
{
auto channel = this->split_->getChannel();
// Bail out if not a twitch channel.
// Special twitch channels will hide their reply input box.
if (!channel || !channel->isTwitchChannel())
{
return;
}
this->ui_.replyInput->setVisible(channel->isWritable());
auto user = getApp()->accounts->twitch.getCurrent();
QString placeholderText;
if (user->isAnon())
{
placeholderText = QStringLiteral("Log in to send messages...");
}
else
{
placeholderText =
QStringLiteral("Reply as %1...")
.arg(getApp()->accounts->twitch.getCurrent()->getUserName());
}
this->ui_.replyInput->setPlaceholderText(placeholderText);
}
void ReplyThreadPopup::giveFocus(Qt::FocusReason reason)
{
this->ui_.replyInput->giveFocus(reason);
}
void ReplyThreadPopup::focusInEvent(QFocusEvent *event)
{
this->giveFocus(event->reason());
}
} // namespace chatterino
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include "ForwardDecl.hpp"
#include "widgets/DraggablePopup.hpp"
#include <boost/signals2.hpp>
#include <pajlada/signals/scoped-connection.hpp>
#include <pajlada/signals/signal.hpp>
namespace chatterino {
class MessageThread;
class Split;
class SplitInput;
class ReplyThreadPopup final : public DraggablePopup
{
Q_OBJECT
public:
ReplyThreadPopup(bool closeAutomatically, QWidget *parent, Split *split);
void setThread(std::shared_ptr<MessageThread> thread);
void giveFocus(Qt::FocusReason reason);
protected:
void focusInEvent(QFocusEvent *event) override;
private:
void addMessagesFromThread();
void updateInputUI();
// The message reply thread
std::shared_ptr<MessageThread> thread_;
// The channel that the reply thread is in
ChannelPtr channel_;
Split *split_;
struct {
ChannelView *threadView = nullptr;
SplitInput *replyInput = nullptr;
} ui_;
std::unique_ptr<pajlada::Signals::ScopedConnection> messageConnection_;
std::vector<boost::signals2::scoped_connection> bSignals_;
};
} // namespace chatterino
+17 -72
View File
@@ -91,8 +91,16 @@ namespace {
LimitedQueueSnapshot<MessagePtr> snapshot =
channel->getMessageSnapshot();
ChannelPtr channelPtr(
new Channel(channel->getName(), Channel::Type::None));
ChannelPtr channelPtr;
if (channel->isTwitchChannel())
{
channelPtr = std::make_shared<TwitchChannel>(channel->getName());
}
else
{
channelPtr = std::make_shared<Channel>(channel->getName(),
Channel::Type::None);
}
for (size_t i = 0; i < snapshot.size(); i++)
{
@@ -118,33 +126,14 @@ namespace {
} // namespace
#ifdef Q_OS_LINUX
FlagsEnum<BaseWindow::Flags> userInfoPopupFlags{BaseWindow::Dialog,
BaseWindow::EnableCustomFrame};
FlagsEnum<BaseWindow::Flags> userInfoPopupFlagsCloseAutomatically{
BaseWindow::EnableCustomFrame};
#else
FlagsEnum<BaseWindow::Flags> userInfoPopupFlags{BaseWindow::EnableCustomFrame};
FlagsEnum<BaseWindow::Flags> userInfoPopupFlagsCloseAutomatically{
BaseWindow::EnableCustomFrame, BaseWindow::Frameless,
BaseWindow::FramelessDraggable};
#endif
UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
: BaseWindow(closeAutomatically ? userInfoPopupFlagsCloseAutomatically
: userInfoPopupFlags,
parent)
, hack_(new bool)
, dragTimer_(this)
UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent,
Split *split)
: DraggablePopup(closeAutomatically, parent)
, split_(split)
{
this->setWindowTitle("Usercard");
this->setStayInScreenRect(true);
if (closeAutomatically)
this->setActionOnFocusLoss(BaseWindow::Delete);
else
this->setAttribute(Qt::WA_DeleteOnClose);
HotkeyController::HotkeyMap actions{
{"delete",
[this](std::vector<QString>) -> QString {
@@ -498,7 +487,8 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
this->ui_.noMessagesLabel = new Label("No recent messages");
this->ui_.noMessagesLabel->setVisible(false);
this->ui_.latestMessages = new ChannelView(this);
this->ui_.latestMessages =
new ChannelView(this, this->split_, ChannelView::Context::UserCard);
this->ui_.latestMessages->setMinimumSize(400, 275);
this->ui_.latestMessages->setSizePolicy(QSizePolicy::Expanding,
QSizePolicy::Expanding);
@@ -510,21 +500,6 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent)
this->installEvents();
this->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Policy::Ignored);
this->dragTimer_.callOnTimeout(
[this, hack = std::weak_ptr<bool>(this->hack_)] {
if (!hack.lock())
{
// Ensure this timer is never called after the object has been destroyed
return;
}
if (!this->isMoving_)
{
return;
}
this->move(this->requestedDragPos_);
});
}
void UserInfoPopup::themeChangedEvent()
@@ -550,36 +525,6 @@ void UserInfoPopup::scaleChangedEvent(float /*scale*/)
});
}
void UserInfoPopup::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::MouseButton::LeftButton)
{
this->dragTimer_.start(std::chrono::milliseconds(17));
this->startPosDrag_ = event->pos();
this->movingRelativePos = event->localPos();
}
}
void UserInfoPopup::mouseReleaseEvent(QMouseEvent *event)
{
this->dragTimer_.stop();
this->isMoving_ = false;
}
void UserInfoPopup::mouseMoveEvent(QMouseEvent *event)
{
// Drag the window by the amount changed from inital position
// Note that we provide a few *units* of deadzone so people don't
// start dragging the window if they are slow at clicking.
auto movePos = event->pos() - this->startPosDrag_;
if (this->isMoving_ || movePos.manhattanLength() > 10.0)
{
this->requestedDragPos_ =
(event->screenPos() - this->movingRelativePos).toPoint();
this->isMoving_ = true;
}
}
void UserInfoPopup::installEvents()
{
std::shared_ptr<bool> ignoreNext = std::make_shared<bool>(false);
@@ -765,7 +710,7 @@ void UserInfoPopup::updateLatestMessages()
void UserInfoPopup::updateUserData()
{
std::weak_ptr<bool> hack = this->hack_;
std::weak_ptr<bool> hack = this->lifetimeHack_;
auto currentUser = getApp()->accounts->twitch.getCurrent();
const auto onUserFetchFailed = [this, hack] {
+6 -21
View File
@@ -1,6 +1,6 @@
#pragma once
#include "widgets/BaseWindow.hpp"
#include "widgets/DraggablePopup.hpp"
#include "widgets/helper/ChannelView.hpp"
#include <pajlada/signals/scoped-connection.hpp>
@@ -16,12 +16,13 @@ class Channel;
using ChannelPtr = std::shared_ptr<Channel>;
class Label;
class UserInfoPopup final : public BaseWindow
class UserInfoPopup final : public DraggablePopup
{
Q_OBJECT
public:
UserInfoPopup(bool closeAutomatically, QWidget *parent);
UserInfoPopup(bool closeAutomatically, QWidget *parent,
Split *split = nullptr);
void setData(const QString &name, const ChannelPtr &channel);
void setData(const QString &name, const ChannelPtr &contextChannel,
@@ -30,9 +31,6 @@ public:
protected:
virtual void themeChangedEvent() override;
virtual void scaleChangedEvent(float scale) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
private:
void installEvents();
@@ -43,6 +41,8 @@ private:
bool isMod_;
bool isBroadcaster_;
Split *split_;
QString userName_;
QString userId_;
QString avatarUrl_;
@@ -51,25 +51,10 @@ private:
// The channel the messages are rendered from (e.g. #forsen). Can be a special channel, but will try to not be where possible.
ChannelPtr underlyingChannel_;
// isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_)
bool isMoving_ = false;
// startPosDrag_ is the coordinates where the user originally pressed the mouse button down to start dragging
QPoint startPosDrag_;
// requestDragPos_ is the final screen coordinates where the widget should be moved to.
// Takes the relative position of where the user originally clicked the widget into account
QPoint requestedDragPos_;
// dragTimer_ is called ~60 times per second once the user has initiated dragging
QTimer dragTimer_;
pajlada::Signals::NoArgSignal userStateChanged_;
std::unique_ptr<pajlada::Signals::ScopedConnection> refreshConnection_;
std::shared_ptr<bool> hack_;
struct {
Button *avatarButton = nullptr;
Button *localizedNameCopyButton = nullptr;
+146 -5
View File
@@ -44,6 +44,7 @@
#include "widgets/Scrollbar.hpp"
#include "widgets/TooltipWidget.hpp"
#include "widgets/Window.hpp"
#include "widgets/dialogs/ReplyThreadPopup.hpp"
#include "widgets/dialogs/SettingsDialog.hpp"
#include "widgets/dialogs/UserInfoPopup.hpp"
#include "widgets/helper/EffectLabel.hpp"
@@ -119,9 +120,11 @@ namespace {
}
} // namespace
ChannelView::ChannelView(BaseWidget *parent)
ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context)
: BaseWidget(parent)
, scrollBar_(new Scrollbar(this))
, split_(split)
, context_(context)
{
this->setMouseTracking(true);
@@ -160,7 +163,7 @@ ChannelView::ChannelView(BaseWidget *parent)
// and tabbing to it from another widget. I don't currently know
// of any place where you can, or where it would make sense,
// to tab to a ChannelVieChannelView
this->setFocusPolicy(Qt::FocusPolicy::StrongFocus);
this->setFocusPolicy(Qt::FocusPolicy::ClickFocus);
}
void ChannelView::initializeLayout()
@@ -1019,6 +1022,17 @@ MessageElementFlags ChannelView::getFlags() const
if (this->sourceChannel_ == app->twitch->mentionsChannel)
flags.set(MessageElementFlag::ChannelName);
if (this->context_ == Context::ReplyThread)
{
// Don't show inline replies within the ReplyThreadPopup
flags.unset(MessageElementFlag::RepliedMessage);
}
if (!this->canReplyToMessages())
{
flags.unset(MessageElementFlag::ReplyButton);
}
return flags;
}
@@ -1983,10 +1997,27 @@ void ChannelView::addMessageContextMenuItems(
menu.addAction("Copy full message", [layout] {
QString copyString;
layout->addSelectionText(copyString);
layout->addSelectionText(copyString, 0, INT_MAX,
CopyMode::EverythingButReplies);
crossPlatformCopy(copyString);
});
// Only display reply option where it makes sense
if (this->canReplyToMessages() && layout->isReplyable())
{
const auto &messagePtr = layout->getMessagePtr();
menu.addAction("Reply to message", [this, &messagePtr] {
this->setInputReply(messagePtr);
});
if (messagePtr->replyThread != nullptr)
{
menu.addAction("View thread", [this, &messagePtr] {
this->showReplyThreadPopup(messagePtr);
});
}
}
}
void ChannelView::addTwitchLinkContextMenuItems(
@@ -2224,8 +2255,8 @@ void ChannelView::showUserInfoPopup(const QString &userName,
{
auto *userCardParent =
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()));
auto *userPopup =
new UserInfoPopup(getSettings()->autoCloseUserPopup, userCardParent);
auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup,
userCardParent, this->split_);
auto contextChannel =
getApp()->twitch->getChannelOrEmpty(alternativePopoutChannel);
@@ -2351,6 +2382,14 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link,
this->underlyingChannel_.get()->reconnect();
}
break;
case Link::ReplyToMessage: {
this->setInputReply(layout->getMessagePtr());
}
break;
case Link::ViewThread: {
this->showReplyThreadPopup(layout->getMessagePtr());
}
break;
default:;
}
@@ -2473,4 +2512,106 @@ void ChannelView::scrollUpdateRequested()
this->scrollBar_->offset(multiplier * offset);
}
void ChannelView::setInputReply(const MessagePtr &message)
{
if (message == nullptr)
{
return;
}
std::shared_ptr<MessageThread> thread;
if (message->replyThread == nullptr)
{
auto getThread = [&](TwitchChannel *tc) {
auto threadIt = tc->threads().find(message->id);
if (threadIt != tc->threads().end() && !threadIt->second.expired())
{
return threadIt->second.lock();
}
else
{
auto thread = std::make_shared<MessageThread>(message);
tc->addReplyThread(thread);
return thread;
}
};
if (auto tc =
dynamic_cast<TwitchChannel *>(this->underlyingChannel_.get()))
{
thread = getThread(tc);
}
else if (auto tc = dynamic_cast<TwitchChannel *>(this->channel_.get()))
{
thread = getThread(tc);
}
else
{
qCWarning(chatterinoCommon) << "Failed to create new reply thread";
// Unable to create new reply thread.
// TODO(dnsge): Should probably notify user?
return;
}
}
else
{
thread = message->replyThread;
}
this->split_->setInputReply(thread);
}
void ChannelView::showReplyThreadPopup(const MessagePtr &message)
{
if (message == nullptr || message->replyThread == nullptr)
{
return;
}
auto popupParent =
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()));
auto popup = new ReplyThreadPopup(getSettings()->autoCloseThreadPopup,
popupParent, this->split_);
popup->setThread(message->replyThread);
QPoint offset(int(150 * this->scale()), int(70 * this->scale()));
popup->move(QCursor::pos() - offset);
popup->show();
popup->giveFocus(Qt::MouseFocusReason);
}
ChannelView::Context ChannelView::getContext() const
{
return this->context_;
}
bool ChannelView::canReplyToMessages() const
{
if (this->context_ == ChannelView::Context::ReplyThread ||
this->context_ == ChannelView::Context::Search)
{
return false;
}
if (this->channel_ == nullptr)
{
return false;
}
if (!this->channel_->isTwitchChannel())
{
return false;
}
if (this->channel_->getType() == Channel::Type::TwitchWhispers ||
this->channel_->getType() == Channel::Type::TwitchLive)
{
return false;
}
return true;
}
} // namespace chatterino
+19 -1
View File
@@ -39,6 +39,7 @@ class Scrollbar;
class EffectLabel;
struct Link;
class MessageLayoutElement;
class Split;
enum class PauseReason {
Mouse,
@@ -61,7 +62,15 @@ class ChannelView final : public BaseWidget
Q_OBJECT
public:
explicit ChannelView(BaseWidget *parent = nullptr);
enum class Context {
None,
UserCard,
ReplyThread,
Search,
};
explicit ChannelView(BaseWidget *parent = nullptr, Split *split = nullptr,
Context context = Context::None);
void queueUpdate();
Scrollbar &getScrollBar();
@@ -99,6 +108,8 @@ public:
void clearMessages();
Context getContext() const;
/**
* @brief Creates and shows a UserInfoPopup dialog
*
@@ -196,6 +207,10 @@ private:
void enableScrolling(const QPointF &scrollStart);
void disableScrolling();
void setInputReply(const MessagePtr &message);
void showReplyThreadPopup(const MessagePtr &message);
bool canReplyToMessages() const;
QTimer *layoutCooldown_;
bool layoutQueued_;
@@ -221,6 +236,7 @@ private:
ChannelPtr channel_ = nullptr;
ChannelPtr underlyingChannel_ = nullptr;
ChannelPtr sourceChannel_ = nullptr;
Split *split_ = nullptr;
Scrollbar *scrollBar_;
EffectLabel *goToBottom_;
@@ -264,6 +280,8 @@ private:
Selection selection_;
bool selecting_ = false;
const Context context_;
LimitedQueue<MessageLayoutPtr> messages_;
pajlada::Signals::SignalHolder signalHolder_;
+4 -2
View File
@@ -49,8 +49,9 @@ ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName,
return channel;
}
SearchPopup::SearchPopup(QWidget *parent)
SearchPopup::SearchPopup(QWidget *parent, Split *split)
: BasePopup({}, parent)
, split_(split)
{
this->initLayout();
this->resize(400, 600);
@@ -238,7 +239,8 @@ void SearchPopup::initLayout()
// CHANNELVIEW
{
this->channelView_ = new ChannelView(this);
this->channelView_ = new ChannelView(this, this->split_,
ChannelView::Context::Search);
layout1->addWidget(this->channelView_);
}
+3 -1
View File
@@ -5,6 +5,7 @@
#include "messages/LimitedQueueSnapshot.hpp"
#include "messages/search/MessagePredicate.hpp"
#include "widgets/BasePopup.hpp"
#include "widgets/splits/Split.hpp"
#include <memory>
@@ -15,7 +16,7 @@ namespace chatterino {
class SearchPopup : public BasePopup
{
public:
SearchPopup(QWidget *parent);
SearchPopup(QWidget *parent, Split *split = nullptr);
virtual void addChannel(ChannelView &channel);
@@ -58,6 +59,7 @@ private:
QLineEdit *searchInput_{};
ChannelView *channelView_{};
QString channelName_{};
Split *split_ = nullptr;
QList<std::reference_wrapper<ChannelView>> searchChannels_;
};
@@ -185,6 +185,7 @@ void GeneralPage::initLayout(GeneralPageView &layout)
tabDirectionDropdown->setMinimumWidth(
tabDirectionDropdown->minimumSizeHint().width());
layout.addCheckbox("Show message reply button", s.showReplyButton);
layout.addCheckbox("Show tab close button", s.showTabCloseButton);
layout.addCheckbox("Always on top", s.windowTopMost);
#ifdef USEWINSDK
@@ -641,6 +642,9 @@ void GeneralPage::initLayout(GeneralPageView &layout)
layout.addCheckbox("Show parted users (< 1000 chatters)", s.showParts);
layout.addCheckbox("Automatically close user popup when it loses focus",
s.autoCloseUserPopup);
layout.addCheckbox(
"Automatically close reply thread popup when it loses focus",
s.autoCloseThreadPopup);
layout.addCheckbox("Lowercase domains (anti-phishing)", s.lowercaseDomains);
layout.addCheckbox("Bold @usernames", s.boldUsernames);
layout.addCheckbox("Color @usernames", s.colorUsernames);
+8 -2
View File
@@ -9,6 +9,7 @@
#include "controllers/commands/CommandController.hpp"
#include "controllers/hotkeys/HotkeyController.hpp"
#include "controllers/notifications/NotificationController.hpp"
#include "messages/MessageThread.hpp"
#include "providers/twitch/EmoteValue.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
@@ -85,7 +86,7 @@ Split::Split(QWidget *parent)
, channel_(Channel::getEmpty())
, vbox_(new QVBoxLayout(this))
, header_(new SplitHeader(this))
, view_(new ChannelView(this))
, view_(new ChannelView(this, this))
, input_(new SplitInput(this))
, overlay_(new SplitOverlay(this))
{
@@ -1164,7 +1165,7 @@ const QList<QUuid> Split::getFilters() const
void Split::showSearch(bool singleChannel)
{
auto *popup = new SearchPopup(this);
auto *popup = new SearchPopup(this, this);
popup->setAttribute(Qt::WA_DeleteOnClose);
if (singleChannel)
@@ -1269,6 +1270,11 @@ void Split::drag()
}
}
void Split::setInputReply(const std::shared_ptr<MessageThread> &reply)
{
this->input_->setReply(reply);
}
} // namespace chatterino
QDebug operator<<(QDebug dbg, const chatterino::Split &split)
+3
View File
@@ -15,6 +15,7 @@
namespace chatterino {
class ChannelView;
class MessageThread;
class SplitHeader;
class SplitInput;
class SplitContainer;
@@ -74,6 +75,8 @@ public:
void setContainer(SplitContainer *container);
void setInputReply(const std::shared_ptr<MessageThread> &reply);
static pajlada::Signals::Signal<Qt::KeyboardModifiers>
modifierStatusChanged;
static Qt::KeyboardModifiers modifierStatus;
+249 -61
View File
@@ -23,15 +23,24 @@
#include "widgets/splits/SplitContainer.hpp"
#include "widgets/splits/SplitInput.hpp"
#include <functional>
#include <QCompleter>
#include <QPainter>
namespace chatterino {
const int TWITCH_MESSAGE_LIMIT = 500;
SplitInput::SplitInput(Split *_chatWidget)
: BaseWidget(_chatWidget)
SplitInput::SplitInput(Split *_chatWidget, bool enableInlineReplying)
: SplitInput(_chatWidget, _chatWidget, enableInlineReplying)
{
}
SplitInput::SplitInput(QWidget *parent, Split *_chatWidget,
bool enableInlineReplying)
: BaseWidget(parent)
, split_(_chatWidget)
, enableInlineReplying_(enableInlineReplying)
{
this->installEventFilter(this);
this->initLayout();
@@ -66,17 +75,43 @@ void SplitInput::initLayout()
LayoutCreator<SplitInput> layoutCreator(this);
auto layout =
layoutCreator.setLayoutType<QHBoxLayout>().withoutMargin().assign(
&this->ui_.hbox);
layoutCreator.setLayoutType<QVBoxLayout>().withoutMargin().assign(
&this->ui_.vbox);
// reply label stuff
auto replyWrapper =
layout.emplace<QWidget>().assign(&this->ui_.replyWrapper);
this->ui_.replyWrapper->setContentsMargins(0, 0, 0, 0);
auto replyHbox = replyWrapper.emplace<QHBoxLayout>().withoutMargin().assign(
&this->ui_.replyHbox);
auto replyLabel = replyHbox.emplace<QLabel>().assign(&this->ui_.replyLabel);
replyLabel->setAlignment(Qt::AlignLeft);
replyLabel->setFont(
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
replyHbox->addStretch(1);
auto replyCancelButton = replyHbox.emplace<EffectLabel>(nullptr, 4)
.assign(&this->ui_.cancelReplyButton);
replyCancelButton->getLabel().setTextFormat(Qt::RichText);
replyCancelButton->hide();
replyLabel->hide();
// hbox for input, right box
auto hboxLayout =
layout.emplace<QHBoxLayout>().withoutMargin().assign(&this->ui_.hbox);
// input
auto textEdit =
layout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
hboxLayout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
connect(textEdit.getElement(), &ResizingTextEdit::textChanged, this,
&SplitInput::editTextChanged);
// right box
auto box = layout.emplace<QVBoxLayout>().withoutMargin();
auto box = hboxLayout.emplace<QVBoxLayout>().withoutMargin();
box->setSpacing(0);
{
auto textEditLength =
@@ -102,6 +137,8 @@ void SplitInput::initLayout()
this->managedConnections_.managedConnect(app->fonts->fontChanged, [=]() {
this->ui_.textEdit->setFont(
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
this->ui_.replyLabel->setFont(
app->fonts->getFont(FontStyle::ChatMediumBold, this->scale()));
});
// open emote popup
@@ -109,6 +146,12 @@ void SplitInput::initLayout()
this->openEmotePopup();
});
// clear input and remove reply thread
QObject::connect(this->ui_.cancelReplyButton, &EffectLabel::leftClicked,
[=] {
this->clearInput();
});
// clear channelview selection when selecting in the input
QObject::connect(this->ui_.textEdit, &QTextEdit::copyAvailable,
[this](bool available) {
@@ -129,8 +172,10 @@ void SplitInput::initLayout()
void SplitInput::scaleChangedEvent(float scale)
{
// update the icon size of the emote button
auto app = getApp();
// update the icon size of the buttons
this->updateEmoteButton();
this->updateCancelReplyButton();
// set maximum height
if (!this->hidden)
@@ -138,9 +183,11 @@ void SplitInput::scaleChangedEvent(float scale)
this->setMaximumHeight(this->scaledMaxHeight());
}
this->ui_.textEdit->setFont(
getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale()));
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
this->ui_.textEditLength->setFont(
getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale()));
app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
this->ui_.replyLabel->setFont(
app->fonts->getFont(FontStyle::ChatMediumBold, this->scale()));
}
void SplitInput::themeChangedEvent()
@@ -155,6 +202,7 @@ void SplitInput::themeChangedEvent()
#endif
this->updateEmoteButton();
this->updateCancelReplyButton();
this->ui_.textEditLength->setPalette(palette);
this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet);
@@ -162,10 +210,19 @@ void SplitInput::themeChangedEvent()
this->ui_.textEdit->setPalette(placeholderPalette);
#endif
this->ui_.hbox->setMargin(
this->ui_.vbox->setMargin(
int((this->theme->isLightTheme() ? 4 : 2) * this->scale()));
this->ui_.emoteButton->getLabel().setStyleSheet("color: #000");
if (this->theme->isLightTheme())
{
this->ui_.replyLabel->setStyleSheet("color: #333");
}
else
{
this->ui_.replyLabel->setStyleSheet("color: #ccc");
}
}
void SplitInput::updateEmoteButton()
@@ -184,6 +241,24 @@ void SplitInput::updateEmoteButton()
this->ui_.emoteButton->setFixedHeight(int(18 * scale));
}
void SplitInput::updateCancelReplyButton()
{
float scale = this->scale();
QString text =
QStringLiteral(
"<img src=':/buttons/cancel.svg' width='%1' height='%1' />")
.arg(QString::number(int(12 * scale)));
if (this->theme->isLightTheme())
{
text.replace("cancel", "cancelDark");
}
this->ui_.cancelReplyButton->getLabel().setText(text);
this->ui_.cancelReplyButton->setFixedHeight(int(12 * scale));
}
void SplitInput::openEmotePopup()
{
if (!this->emotePopup_)
@@ -217,6 +292,79 @@ void SplitInput::openEmotePopup()
this->emotePopup_->activateWindow();
}
QString SplitInput::handleSendMessage(std::vector<QString> &arguments)
{
auto c = this->split_->getChannel();
if (c == nullptr)
return "";
if (!c->isTwitchChannel() || this->replyThread_ == nullptr)
{
// standard message send behavior
QString message = ui_.textEdit->toPlainText();
message = message.replace('\n', ' ');
QString sendMessage =
getApp()->commands->execCommand(message, c, false);
c->sendMessage(sendMessage);
this->postMessageSend(message, arguments);
return "";
}
else
{
// Reply to message
auto tc = dynamic_cast<TwitchChannel *>(c.get());
if (!tc)
{
// this should not fail
return "";
}
QString message = this->ui_.textEdit->toPlainText();
if (this->enableInlineReplying_)
{
// Remove @username prefix that is inserted when doing inline replies
message.remove(0, this->replyThread_->root()->displayName.length() +
1); // remove "@username"
if (!message.isEmpty() && message.at(0) == ' ')
{
message.remove(0, 1); // remove possible space
}
}
message = message.replace('\n', ' ');
QString sendMessage =
getApp()->commands->execCommand(message, c, false);
// Reply within TwitchChannel
tc->sendReply(sendMessage, this->replyThread_->rootId());
this->postMessageSend(message, arguments);
return "";
}
}
void SplitInput::postMessageSend(const QString &message,
const std::vector<QString> &arguments)
{
// don't add duplicate messages and empty message to message history
if ((this->prevMsg_.isEmpty() || !this->prevMsg_.endsWith(message)) &&
!message.trimmed().isEmpty())
{
this->prevMsg_.append(message);
}
if (arguments.empty() || arguments.at(0) != "keepInput")
{
this->clearInput();
}
this->prevIndex_ = this->prevMsg_.size();
}
int SplitInput::scaledMaxHeight() const
{
return int(150 * this->scale());
@@ -302,37 +450,7 @@ void SplitInput::addShortcuts()
}},
{"sendMessage",
[this](std::vector<QString> arguments) -> QString {
auto c = this->split_->getChannel();
if (c == nullptr)
return "";
QString message = ui_.textEdit->toPlainText();
message = message.replace('\n', ' ');
QString sendMessage =
getApp()->commands->execCommand(message, c, false);
c->sendMessage(sendMessage);
// don't add duplicate messages and empty message to message history
if ((this->prevMsg_.isEmpty() ||
!this->prevMsg_.endsWith(message)) &&
!message.trimmed().isEmpty())
{
this->prevMsg_.append(message);
}
bool shouldClearInput = true;
if (arguments.size() != 0 && arguments.at(0) == "keepInput")
{
shouldClearInput = false;
}
if (shouldClearInput)
{
this->currMsg_ = QString();
this->ui_.textEdit->setPlainText(QString());
}
this->prevIndex_ = this->prevMsg_.size();
return "";
return this->handleSendMessage(arguments);
}},
{"previousMessage",
[this](std::vector<QString>) -> QString {
@@ -456,8 +574,7 @@ void SplitInput::addShortcuts()
}},
{"clear",
[this](std::vector<QString>) -> QString {
this->ui_.textEdit->setText("");
this->ui_.textEdit->moveCursor(QTextCursor::Start);
this->clearInput();
return "";
}},
{"selectAll",
@@ -769,37 +886,71 @@ void SplitInput::editTextChanged()
}
this->ui_.textEditLength->setText(labelText);
bool hasReply = false;
if (this->enableInlineReplying_)
{
if (this->replyThread_ != nullptr)
{
// Check if the input still starts with @username. If not, don't reply.
//
// We need to verify that
// 1. the @username prefix exists and
// 2. if a character exists after the @username, it is a space
QString replyPrefix = "@" + this->replyThread_->root()->displayName;
if (!text.startsWith(replyPrefix) ||
(text.length() > replyPrefix.length() &&
text.at(replyPrefix.length()) != ' '))
{
this->replyThread_ = nullptr;
}
}
// Show/hide reply label if inline replies are possible
hasReply = this->replyThread_ != nullptr;
}
this->ui_.replyWrapper->setVisible(hasReply);
this->ui_.replyLabel->setVisible(hasReply);
this->ui_.cancelReplyButton->setVisible(hasReply);
}
void SplitInput::paintEvent(QPaintEvent * /*event*/)
{
QPainter painter(this);
int s;
QColor borderColor;
if (this->theme->isLightTheme())
{
int s = int(3 * this->scale());
QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
painter.fillRect(rect, this->theme->splits.input.background);
painter.setPen(QColor("#ccc"));
painter.drawRect(rect);
s = int(3 * this->scale());
borderColor = QColor("#ccc");
}
else
{
int s = int(1 * this->scale());
QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
painter.fillRect(rect, this->theme->splits.input.background);
painter.setPen(QColor("#333"));
painter.drawRect(rect);
s = int(1 * this->scale());
borderColor = QColor("#333");
}
// int offset = 2;
// painter.fillRect(offset, this->height() - offset, this->width() - 2 *
// offset, 1,
// getApp()->themes->splits.input.focusedLine);
QMargins removeMargins(s - 1, s - 1, s, s);
QRect baseRect = this->rect();
// completeAreaRect includes the reply label
QRect completeAreaRect = baseRect.marginsRemoved(removeMargins);
painter.fillRect(completeAreaRect, this->theme->splits.input.background);
painter.setPen(borderColor);
painter.drawRect(completeAreaRect);
if (this->enableInlineReplying_ && this->replyThread_ != nullptr)
{
// Move top of rect down to not include reply label
baseRect.setTop(baseRect.top() + this->ui_.replyWrapper->height());
QRect onlyInputRect = baseRect.marginsRemoved(removeMargins);
painter.setPen(borderColor);
painter.drawRect(onlyInputRect);
}
}
void SplitInput::resizeEvent(QResizeEvent *)
@@ -814,4 +965,41 @@ void SplitInput::resizeEvent(QResizeEvent *)
}
}
void SplitInput::giveFocus(Qt::FocusReason reason)
{
this->ui_.textEdit->setFocus(reason);
}
void SplitInput::setReply(std::shared_ptr<MessageThread> reply,
bool showReplyingLabel)
{
this->replyThread_ = std::move(reply);
if (this->enableInlineReplying_)
{
// Only enable reply label if inline replying
this->ui_.textEdit->setPlainText(
"@" + this->replyThread_->root()->displayName + " ");
this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock);
this->ui_.replyLabel->setText("Replying to @" +
this->replyThread_->root()->displayName);
}
}
void SplitInput::setPlaceholderText(const QString &text)
{
this->ui_.textEdit->setPlaceholderText(text);
}
void SplitInput::clearInput()
{
this->currMsg_ = "";
this->ui_.textEdit->setText("");
this->ui_.textEdit->moveCursor(QTextCursor::Start);
if (this->enableInlineReplying_)
{
this->replyThread_ = nullptr;
}
}
} // namespace chatterino
+31 -2
View File
@@ -11,6 +11,7 @@
#include <QTextEdit>
#include <QVBoxLayout>
#include <QWidget>
#include <memory>
namespace chatterino {
@@ -18,6 +19,7 @@ class Split;
class EmotePopup;
class InputCompletionPopup;
class EffectLabel;
class MessageThread;
class ResizingTextEdit;
class SplitInput : public BaseWidget
@@ -25,13 +27,19 @@ class SplitInput : public BaseWidget
Q_OBJECT
public:
SplitInput(Split *_chatWidget);
SplitInput(Split *_chatWidget, bool enableInlineReplying = true);
SplitInput(QWidget *parent, Split *_chatWidget,
bool enableInlineReplying = true);
void clearSelection();
bool isEditFirstWord() const;
QString getInputText() const;
void insertText(const QString &text);
void setReply(std::shared_ptr<MessageThread> reply,
bool showInlineReplying = true);
void setPlaceholderText(const QString &text);
/**
* @brief Hide the widget
*
@@ -64,7 +72,16 @@ protected:
void paintEvent(QPaintEvent * /*event*/) override;
void resizeEvent(QResizeEvent * /*event*/) override;
private:
virtual void giveFocus(Qt::FocusReason reason);
QString handleSendMessage(std::vector<QString> &arguments);
void postMessageSend(const QString &message,
const std::vector<QString> &arguments);
/// Clears the input box, clears reply thread if inline replies are enabled
void clearInput();
protected:
void addShortcuts() override;
void initLayout();
bool eventFilter(QObject *obj, QEvent *event) override;
@@ -78,6 +95,8 @@ private:
void insertCompletionText(const QString &text);
void openEmotePopup();
void updateCancelReplyButton();
// scaledMaxHeight returns the height in pixels that this widget can grow to
// This does not take hidden into account, so callers must take hidden into account themselves
int scaledMaxHeight() const;
@@ -92,8 +111,17 @@ private:
EffectLabel *emoteButton;
QHBoxLayout *hbox;
QVBoxLayout *vbox;
QWidget *replyWrapper;
QHBoxLayout *replyHbox;
QLabel *replyLabel;
EffectLabel *cancelReplyButton;
} ui_;
std::shared_ptr<MessageThread> replyThread_ = nullptr;
bool enableInlineReplying_;
pajlada::Signals::SignalHolder managedConnections_;
QStringList prevMsg_;
QString currMsg_;
@@ -109,6 +137,7 @@ private slots:
void editTextChanged();
friend class Split;
friend class ReplyThreadPopup;
};
} // namespace chatterino