diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8ac1ca177..47dbbb2a4 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false 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 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! diff --git a/.github/ISSUE_TEMPLATE/z_browser_extension.md b/.github/ISSUE_TEMPLATE/z_browser_extension.md deleted file mode 100644 index 2ae9cdf67..000000000 --- a/.github/ISSUE_TEMPLATE/z_browser_extension.md +++ /dev/null @@ -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 diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 7fbceea74..05a2454e8 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -13,7 +13,7 @@ jobs: # Gives an error if there's no change in the changelog (except using label) - name: Changelog check - uses: dangoslen/changelog-enforcer@v2.2.0 + uses: dangoslen/changelog-enforcer@v2.3.1 with: changeLogPath: 'CHANGELOG.md' skipLabels: 'no changelog entry needed, ci, submodules' diff --git a/CHANGELOG.md b/CHANGELOG.md index d76c5724b..6099ca66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,14 @@ - Minor: Added a setting to hide similar messages by any user. (#2716) - Minor: Duplicate spaces now count towards the display message length. (#3002) - 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: 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: 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: Add benchmarks that can be compiled with the `BUILD_BENCHMARKS` CMake flag. Off by default. (#3038) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7135e908b..094d60bb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. - 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` 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 ``. +- 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 ` +- `Usage: /unblock . Unblocks a user.` +- `Usage: /streamlink ` +- `Usage: /usercard [channel]` + +##### Bad + +- `Usage /streamlink ` - Missing colon after _Usage_. +- `usage: /streamlink ` - _Usage_ must be capitalized. +- `Usage: /streamlink channel` - The required argument `channel` must be wrapped in angle brackets. +- `Usage: /streamlink .` - Don't put a dot after usage if it's not followed by a description. diff --git a/lib/lrucache/lrucache/lrucache.hpp b/lib/lrucache/lrucache/lrucache.hpp index aa2c6c511..ef7d031db 100644 --- a/lib/lrucache/lrucache/lrucache.hpp +++ b/lib/lrucache/lrucache/lrucache.hpp @@ -35,6 +35,7 @@ public: lru_cache(lru_cache &&other) : _cache_items_list(std::move(other._cache_items_list)) , _cache_items_map(std::move(other._cache_items_map)) + , _max_size(other._max_size) { other._cache_items_list.clear(); other._cache_items_map.clear(); @@ -44,6 +45,7 @@ public: { _cache_items_list = std::move(other._cache_items_list); _cache_items_map = std::move(other._cache_items_map); + _max_size = other._max_size; other._cache_items_list.clear(); other._cache_items_map.clear(); return *this; diff --git a/src/common/ChannelChatters.cpp b/src/common/ChannelChatters.cpp index 8b72f7b17..f9eb1ea65 100644 --- a/src/common/ChannelChatters.cpp +++ b/src/common/ChannelChatters.cpp @@ -74,6 +74,12 @@ void ChannelChatters::updateOnlineChatters( chatters_->updateOnlineChatters(chatters); } +size_t ChannelChatters::colorsSize() const +{ + auto size = this->chatterColors_.access()->size(); + return size; +} + const QColor ChannelChatters::getUserColor(const QString &user) { const auto chatterColors = this->chatterColors_.access(); diff --git a/src/common/ChannelChatters.hpp b/src/common/ChannelChatters.hpp index 3a2c4e49a..70ca53924 100644 --- a/src/common/ChannelChatters.hpp +++ b/src/common/ChannelChatters.hpp @@ -25,9 +25,13 @@ public: void setUserColor(const QString &user, const QColor &color); void updateOnlineChatters(const std::unordered_set &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; +private: Channel &channel_; // maps 2 char prefix to set of names diff --git a/src/common/Version.cpp b/src/common/Version.cpp index aa8639dce..b678fe48a 100644 --- a/src/common/Version.cpp +++ b/src/common/Version.cpp @@ -2,6 +2,8 @@ #include "common/Modes.hpp" +#include + #define UGLYMACROHACK1(s) #s #define FROM_EXTERNAL_DEFINE(s) UGLYMACROHACK1(s) @@ -71,4 +73,9 @@ const bool &Version::isSupportedOS() const return this->isSupportedOS_; } +bool Version::isFlatpak() const +{ + return QFileInfo::exists("/.flatpak-info"); +} + } // namespace chatterino diff --git a/src/common/Version.hpp b/src/common/Version.hpp index 1a858c0ea..967363358 100644 --- a/src/common/Version.hpp +++ b/src/common/Version.hpp @@ -29,6 +29,7 @@ public: const QString &dateOfBuild() const; const QString &fullVersion() const; const bool &isSupportedOS() const; + bool isFlatpak() const; private: Version(); diff --git a/src/common/WindowDescriptors.cpp b/src/common/WindowDescriptors.cpp index 7a82596f3..902de665f 100644 --- a/src/common/WindowDescriptors.cpp +++ b/src/common/WindowDescriptors.cpp @@ -110,6 +110,42 @@ void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor, 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(splitRoot); + } + else if (nodeType == "horizontal" || nodeType == "vertical") + { + tab.rootNode_ = loadNodes(splitRoot); + } + } + + return tab; +} + WindowLayout WindowLayout::loadFromFile(const QString &path) { WindowLayout layout; @@ -117,15 +153,15 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) bool hasSetAMainWindow = false; // "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; // Load window type - QString type_val = window_obj.value("type").toString(); - auto type = type_val == "main" ? WindowType::Main : WindowType::Popup; + QString typeVal = windowObj.value("type").toString(); + auto type = typeVal == "main" ? WindowType::Main : WindowType::Popup; if (type == WindowType::Main) { @@ -142,21 +178,21 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) window.type_ = type; // Load window state - if (window_obj.value("state") == "minimized") + if (windowObj.value("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; } // Load window geometry { - int x = window_obj.value("x").toInt(-1); - int y = window_obj.value("y").toInt(-1); - int width = window_obj.value("width").toInt(-1); - int height = window_obj.value("height").toInt(-1); + int x = windowObj.value("x").toInt(-1); + int y = windowObj.value("y").toInt(-1); + int width = windowObj.value("width").toInt(-1); + int height = windowObj.value("height").toInt(-1); window.geometry_ = QRect(x, y, width, height); } @@ -164,23 +200,10 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) bool hasSetASelectedTab = false; // Load window tabs - QJsonArray tabs = window_obj.value("tabs").toArray(); - for (QJsonValue tab_val : tabs) + QJsonArray tabs = windowObj.value("tabs").toArray(); + for (QJsonValue tabVal : tabs) { - TabDescriptor tab; - - 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); - + TabDescriptor tab = TabDescriptor::loadFromJSON(tabVal.toObject()); if (tab.selected_) { if (hasSetASelectedTab) @@ -192,34 +215,11 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) } 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(splitRoot); - } - else if (nodeType == "horizontal" || nodeType == "vertical") - { - tab.rootNode_ = - loadNodes(splitRoot); - } - } - window.tabs_.emplace_back(std::move(tab)); } // 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(), emote_popup_obj.value("y").toInt()); diff --git a/src/common/WindowDescriptors.hpp b/src/common/WindowDescriptors.hpp index 9c2aa2888..4740bc919 100644 --- a/src/common/WindowDescriptors.hpp +++ b/src/common/WindowDescriptors.hpp @@ -67,6 +67,8 @@ struct ContainerNodeDescriptor { }; struct TabDescriptor { + static TabDescriptor loadFromJSON(const QJsonObject &root); + QString customTitle_; bool selected_{false}; bool highlightsEnabled_{true}; diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 13368917e..82beac036 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -296,7 +296,7 @@ void CommandController::initialize(Settings &, Paths &paths) auto blockLambda = [](const auto &words, auto channel) { if (words.size() < 2) { - channel->addMessage(makeSystemMessage("Usage: /block [user]")); + channel->addMessage(makeSystemMessage("Usage: /block ")); return ""; } @@ -341,7 +341,7 @@ void CommandController::initialize(Settings &, Paths &paths) auto unblockLambda = [](const auto &words, auto channel) { if (words.size() < 2) { - channel->addMessage(makeSystemMessage("Usage: /unblock [user]")); + channel->addMessage(makeSystemMessage("Usage: /unblock ")); return ""; } @@ -455,7 +455,7 @@ void CommandController::initialize(Settings &, Paths &paths) if (words.size() < 2) { channel->addMessage( - makeSystemMessage("Usage /user [user] (channel)")); + makeSystemMessage("Usage: /user [channel]")); return ""; } QString userName = words[1]; @@ -476,12 +476,33 @@ void CommandController::initialize(Settings &, Paths &paths) this->registerCommand("/usercard", [](const auto &words, auto channel) { if (words.size() < 2) { - channel->addMessage(makeSystemMessage("Usage /usercard [user]")); + channel->addMessage( + makeSystemMessage("Usage: /usercard [channel]")); return ""; } QString userName = words[1]; 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( getSettings()->autoCloseUserPopup, static_cast(&(getApp()->windows->getMainWindow()))); @@ -604,7 +625,7 @@ void CommandController::initialize(Settings &, Paths &paths) (!channel->isTwitchChannel() || channel->isEmpty())) { channel->addMessage(makeSystemMessage( - "Usage: /streamlink [channel]. You can also use the " + "Usage: /streamlink . You can also use the " "command without arguments in any Twitch channel to open " "it in streamlink.")); return ""; @@ -625,7 +646,7 @@ void CommandController::initialize(Settings &, Paths &paths) (!channel->isTwitchChannel() || channel->isEmpty())) { channel->addMessage(makeSystemMessage( - "Usage: /popout [channel]. You can also use the command " + "Usage: /popout . You can also use the command " "without arguments in any Twitch channel to open its " "popout chat.")); return ""; @@ -652,7 +673,7 @@ void CommandController::initialize(Settings &, Paths &paths) if (words.size() < 2) { channel->addMessage( - makeSystemMessage("Usage: /settitle .")); + makeSystemMessage("Usage: /settitle ")); return ""; } if (auto twitchChannel = dynamic_cast(channel.get())) @@ -683,7 +704,7 @@ void CommandController::initialize(Settings &, Paths &paths) if (words.size() < 2) { channel->addMessage( - makeSystemMessage("Usage: /setgame .")); + makeSystemMessage("Usage: /setgame ")); return ""; } if (auto twitchChannel = dynamic_cast(channel.get())) @@ -748,7 +769,7 @@ void CommandController::initialize(Settings &, Paths &paths) const ChannelPtr channel) { if (words.size() < 2) { - channel->addMessage(makeSystemMessage("Usage: /openurl .")); + channel->addMessage(makeSystemMessage("Usage: /openurl ")); return ""; } diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index a4ce3c513..e15362a5e 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -810,8 +810,7 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) if (tags == "bad_delete_message_error" || tags == "usage_delete") { channel->addMessage(makeSystemMessage( - "Usage: \"/delete \" - can't take more " - "than one argument")); + "Usage: /delete . Can't take more than one argument")); } else if (tags == "host_on" || tags == "host_target_went_offline") { diff --git a/src/singletons/Updates.cpp b/src/singletons/Updates.cpp index 5751b59cc..cf32b30ff 100644 --- a/src/singletons/Updates.cpp +++ b/src/singletons/Updates.cpp @@ -232,7 +232,9 @@ void Updates::installUpdates() void Updates::checkForUpdates() { - if (!Version::instance().isSupportedOS()) + auto version = Version::instance(); + + if (!version.isSupportedOS()) { qCDebug(chatterinoUpdate) << "Update checking disabled because OS doesn't appear to be one " @@ -241,7 +243,7 @@ void Updates::checkForUpdates() } // Disable updates on Flatpak - if (QFileInfo::exists("/.flatpak-info")) + if (version.isFlatpak()) { return; } diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index af66ffcad..254b8b767 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -379,20 +379,20 @@ void WindowManager::save() QJsonDocument document; // "serialize" - QJsonArray window_arr; + QJsonArray windowArr; for (Window *window : this->windows_) { - QJsonObject window_obj; + QJsonObject windowObj; // window type switch (window->getType()) { case WindowType::Main: - window_obj.insert("type", "main"); + windowObj.insert("type", "main"); break; case WindowType::Popup: - window_obj.insert("type", "popup"); + windowObj.insert("type", "popup"); break; case WindowType::Attached:; @@ -400,68 +400,48 @@ void WindowManager::save() if (window->isMaximized()) { - window_obj.insert("state", "maximized"); + windowObj.insert("state", "maximized"); } else if (window->isMinimized()) { - window_obj.insert("state", "minimized"); + windowObj.insert("state", "minimized"); } // window geometry auto rect = window->getBounds(); - window_obj.insert("x", rect.x()); - window_obj.insert("y", rect.y()); - window_obj.insert("width", rect.width()); - window_obj.insert("height", rect.height()); + windowObj.insert("x", rect.x()); + windowObj.insert("y", rect.y()); + windowObj.insert("width", rect.width()); + windowObj.insert("height", rect.height()); - QJsonObject emote_popup_obj; - emote_popup_obj.insert("x", this->emotePopupPos_.x()); - emote_popup_obj.insert("y", this->emotePopupPos_.y()); - window_obj.insert("emotePopup", emote_popup_obj); + QJsonObject emotePopupObj; + emotePopupObj.insert("x", this->emotePopupPos_.x()); + emotePopupObj.insert("y", this->emotePopupPos_.y()); + windowObj.insert("emotePopup", emotePopupObj); // window tabs - QJsonArray tabs_arr; + QJsonArray tabsArr; - for (int tab_i = 0; tab_i < window->getNotebook().getPageCount(); - tab_i++) + for (int tabIndex = 0; tabIndex < window->getNotebook().getPageCount(); + tabIndex++) { - QJsonObject tab_obj; + QJsonObject tabObj; SplitContainer *tab = dynamic_cast( - window->getNotebook().getPageAt(tab_i)); + window->getNotebook().getPageAt(tabIndex)); assert(tab != nullptr); - // custom tab title - if (tab->getTab()->hasCustomTitle()) - { - 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); + bool isSelected = window->getNotebook().getSelectedPage() == tab; + WindowManager::encodeTab(tab, isSelected, tabObj); + tabsArr.append(tabObj); } - window_obj.insert("tabs", tabs_arr); - window_arr.append(window_obj); + windowObj.insert("tabs", tabsArr); + windowArr.append(windowObj); } QJsonObject obj; - obj.insert("windows", window_arr); + obj.insert("windows", windowArr); document.setObject(obj); // save file @@ -497,6 +477,32 @@ void WindowManager::queueSave() 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) { switch (node->getType()) @@ -506,11 +512,12 @@ void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj) obj.insert("moderationMode", node->getSplit()->getModerationMode()); QJsonObject split; - encodeChannel(node->getSplit()->getIndirectChannel(), split); + WindowManager::encodeChannel(node->getSplit()->getIndirectChannel(), + split); obj.insert("data", split); QJsonArray filters; - encodeFilters(node->getSplit(), filters); + WindowManager::encodeFilters(node->getSplit(), filters); obj.insert("filters", filters); } break; @@ -520,14 +527,14 @@ void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj) ? "horizontal" : "vertical"); - QJsonArray items_arr; + QJsonArray itemsArr; for (const std::unique_ptr &n : node->getChildren()) { QJsonObject subObj; - this->encodeNodeRecursively(n.get(), subObj); - items_arr.append(subObj); + WindowManager::encodeNodeRecursively(n.get(), subObj); + itemsArr.append(subObj); } - obj.insert("items", items_arr); + obj.insert("items", itemsArr); } break; } diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 42a6f5d51..87151d204 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -30,6 +30,8 @@ public: WindowManager(); ~WindowManager() override; + static void encodeTab(SplitContainer *tab, bool isSelected, + QJsonObject &obj); static void encodeChannel(IndirectChannel channel, QJsonObject &obj); static void encodeFilters(Split *split, QJsonArray &arr); static IndirectChannel decodeChannel(const SplitDescriptor &descriptor); @@ -99,7 +101,8 @@ public: pajlada::Signals::Signal selectSplitContainer; 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 WindowLayout loadWindowLayoutFromFile() const; diff --git a/src/util/StreamLink.cpp b/src/util/StreamLink.cpp index b631cad51..8b61ca9d7 100644 --- a/src/util/StreamLink.cpp +++ b/src/util/StreamLink.cpp @@ -10,6 +10,7 @@ #include #include #include "common/QLogging.hpp" +#include "common/Version.hpp" #include @@ -35,18 +36,6 @@ namespace { #endif } - QString getStreamlinkProgram() - { - if (getSettings()->streamlinkUseCustomPath) - { - return getSettings()->streamlinkPath + "/" + getBinaryName(); - } - else - { - return getBinaryName(); - } - } - bool checkStreamlinkPath(const QString &path) { QFileInfo fileinfo(path); @@ -83,7 +72,27 @@ namespace { QProcess *createStreamlinkProcess() { 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) { 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(); } @@ -173,7 +183,9 @@ void getStreamQualities(const QString &channelURL, void openStreamlink(const QString &channelURL, const QString &quality, 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 // 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(); arguments << splitCommand(additionalOptions); - bool res = QProcess::startDetached(getStreamlinkProgram(), arguments); + proc->setArguments(std::move(arguments)); + bool res = proc->startDetached(); if (!res) { diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index 932e05fd5..dea06c69c 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -338,6 +338,14 @@ void Window::addShortcuts() } }); + createWindowShortcut(this, "CTRL+SHIFT+N", [this] { + if (auto page = dynamic_cast( + this->notebook_->getSelectedPage())) + { + page->popup(); + } + }); + // Zoom in { auto s = new QShortcut(QKeySequence::ZoomIn, this); diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 706ca839d..6138962e2 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -33,7 +33,7 @@ const QString TEXT_VIEWS("Views: %1"); const QString TEXT_FOLLOWERS("Followers: %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_UNAVAILABLE "(not available)" @@ -513,7 +513,7 @@ void UserInfoPopup::setData(const QString &name, const ChannelPtr &channel) { this->userName_ = name; 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->setProperty("copy-text", name); @@ -598,7 +598,8 @@ void UserInfoPopup::updateUserData() this->avatarUrl_ = user.profileImageUrl; 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( TEXT_VIEWS.arg(localizeNumbers(user.viewCount))); this->ui_.createdDateLabel->setText( diff --git a/src/widgets/helper/NotebookTab.cpp b/src/widgets/helper/NotebookTab.cpp index 9fdd41db0..bb53599fc 100644 --- a/src/widgets/helper/NotebookTab.cpp +++ b/src/widgets/helper/NotebookTab.cpp @@ -64,6 +64,16 @@ NotebookTab::NotebookTab(Notebook *notebook) this->notebook_->removePage(this->page); }); + this->menu_.addAction( + "Popup Tab", + [=]() { + if (auto container = dynamic_cast(this->page)) + { + container->popup(); + } + }, + QKeySequence("Ctrl+Shift+N")); + highlightNewMessagesAction_ = new QAction("Mark Tab as Unread on New Messages", &this->menu_); highlightNewMessagesAction_->setCheckable(true); diff --git a/src/widgets/settingspages/KeyboardSettingsPage.cpp b/src/widgets/settingspages/KeyboardSettingsPage.cpp index 7cad92a5f..fcdc69a00 100644 --- a/src/widgets/settingspages/KeyboardSettingsPage.cpp +++ b/src/widgets/settingspages/KeyboardSettingsPage.cpp @@ -44,6 +44,8 @@ KeyboardSettingsPage::KeyboardSettingsPage() form->addRow(new QLabel("Ctrl + Shift + T"), new QLabel("Create new tab")); form->addRow(new QLabel("Ctrl + Shift + W"), 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"), new QLabel("Hide/Show similar messages (See General->R9K)")); diff --git a/src/widgets/splits/SplitContainer.cpp b/src/widgets/splits/SplitContainer.cpp index 4a04ba199..5e1543c95 100644 --- a/src/widgets/splits/SplitContainer.cpp +++ b/src/widgets/splits/SplitContainer.cpp @@ -9,6 +9,7 @@ #include "util/Helpers.hpp" #include "util/LayoutCreator.hpp" #include "widgets/Notebook.hpp" +#include "widgets/Window.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/NotebookTab.hpp" #include "widgets/splits/ClosedSplits.hpp" @@ -761,6 +762,33 @@ void SplitContainer::applyFromDescriptor(const NodeDescriptor &rootNode) 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( const NodeDescriptor &rootNode, Node *node) { diff --git a/src/widgets/splits/SplitContainer.hpp b/src/widgets/splits/SplitContainer.hpp index bb26e43dc..1662f0b68 100644 --- a/src/widgets/splits/SplitContainer.hpp +++ b/src/widgets/splits/SplitContainer.hpp @@ -202,6 +202,8 @@ public: void applyFromDescriptor(const NodeDescriptor &rootNode); + void popup(); + protected: void paintEvent(QPaintEvent *event) override; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8b1fafe99..4c15a4902 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,6 +2,7 @@ project(chatterino-test) set(test_SOURCES ${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/NetworkCommon.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NetworkRequest.cpp diff --git a/tests/src/ChannelChatters.cpp b/tests/src/ChannelChatters.cpp new file mode 100644 index 000000000..e0006a205 --- /dev/null +++ b/tests/src/ChannelChatters.cpp @@ -0,0 +1,113 @@ +#include "common/ChannelChatters.hpp" + +#include +#include +#include + +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")); +}