Merge remote-tracking branch 'origin/master' into zneix/enhancement/login-overhaul

This commit is contained in:
zneix 2021-08-22 12:37:58 +02:00
commit e2a80a4d59
No known key found for this signature in database
GPG key ID: 911916E0523B22F6
26 changed files with 400 additions and 143 deletions

View file

@ -1,5 +1,8 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Issue about the Chatterino Browser Extension
url: https://github.com/Chatterino/chatterino-browser-ext/issues
about: Make a suggestion or report a bug about the Chatterino browser extension.
- name: Suggestions or feature request - name: Suggestions or feature request
url: https://github.com/chatterino/chatterino2/discussions/categories/ideas url: https://github.com/chatterino/chatterino2/discussions/categories/ideas
about: Got something you think should change or be added? Search for or start a new discussion! about: Got something you think should change or be added? Search for or start a new discussion!

View file

@ -1,7 +0,0 @@
---
name: Issue about the Chatterino Browser Extension
about: Make a suggestion or report a bug about the Chatterino browser extension.
---
Issues for the extension are tracked here: https://github.com/chatterino/chatterino-browser-ext

View file

@ -13,7 +13,7 @@ jobs:
# Gives an error if there's no change in the changelog (except using label) # Gives an error if there's no change in the changelog (except using label)
- name: Changelog check - name: Changelog check
uses: dangoslen/changelog-enforcer@v2.2.0 uses: dangoslen/changelog-enforcer@v2.3.1
with: with:
changeLogPath: 'CHANGELOG.md' changeLogPath: 'CHANGELOG.md'
skipLabels: 'no changelog entry needed, ci, submodules' skipLabels: 'no changelog entry needed, ci, submodules'

View file

@ -10,10 +10,14 @@
- Minor: Added a setting to hide similar messages by any user. (#2716) - Minor: Added a setting to hide similar messages by any user. (#2716)
- Minor: Duplicate spaces now count towards the display message length. (#3002) - Minor: Duplicate spaces now count towards the display message length. (#3002)
- Minor: Commands are now backed up. (#3168) - Minor: Commands are now backed up. (#3168)
- Minor: Added the ability to open an entire tab as a popup. (#3082)
- Minor: Added optional parameter to /usercard command for opening a usercard in a different channel context. (#3172)
- Bugfix: Fixed colored usernames sometimes not working. (#3170)
- Bugfix: Restored ability to send duplicate `/me` messages. (#3166) - Bugfix: Restored ability to send duplicate `/me` messages. (#3166)
- Bugfix: Notifications for moderators about other moderators deleting messages can now be disabled. (#3121) - Bugfix: Notifications for moderators about other moderators deleting messages can now be disabled. (#3121)
- Bugfix: Moderation mode and active filters are now preserved when opening a split as a popup. (#3113, #3130) - Bugfix: Moderation mode and active filters are now preserved when opening a split as a popup. (#3113, #3130)
- Bugfix: Fixed a bug that caused all badge highlights to use the same color. (#3132, #3134) - Bugfix: Fixed a bug that caused all badge highlights to use the same color. (#3132, #3134)
- Bugfix: Allow starting Streamlink from Chatterino when running as a Flatpak. (#3178)
- Dev: Renamed CMake's build option `USE_SYSTEM_QT5KEYCHAIN` to `USE_SYSTEM_QTKEYCHAIN`. (#3103) - Dev: Renamed CMake's build option `USE_SYSTEM_QT5KEYCHAIN` to `USE_SYSTEM_QTKEYCHAIN`. (#3103)
- Dev: Add benchmarks that can be compiled with the `BUILD_BENCHMARKS` CMake flag. Off by default. (#3038) - Dev: Add benchmarks that can be compiled with the `BUILD_BENCHMARKS` CMake flag. Off by default. (#3038)

View file

@ -215,3 +215,28 @@ Keep the element on the stack if possible. If you need a pointer or have complex
- Use the [object tree](https://doc.qt.io/qt-5/objecttrees.html#) to manage lifetimes where possible. Objects are destroyed when their parent object is destroyed. - Use the [object tree](https://doc.qt.io/qt-5/objecttrees.html#) to manage lifetimes where possible. Objects are destroyed when their parent object is destroyed.
- If you have to explicitly delete an object use `variable->deleteLater()` instead of `delete variable`. This ensures that it will be deleted on the correct thread. - If you have to explicitly delete an object use `variable->deleteLater()` instead of `delete variable`. This ensures that it will be deleted on the correct thread.
- If an object doesn't have a parent, consider using `std::unique_ptr<Type, DeleteLater>` with `DeleteLater` from "src/common/Common.hpp". This will call `deleteLater()` on the pointer once it goes out of scope, or the object is destroyed. - If an object doesn't have a parent, consider using `std::unique_ptr<Type, DeleteLater>` with `DeleteLater` from "src/common/Common.hpp". This will call `deleteLater()` on the pointer once it goes out of scope, or the object is destroyed.
## Conventions
#### Usage strings
When informing the user about how a command is supposed to be used, we aim to follow [this standard](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html) where possible.
- Square brackets are reserved for `[optional arguments]`.
- Angle brackets are reserved for `<required arguments>`.
- The word _Usage_ should be capitalized and must be followed by a colon.
- If the usage deserves a description, put a dot after all parameters and explain it briefly.
##### Good
- `Usage: /block <user>`
- `Usage: /unblock <user>. Unblocks a user.`
- `Usage: /streamlink <channel>`
- `Usage: /usercard <user> [channel]`
##### Bad
- `Usage /streamlink <channel>` - Missing colon after _Usage_.
- `usage: /streamlink <channel>` - _Usage_ must be capitalized.
- `Usage: /streamlink channel` - The required argument `channel` must be wrapped in angle brackets.
- `Usage: /streamlink <channel>.` - Don't put a dot after usage if it's not followed by a description.

View file

@ -35,6 +35,7 @@ public:
lru_cache(lru_cache<key_t, value_t> &&other) lru_cache(lru_cache<key_t, value_t> &&other)
: _cache_items_list(std::move(other._cache_items_list)) : _cache_items_list(std::move(other._cache_items_list))
, _cache_items_map(std::move(other._cache_items_map)) , _cache_items_map(std::move(other._cache_items_map))
, _max_size(other._max_size)
{ {
other._cache_items_list.clear(); other._cache_items_list.clear();
other._cache_items_map.clear(); other._cache_items_map.clear();
@ -44,6 +45,7 @@ public:
{ {
_cache_items_list = std::move(other._cache_items_list); _cache_items_list = std::move(other._cache_items_list);
_cache_items_map = std::move(other._cache_items_map); _cache_items_map = std::move(other._cache_items_map);
_max_size = other._max_size;
other._cache_items_list.clear(); other._cache_items_list.clear();
other._cache_items_map.clear(); other._cache_items_map.clear();
return *this; return *this;

View file

@ -74,6 +74,12 @@ void ChannelChatters::updateOnlineChatters(
chatters_->updateOnlineChatters(chatters); chatters_->updateOnlineChatters(chatters);
} }
size_t ChannelChatters::colorsSize() const
{
auto size = this->chatterColors_.access()->size();
return size;
}
const QColor ChannelChatters::getUserColor(const QString &user) const QColor ChannelChatters::getUserColor(const QString &user)
{ {
const auto chatterColors = this->chatterColors_.access(); const auto chatterColors = this->chatterColors_.access();

View file

@ -25,9 +25,13 @@ public:
void setUserColor(const QString &user, const QColor &color); void setUserColor(const QString &user, const QColor &color);
void updateOnlineChatters(const std::unordered_set<QString> &chatters); void updateOnlineChatters(const std::unordered_set<QString> &chatters);
private: // colorsSize returns the amount of colors stored in `chatterColors_`
// NOTE: This function is only meant to be used in tests and benchmarks
size_t colorsSize() const;
static constexpr int maxChatterColorCount = 5000; static constexpr int maxChatterColorCount = 5000;
private:
Channel &channel_; Channel &channel_;
// maps 2 char prefix to set of names // maps 2 char prefix to set of names

View file

@ -2,6 +2,8 @@
#include "common/Modes.hpp" #include "common/Modes.hpp"
#include <QFileInfo>
#define UGLYMACROHACK1(s) #s #define UGLYMACROHACK1(s) #s
#define FROM_EXTERNAL_DEFINE(s) UGLYMACROHACK1(s) #define FROM_EXTERNAL_DEFINE(s) UGLYMACROHACK1(s)
@ -71,4 +73,9 @@ const bool &Version::isSupportedOS() const
return this->isSupportedOS_; return this->isSupportedOS_;
} }
bool Version::isFlatpak() const
{
return QFileInfo::exists("/.flatpak-info");
}
} // namespace chatterino } // namespace chatterino

View file

@ -29,6 +29,7 @@ public:
const QString &dateOfBuild() const; const QString &dateOfBuild() const;
const QString &fullVersion() const; const QString &fullVersion() const;
const bool &isSupportedOS() const; const bool &isSupportedOS() const;
bool isFlatpak() const;
private: private:
Version(); Version();

View file

@ -110,6 +110,42 @@ void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor,
descriptor.filters_ = loadFilters(root.value("filters")); descriptor.filters_ = loadFilters(root.value("filters"));
} }
TabDescriptor TabDescriptor::loadFromJSON(const QJsonObject &tabObj)
{
TabDescriptor tab;
// Load tab custom title
QJsonValue titleVal = tabObj.value("title");
if (titleVal.isString())
{
tab.customTitle_ = titleVal.toString();
}
// Load tab selected state
tab.selected_ = tabObj.value("selected").toBool(false);
// Load tab "highlightsEnabled" state
tab.highlightsEnabled_ = tabObj.value("highlightsEnabled").toBool(true);
QJsonObject splitRoot = tabObj.value("splits2").toObject();
// Load tab splits
if (!splitRoot.isEmpty())
{
// root type
auto nodeType = splitRoot.value("type").toString();
if (nodeType == "split")
{
tab.rootNode_ = loadNodes<SplitNodeDescriptor>(splitRoot);
}
else if (nodeType == "horizontal" || nodeType == "vertical")
{
tab.rootNode_ = loadNodes<ContainerNodeDescriptor>(splitRoot);
}
}
return tab;
}
WindowLayout WindowLayout::loadFromFile(const QString &path) WindowLayout WindowLayout::loadFromFile(const QString &path)
{ {
WindowLayout layout; WindowLayout layout;
@ -117,15 +153,15 @@ WindowLayout WindowLayout::loadFromFile(const QString &path)
bool hasSetAMainWindow = false; bool hasSetAMainWindow = false;
// "deserialize" // "deserialize"
for (const QJsonValue &window_val : loadWindowArray(path)) for (const QJsonValue &windowVal : loadWindowArray(path))
{ {
QJsonObject window_obj = window_val.toObject(); QJsonObject windowObj = windowVal.toObject();
WindowDescriptor window; WindowDescriptor window;
// Load window type // Load window type
QString type_val = window_obj.value("type").toString(); QString typeVal = windowObj.value("type").toString();
auto type = type_val == "main" ? WindowType::Main : WindowType::Popup; auto type = typeVal == "main" ? WindowType::Main : WindowType::Popup;
if (type == WindowType::Main) if (type == WindowType::Main)
{ {
@ -142,21 +178,21 @@ WindowLayout WindowLayout::loadFromFile(const QString &path)
window.type_ = type; window.type_ = type;
// Load window state // Load window state
if (window_obj.value("state") == "minimized") if (windowObj.value("state") == "minimized")
{ {
window.state_ = WindowDescriptor::State::Minimized; window.state_ = WindowDescriptor::State::Minimized;
} }
else if (window_obj.value("state") == "maximized") else if (windowObj.value("state") == "maximized")
{ {
window.state_ = WindowDescriptor::State::Maximized; window.state_ = WindowDescriptor::State::Maximized;
} }
// Load window geometry // Load window geometry
{ {
int x = window_obj.value("x").toInt(-1); int x = windowObj.value("x").toInt(-1);
int y = window_obj.value("y").toInt(-1); int y = windowObj.value("y").toInt(-1);
int width = window_obj.value("width").toInt(-1); int width = windowObj.value("width").toInt(-1);
int height = window_obj.value("height").toInt(-1); int height = windowObj.value("height").toInt(-1);
window.geometry_ = QRect(x, y, width, height); window.geometry_ = QRect(x, y, width, height);
} }
@ -164,23 +200,10 @@ WindowLayout WindowLayout::loadFromFile(const QString &path)
bool hasSetASelectedTab = false; bool hasSetASelectedTab = false;
// Load window tabs // Load window tabs
QJsonArray tabs = window_obj.value("tabs").toArray(); QJsonArray tabs = windowObj.value("tabs").toArray();
for (QJsonValue tab_val : tabs) for (QJsonValue tabVal : tabs)
{ {
TabDescriptor tab; TabDescriptor tab = TabDescriptor::loadFromJSON(tabVal.toObject());
QJsonObject tab_obj = tab_val.toObject();
// Load tab custom title
QJsonValue title_val = tab_obj.value("title");
if (title_val.isString())
{
tab.customTitle_ = title_val.toString();
}
// Load tab selected state
tab.selected_ = tab_obj.value("selected").toBool(false);
if (tab.selected_) if (tab.selected_)
{ {
if (hasSetASelectedTab) if (hasSetASelectedTab)
@ -192,34 +215,11 @@ WindowLayout WindowLayout::loadFromFile(const QString &path)
} }
hasSetASelectedTab = true; hasSetASelectedTab = true;
} }
// Load tab "highlightsEnabled" state
tab.highlightsEnabled_ =
tab_obj.value("highlightsEnabled").toBool(true);
QJsonObject splitRoot = tab_obj.value("splits2").toObject();
// Load tab splits
if (!splitRoot.isEmpty())
{
// root type
auto nodeType = splitRoot.value("type").toString();
if (nodeType == "split")
{
tab.rootNode_ = loadNodes<SplitNodeDescriptor>(splitRoot);
}
else if (nodeType == "horizontal" || nodeType == "vertical")
{
tab.rootNode_ =
loadNodes<ContainerNodeDescriptor>(splitRoot);
}
}
window.tabs_.emplace_back(std::move(tab)); window.tabs_.emplace_back(std::move(tab));
} }
// Load emote popup position // Load emote popup position
QJsonObject emote_popup_obj = window_obj.value("emotePopup").toObject(); QJsonObject emote_popup_obj = windowObj.value("emotePopup").toObject();
layout.emotePopupPos_ = QPoint(emote_popup_obj.value("x").toInt(), layout.emotePopupPos_ = QPoint(emote_popup_obj.value("x").toInt(),
emote_popup_obj.value("y").toInt()); emote_popup_obj.value("y").toInt());

View file

@ -67,6 +67,8 @@ struct ContainerNodeDescriptor {
}; };
struct TabDescriptor { struct TabDescriptor {
static TabDescriptor loadFromJSON(const QJsonObject &root);
QString customTitle_; QString customTitle_;
bool selected_{false}; bool selected_{false};
bool highlightsEnabled_{true}; bool highlightsEnabled_{true};

View file

@ -296,7 +296,7 @@ void CommandController::initialize(Settings &, Paths &paths)
auto blockLambda = [](const auto &words, auto channel) { auto blockLambda = [](const auto &words, auto channel) {
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage(makeSystemMessage("Usage: /block [user]")); channel->addMessage(makeSystemMessage("Usage: /block <user>"));
return ""; return "";
} }
@ -341,7 +341,7 @@ void CommandController::initialize(Settings &, Paths &paths)
auto unblockLambda = [](const auto &words, auto channel) { auto unblockLambda = [](const auto &words, auto channel) {
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage(makeSystemMessage("Usage: /unblock [user]")); channel->addMessage(makeSystemMessage("Usage: /unblock <user>"));
return ""; return "";
} }
@ -455,7 +455,7 @@ void CommandController::initialize(Settings &, Paths &paths)
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage( channel->addMessage(
makeSystemMessage("Usage /user [user] (channel)")); makeSystemMessage("Usage: /user <user> [channel]"));
return ""; return "";
} }
QString userName = words[1]; QString userName = words[1];
@ -476,12 +476,33 @@ void CommandController::initialize(Settings &, Paths &paths)
this->registerCommand("/usercard", [](const auto &words, auto channel) { this->registerCommand("/usercard", [](const auto &words, auto channel) {
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage(makeSystemMessage("Usage /usercard [user]")); channel->addMessage(
makeSystemMessage("Usage: /usercard <user> [channel]"));
return ""; return "";
} }
QString userName = words[1]; QString userName = words[1];
stripUserName(userName); stripUserName(userName);
if (words.size() > 2)
{
QString channelName = words[2];
stripChannelName(channelName);
ChannelPtr channelTemp =
getApp()->twitch2->getChannelOrEmpty(channelName);
if (channelTemp->isEmpty())
{
channel->addMessage(makeSystemMessage(
"A usercard can only be displayed for a channel that is "
"currently opened in Chatterino."));
return "";
}
channel = channelTemp;
}
auto *userPopup = new UserInfoPopup( auto *userPopup = new UserInfoPopup(
getSettings()->autoCloseUserPopup, getSettings()->autoCloseUserPopup,
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()))); static_cast<QWidget *>(&(getApp()->windows->getMainWindow())));
@ -604,7 +625,7 @@ void CommandController::initialize(Settings &, Paths &paths)
(!channel->isTwitchChannel() || channel->isEmpty())) (!channel->isTwitchChannel() || channel->isEmpty()))
{ {
channel->addMessage(makeSystemMessage( channel->addMessage(makeSystemMessage(
"Usage: /streamlink [channel]. You can also use the " "Usage: /streamlink <channel>. You can also use the "
"command without arguments in any Twitch channel to open " "command without arguments in any Twitch channel to open "
"it in streamlink.")); "it in streamlink."));
return ""; return "";
@ -625,7 +646,7 @@ void CommandController::initialize(Settings &, Paths &paths)
(!channel->isTwitchChannel() || channel->isEmpty())) (!channel->isTwitchChannel() || channel->isEmpty()))
{ {
channel->addMessage(makeSystemMessage( channel->addMessage(makeSystemMessage(
"Usage: /popout [channel]. You can also use the command " "Usage: /popout <channel>. You can also use the command "
"without arguments in any Twitch channel to open its " "without arguments in any Twitch channel to open its "
"popout chat.")); "popout chat."));
return ""; return "";
@ -652,7 +673,7 @@ void CommandController::initialize(Settings &, Paths &paths)
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage( channel->addMessage(
makeSystemMessage("Usage: /settitle <stream title>.")); makeSystemMessage("Usage: /settitle <stream title>"));
return ""; return "";
} }
if (auto twitchChannel = dynamic_cast<TwitchChannel *>(channel.get())) if (auto twitchChannel = dynamic_cast<TwitchChannel *>(channel.get()))
@ -683,7 +704,7 @@ void CommandController::initialize(Settings &, Paths &paths)
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage( channel->addMessage(
makeSystemMessage("Usage: /setgame <stream game>.")); makeSystemMessage("Usage: /setgame <stream game>"));
return ""; return "";
} }
if (auto twitchChannel = dynamic_cast<TwitchChannel *>(channel.get())) if (auto twitchChannel = dynamic_cast<TwitchChannel *>(channel.get()))
@ -748,7 +769,7 @@ void CommandController::initialize(Settings &, Paths &paths)
const ChannelPtr channel) { const ChannelPtr channel) {
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage(makeSystemMessage("Usage: /openurl <URL>.")); channel->addMessage(makeSystemMessage("Usage: /openurl <URL>"));
return ""; return "";
} }

View file

@ -810,8 +810,7 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message)
if (tags == "bad_delete_message_error" || tags == "usage_delete") if (tags == "bad_delete_message_error" || tags == "usage_delete")
{ {
channel->addMessage(makeSystemMessage( channel->addMessage(makeSystemMessage(
"Usage: \"/delete <msg-id>\" - can't take more " "Usage: /delete <msg-id>. Can't take more than one argument"));
"than one argument"));
} }
else if (tags == "host_on" || tags == "host_target_went_offline") else if (tags == "host_on" || tags == "host_target_went_offline")
{ {

View file

@ -232,7 +232,9 @@ void Updates::installUpdates()
void Updates::checkForUpdates() void Updates::checkForUpdates()
{ {
if (!Version::instance().isSupportedOS()) auto version = Version::instance();
if (!version.isSupportedOS())
{ {
qCDebug(chatterinoUpdate) qCDebug(chatterinoUpdate)
<< "Update checking disabled because OS doesn't appear to be one " << "Update checking disabled because OS doesn't appear to be one "
@ -241,7 +243,7 @@ void Updates::checkForUpdates()
} }
// Disable updates on Flatpak // Disable updates on Flatpak
if (QFileInfo::exists("/.flatpak-info")) if (version.isFlatpak())
{ {
return; return;
} }

View file

@ -379,20 +379,20 @@ void WindowManager::save()
QJsonDocument document; QJsonDocument document;
// "serialize" // "serialize"
QJsonArray window_arr; QJsonArray windowArr;
for (Window *window : this->windows_) for (Window *window : this->windows_)
{ {
QJsonObject window_obj; QJsonObject windowObj;
// window type // window type
switch (window->getType()) switch (window->getType())
{ {
case WindowType::Main: case WindowType::Main:
window_obj.insert("type", "main"); windowObj.insert("type", "main");
break; break;
case WindowType::Popup: case WindowType::Popup:
window_obj.insert("type", "popup"); windowObj.insert("type", "popup");
break; break;
case WindowType::Attached:; case WindowType::Attached:;
@ -400,68 +400,48 @@ void WindowManager::save()
if (window->isMaximized()) if (window->isMaximized())
{ {
window_obj.insert("state", "maximized"); windowObj.insert("state", "maximized");
} }
else if (window->isMinimized()) else if (window->isMinimized())
{ {
window_obj.insert("state", "minimized"); windowObj.insert("state", "minimized");
} }
// window geometry // window geometry
auto rect = window->getBounds(); auto rect = window->getBounds();
window_obj.insert("x", rect.x()); windowObj.insert("x", rect.x());
window_obj.insert("y", rect.y()); windowObj.insert("y", rect.y());
window_obj.insert("width", rect.width()); windowObj.insert("width", rect.width());
window_obj.insert("height", rect.height()); windowObj.insert("height", rect.height());
QJsonObject emote_popup_obj; QJsonObject emotePopupObj;
emote_popup_obj.insert("x", this->emotePopupPos_.x()); emotePopupObj.insert("x", this->emotePopupPos_.x());
emote_popup_obj.insert("y", this->emotePopupPos_.y()); emotePopupObj.insert("y", this->emotePopupPos_.y());
window_obj.insert("emotePopup", emote_popup_obj); windowObj.insert("emotePopup", emotePopupObj);
// window tabs // window tabs
QJsonArray tabs_arr; QJsonArray tabsArr;
for (int tab_i = 0; tab_i < window->getNotebook().getPageCount(); for (int tabIndex = 0; tabIndex < window->getNotebook().getPageCount();
tab_i++) tabIndex++)
{ {
QJsonObject tab_obj; QJsonObject tabObj;
SplitContainer *tab = dynamic_cast<SplitContainer *>( SplitContainer *tab = dynamic_cast<SplitContainer *>(
window->getNotebook().getPageAt(tab_i)); window->getNotebook().getPageAt(tabIndex));
assert(tab != nullptr); assert(tab != nullptr);
// custom tab title bool isSelected = window->getNotebook().getSelectedPage() == tab;
if (tab->getTab()->hasCustomTitle()) WindowManager::encodeTab(tab, isSelected, tabObj);
{ tabsArr.append(tabObj);
tab_obj.insert("title", tab->getTab()->getCustomTitle());
}
// selected
if (window->getNotebook().getSelectedPage() == tab)
{
tab_obj.insert("selected", true);
}
// highlighting on new messages
tab_obj.insert("highlightsEnabled",
tab->getTab()->hasHighlightsEnabled());
// splits
QJsonObject splits;
this->encodeNodeRecursively(tab->getBaseNode(), splits);
tab_obj.insert("splits2", splits);
tabs_arr.append(tab_obj);
} }
window_obj.insert("tabs", tabs_arr); windowObj.insert("tabs", tabsArr);
window_arr.append(window_obj); windowArr.append(windowObj);
} }
QJsonObject obj; QJsonObject obj;
obj.insert("windows", window_arr); obj.insert("windows", windowArr);
document.setObject(obj); document.setObject(obj);
// save file // save file
@ -497,6 +477,32 @@ void WindowManager::queueSave()
this->saveTimer->start(10s); this->saveTimer->start(10s);
} }
void WindowManager::encodeTab(SplitContainer *tab, bool isSelected,
QJsonObject &obj)
{
// custom tab title
if (tab->getTab()->hasCustomTitle())
{
obj.insert("title", tab->getTab()->getCustomTitle());
}
// selected
if (isSelected)
{
obj.insert("selected", true);
}
// highlighting on new messages
obj.insert("highlightsEnabled", tab->getTab()->hasHighlightsEnabled());
// splits
QJsonObject splits;
WindowManager::encodeNodeRecursively(tab->getBaseNode(), splits);
obj.insert("splits2", splits);
}
void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj) void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj)
{ {
switch (node->getType()) switch (node->getType())
@ -506,11 +512,12 @@ void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj)
obj.insert("moderationMode", node->getSplit()->getModerationMode()); obj.insert("moderationMode", node->getSplit()->getModerationMode());
QJsonObject split; QJsonObject split;
encodeChannel(node->getSplit()->getIndirectChannel(), split); WindowManager::encodeChannel(node->getSplit()->getIndirectChannel(),
split);
obj.insert("data", split); obj.insert("data", split);
QJsonArray filters; QJsonArray filters;
encodeFilters(node->getSplit(), filters); WindowManager::encodeFilters(node->getSplit(), filters);
obj.insert("filters", filters); obj.insert("filters", filters);
} }
break; break;
@ -520,14 +527,14 @@ void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj)
? "horizontal" ? "horizontal"
: "vertical"); : "vertical");
QJsonArray items_arr; QJsonArray itemsArr;
for (const std::unique_ptr<SplitNode> &n : node->getChildren()) for (const std::unique_ptr<SplitNode> &n : node->getChildren())
{ {
QJsonObject subObj; QJsonObject subObj;
this->encodeNodeRecursively(n.get(), subObj); WindowManager::encodeNodeRecursively(n.get(), subObj);
items_arr.append(subObj); itemsArr.append(subObj);
} }
obj.insert("items", items_arr); obj.insert("items", itemsArr);
} }
break; break;
} }

View file

@ -30,6 +30,8 @@ public:
WindowManager(); WindowManager();
~WindowManager() override; ~WindowManager() override;
static void encodeTab(SplitContainer *tab, bool isSelected,
QJsonObject &obj);
static void encodeChannel(IndirectChannel channel, QJsonObject &obj); static void encodeChannel(IndirectChannel channel, QJsonObject &obj);
static void encodeFilters(Split *split, QJsonArray &arr); static void encodeFilters(Split *split, QJsonArray &arr);
static IndirectChannel decodeChannel(const SplitDescriptor &descriptor); static IndirectChannel decodeChannel(const SplitDescriptor &descriptor);
@ -99,7 +101,8 @@ public:
pajlada::Signals::Signal<SplitContainer *> selectSplitContainer; pajlada::Signals::Signal<SplitContainer *> selectSplitContainer;
private: private:
void encodeNodeRecursively(SplitContainer::Node *node, QJsonObject &obj); static void encodeNodeRecursively(SplitContainer::Node *node,
QJsonObject &obj);
// Load window layout from the window-layout.json file // Load window layout from the window-layout.json file
WindowLayout loadWindowLayoutFromFile() const; WindowLayout loadWindowLayoutFromFile() const;

View file

@ -10,6 +10,7 @@
#include <QFileInfo> #include <QFileInfo>
#include <QProcess> #include <QProcess>
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
#include "common/Version.hpp"
#include <functional> #include <functional>
@ -35,18 +36,6 @@ namespace {
#endif #endif
} }
QString getStreamlinkProgram()
{
if (getSettings()->streamlinkUseCustomPath)
{
return getSettings()->streamlinkPath + "/" + getBinaryName();
}
else
{
return getBinaryName();
}
}
bool checkStreamlinkPath(const QString &path) bool checkStreamlinkPath(const QString &path)
{ {
QFileInfo fileinfo(path); QFileInfo fileinfo(path);
@ -83,7 +72,27 @@ namespace {
QProcess *createStreamlinkProcess() QProcess *createStreamlinkProcess()
{ {
auto p = new QProcess; auto p = new QProcess;
p->setProgram(getStreamlinkProgram());
const QString path = [] {
if (getSettings()->streamlinkUseCustomPath)
{
return getSettings()->streamlinkPath + "/" + getBinaryName();
}
else
{
return QString{getBinaryName()};
}
}();
if (Version::instance().isFlatpak())
{
p->setProgram("flatpak-spawn");
p->setArguments({"--host", path});
}
else
{
p->setProgram(path);
}
QObject::connect(p, &QProcess::errorOccurred, [=](auto err) { QObject::connect(p, &QProcess::errorOccurred, [=](auto err) {
if (err == QProcess::FailedToStart) if (err == QProcess::FailedToStart)
@ -165,7 +174,8 @@ void getStreamQualities(const QString &channelURL,
} }
}); });
p->setArguments({channelURL, "--default-stream=KKona"}); p->setArguments(p->arguments() +
QStringList{channelURL, "--default-stream=KKona"});
p->start(); p->start();
} }
@ -173,7 +183,9 @@ void getStreamQualities(const QString &channelURL,
void openStreamlink(const QString &channelURL, const QString &quality, void openStreamlink(const QString &channelURL, const QString &quality,
QStringList extraArguments) QStringList extraArguments)
{ {
QStringList arguments = extraArguments << channelURL << quality; auto proc = createStreamlinkProcess();
auto arguments = proc->arguments()
<< extraArguments << channelURL << quality;
// Remove empty arguments before appending additional streamlink options // Remove empty arguments before appending additional streamlink options
// as the options might purposely contain empty arguments // as the options might purposely contain empty arguments
@ -182,7 +194,8 @@ void openStreamlink(const QString &channelURL, const QString &quality,
QString additionalOptions = getSettings()->streamlinkOpts.getValue(); QString additionalOptions = getSettings()->streamlinkOpts.getValue();
arguments << splitCommand(additionalOptions); arguments << splitCommand(additionalOptions);
bool res = QProcess::startDetached(getStreamlinkProgram(), arguments); proc->setArguments(std::move(arguments));
bool res = proc->startDetached();
if (!res) if (!res)
{ {

View file

@ -338,6 +338,14 @@ void Window::addShortcuts()
} }
}); });
createWindowShortcut(this, "CTRL+SHIFT+N", [this] {
if (auto page = dynamic_cast<SplitContainer *>(
this->notebook_->getSelectedPage()))
{
page->popup();
}
});
// Zoom in // Zoom in
{ {
auto s = new QShortcut(QKeySequence::ZoomIn, this); auto s = new QShortcut(QKeySequence::ZoomIn, this);

View file

@ -33,7 +33,7 @@
const QString TEXT_VIEWS("Views: %1"); const QString TEXT_VIEWS("Views: %1");
const QString TEXT_FOLLOWERS("Followers: %1"); const QString TEXT_FOLLOWERS("Followers: %1");
const QString TEXT_CREATED("Created: %1"); const QString TEXT_CREATED("Created: %1");
const QString TEXT_TITLE("%1's Usercard"); const QString TEXT_TITLE("%1's Usercard - #%2");
#define TEXT_USER_ID "ID: " #define TEXT_USER_ID "ID: "
#define TEXT_UNAVAILABLE "(not available)" #define TEXT_UNAVAILABLE "(not available)"
@ -513,7 +513,7 @@ void UserInfoPopup::setData(const QString &name, const ChannelPtr &channel)
{ {
this->userName_ = name; this->userName_ = name;
this->channel_ = channel; this->channel_ = channel;
this->setWindowTitle(TEXT_TITLE.arg(name)); this->setWindowTitle(TEXT_TITLE.arg(name, channel->getName()));
this->ui_.nameLabel->setText(name); this->ui_.nameLabel->setText(name);
this->ui_.nameLabel->setProperty("copy-text", name); this->ui_.nameLabel->setProperty("copy-text", name);
@ -598,7 +598,8 @@ void UserInfoPopup::updateUserData()
this->avatarUrl_ = user.profileImageUrl; this->avatarUrl_ = user.profileImageUrl;
this->ui_.nameLabel->setText(user.displayName); this->ui_.nameLabel->setText(user.displayName);
this->setWindowTitle(TEXT_TITLE.arg(user.displayName)); this->setWindowTitle(
TEXT_TITLE.arg(user.displayName, this->channel_->getName()));
this->ui_.viewCountLabel->setText( this->ui_.viewCountLabel->setText(
TEXT_VIEWS.arg(localizeNumbers(user.viewCount))); TEXT_VIEWS.arg(localizeNumbers(user.viewCount)));
this->ui_.createdDateLabel->setText( this->ui_.createdDateLabel->setText(

View file

@ -64,6 +64,16 @@ NotebookTab::NotebookTab(Notebook *notebook)
this->notebook_->removePage(this->page); this->notebook_->removePage(this->page);
}); });
this->menu_.addAction(
"Popup Tab",
[=]() {
if (auto container = dynamic_cast<SplitContainer *>(this->page))
{
container->popup();
}
},
QKeySequence("Ctrl+Shift+N"));
highlightNewMessagesAction_ = highlightNewMessagesAction_ =
new QAction("Mark Tab as Unread on New Messages", &this->menu_); new QAction("Mark Tab as Unread on New Messages", &this->menu_);
highlightNewMessagesAction_->setCheckable(true); highlightNewMessagesAction_->setCheckable(true);

View file

@ -44,6 +44,8 @@ KeyboardSettingsPage::KeyboardSettingsPage()
form->addRow(new QLabel("Ctrl + Shift + T"), new QLabel("Create new tab")); form->addRow(new QLabel("Ctrl + Shift + T"), new QLabel("Create new tab"));
form->addRow(new QLabel("Ctrl + Shift + W"), form->addRow(new QLabel("Ctrl + Shift + W"),
new QLabel("Close current tab")); new QLabel("Close current tab"));
form->addRow(new QLabel("Ctrl + Shift + N"),
new QLabel("Open current tab as a popup"));
form->addRow(new QLabel("Ctrl + H"), form->addRow(new QLabel("Ctrl + H"),
new QLabel("Hide/Show similar messages (See General->R9K)")); new QLabel("Hide/Show similar messages (See General->R9K)"));

View file

@ -9,6 +9,7 @@
#include "util/Helpers.hpp" #include "util/Helpers.hpp"
#include "util/LayoutCreator.hpp" #include "util/LayoutCreator.hpp"
#include "widgets/Notebook.hpp" #include "widgets/Notebook.hpp"
#include "widgets/Window.hpp"
#include "widgets/helper/ChannelView.hpp" #include "widgets/helper/ChannelView.hpp"
#include "widgets/helper/NotebookTab.hpp" #include "widgets/helper/NotebookTab.hpp"
#include "widgets/splits/ClosedSplits.hpp" #include "widgets/splits/ClosedSplits.hpp"
@ -761,6 +762,33 @@ void SplitContainer::applyFromDescriptor(const NodeDescriptor &rootNode)
this->layout(); this->layout();
} }
void SplitContainer::popup()
{
Window &window = getApp()->windows->createWindow(WindowType::Popup);
auto popupContainer = window.getNotebook().getOrAddSelectedPage();
QJsonObject encodedTab;
WindowManager::encodeTab(this, true, encodedTab);
TabDescriptor tab = TabDescriptor::loadFromJSON(encodedTab);
// custom title
if (!tab.customTitle_.isEmpty())
{
popupContainer->getTab()->setCustomTitle(tab.customTitle_);
}
// highlighting on new messages
popupContainer->getTab()->setHighlightsEnabled(tab.highlightsEnabled_);
// splits
if (tab.rootNode_)
{
popupContainer->applyFromDescriptor(*tab.rootNode_);
}
window.show();
}
void SplitContainer::applyFromDescriptorRecursively( void SplitContainer::applyFromDescriptorRecursively(
const NodeDescriptor &rootNode, Node *node) const NodeDescriptor &rootNode, Node *node)
{ {

View file

@ -202,6 +202,8 @@ public:
void applyFromDescriptor(const NodeDescriptor &rootNode); void applyFromDescriptor(const NodeDescriptor &rootNode);
void popup();
protected: protected:
void paintEvent(QPaintEvent *event) override; void paintEvent(QPaintEvent *event) override;

View file

@ -2,6 +2,7 @@ project(chatterino-test)
set(test_SOURCES set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/main.cpp ${CMAKE_CURRENT_LIST_DIR}/src/main.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ChannelChatters.cpp
${CMAKE_CURRENT_LIST_DIR}/src/AccessGuard.cpp ${CMAKE_CURRENT_LIST_DIR}/src/AccessGuard.cpp
${CMAKE_CURRENT_LIST_DIR}/src/NetworkCommon.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NetworkCommon.cpp
${CMAKE_CURRENT_LIST_DIR}/src/NetworkRequest.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NetworkRequest.cpp

View file

@ -0,0 +1,113 @@
#include "common/ChannelChatters.hpp"
#include <gtest/gtest.h>
#include <QColor>
#include <QStringList>
namespace chatterino {
class MockChannel : public Channel
{
public:
MockChannel(const QString &name)
: Channel(name, Channel::Type::Twitch)
{
}
};
} // namespace chatterino
using namespace chatterino;
// Ensure inserting the same user does not increase the size of the stored colors
TEST(ChatterChatters, insertSameUser)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
EXPECT_EQ(chatters.colorsSize(), 0);
chatters.setUserColor("pajlada", QColor("#fff"));
EXPECT_EQ(chatters.colorsSize(), 1);
chatters.setUserColor("pajlada", QColor("#fff"));
EXPECT_EQ(chatters.colorsSize(), 1);
}
// Ensure we can update a chatters color
TEST(ChatterChatters, insertSameUserUpdatesColor)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
chatters.setUserColor("pajlada", QColor("#fff"));
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor("#fff"));
chatters.setUserColor("pajlada", QColor("#f0f"));
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor("#f0f"));
}
// Ensure getting a non-existant users color returns an invalid QColor
TEST(ChatterChatters, getNonExistantUser)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
EXPECT_EQ(chatters.getUserColor("nonexistantuser"), QColor());
}
// Ensure getting a user doesn't create an entry
TEST(ChatterChatters, getDoesNotCreate)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
EXPECT_EQ(chatters.colorsSize(), 0);
chatters.getUserColor("nonexistantuser");
EXPECT_EQ(chatters.colorsSize(), 0);
}
// Ensure the least recently used entry is purged when we reach MAX_SIZE
TEST(ChatterChatters, insertMaxSize)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
// Prime chatters with 2 control entries
chatters.setUserColor("pajlada", QColor("#f00"));
chatters.setUserColor("zneix", QColor("#f0f"));
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor("#f00"));
EXPECT_EQ(chatters.getUserColor("zneix"), QColor("#f0f"));
EXPECT_EQ(chatters.getUserColor("nonexistantuser"), QColor());
EXPECT_EQ(chatters.colorsSize(), 2);
for (int i = 0; i < ChannelChatters::maxChatterColorCount - 1; ++i)
{
auto username = QString("user%1").arg(i);
chatters.setUserColor(username, QColor("#00f"));
}
// Should have bumped ONE entry out (pajlada)
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor());
EXPECT_EQ(chatters.getUserColor("zneix"), QColor("#f0f"));
EXPECT_EQ(chatters.getUserColor("user1"), QColor("#00f"));
chatters.setUserColor("newuser", QColor("#00e"));
for (int i = 0; i < ChannelChatters::maxChatterColorCount; ++i)
{
auto username = QString("user%1").arg(i);
chatters.setUserColor(username, QColor("#00f"));
}
// One more entry should be bumped out (zneix)
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor());
EXPECT_EQ(chatters.getUserColor("zneix"), QColor());
EXPECT_EQ(chatters.getUserColor("user1"), QColor("#00f"));
}