diff --git a/.gitmodules b/.gitmodules index 32042876c..478720eb2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -20,3 +20,7 @@ [submodule "lib/qBreakpad"] path = lib/qBreakpad url = https://github.com/jiakuan/qBreakpad.git +[submodule "lib/WinToast"] + path = lib/WinToast + url = https://github.com/mohabouje/WinToast + diff --git a/chatterino.pro b/chatterino.pro index 69fe3e0c3..131696679 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -52,6 +52,7 @@ include(dependencies/libcommuni.pri) include(dependencies/websocketpp.pri) include(dependencies/openssl.pri) include(dependencies/boost.pri) +include(dependencies/wintoast.pri) # Optional feature: QtWebEngine #exists ($(QTDIR)/include/QtWebEngine/QtWebEngine) { @@ -124,6 +125,7 @@ SOURCES += \ src/controllers/highlights/UserHighlightModel.cpp \ src/controllers/ignores/IgnoreController.cpp \ src/controllers/ignores/IgnoreModel.cpp \ + src/controllers/notifications/NotificationController.cpp \ src/controllers/taggedusers/TaggedUser.cpp \ src/controllers/taggedusers/TaggedUsersController.cpp \ src/controllers/taggedusers/TaggedUsersModel.cpp \ @@ -205,6 +207,7 @@ SOURCES += \ src/widgets/settingspages/KeyboardSettingsPage.cpp \ src/widgets/settingspages/LogsPage.cpp \ src/widgets/settingspages/ModerationPage.cpp \ + src/widgets/settingspages/NotificationPage.cpp \ src/widgets/settingspages/SettingsPage.cpp \ src/widgets/settingspages/SpecialChannelsPage.cpp \ src/widgets/splits/Split.cpp \ @@ -250,6 +253,9 @@ SOURCES += \ src/BrowserExtension.cpp \ src/util/FormatTime.cpp \ src/util/FunctionEventFilter.cpp \ + src/controllers/notifications/NotificationModel.cpp \ + src/singletons/Toasts.cpp \ + src/common/DownloadManager.cpp \ src/widgets/helper/EffectLabel.cpp \ src/widgets/helper/Button.cpp \ src/messages/MessageContainer.cpp \ @@ -292,6 +298,7 @@ HEADERS += \ src/controllers/ignores/IgnoreController.hpp \ src/controllers/ignores/IgnoreModel.hpp \ src/controllers/ignores/IgnorePhrase.hpp \ + src/controllers/notifications/NotificationController.hpp \ src/controllers/taggedusers/TaggedUser.hpp \ src/controllers/taggedusers/TaggedUsersController.hpp \ src/controllers/taggedusers/TaggedUsersModel.hpp \ @@ -394,6 +401,7 @@ HEADERS += \ src/widgets/settingspages/KeyboardSettingsPage.hpp \ src/widgets/settingspages/LogsPage.hpp \ src/widgets/settingspages/ModerationPage.hpp \ + src/widgets/settingspages/NotificationPage.hpp \ src/widgets/settingspages/SettingsPage.hpp \ src/widgets/settingspages/SpecialChannelsPage.hpp \ src/widgets/splits/Split.hpp \ @@ -446,6 +454,9 @@ HEADERS += \ src/BrowserExtension.hpp \ src/util/FormatTime.hpp \ src/util/FunctionEventFilter.hpp \ + src/controllers/notifications/NotificationModel.hpp \ + src/singletons/Toasts.hpp \ + src/common/DownloadManager.hpp \ src/widgets/helper/EffectLabel.hpp \ src/util/LayoutHelper.hpp \ src/widgets/helper/Button.hpp \ @@ -453,7 +464,7 @@ HEADERS += \ src/common/UsernameSet.hpp \ src/widgets/settingspages/AdvancedPage.hpp -RESOURCES += \ +RESOURCES += \ resources/resources.qrc \ resources/resources_autogenerated.qrc diff --git a/dependencies/wintoast.pri b/dependencies/wintoast.pri new file mode 100644 index 000000000..b8a5ee85c --- /dev/null +++ b/dependencies/wintoast.pri @@ -0,0 +1,5 @@ +win32 { + INCLUDEPATH += $$PWD/../lib/wintoast/src/ + SOURCES += \ + $$PWD/../lib/WinToast/src/wintoastlib.cpp +} diff --git a/src/Application.cpp b/src/Application.cpp index 9f0ad0706..2f8c02587 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -5,6 +5,7 @@ #include "controllers/highlights/HighlightController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/moderationactions/ModerationActions.hpp" +#include "controllers/notifications/NotificationController.hpp" #include "controllers/taggedusers/TaggedUsersController.hpp" #include "debug/Log.hpp" #include "messages/MessageBuilder.hpp" @@ -21,6 +22,7 @@ #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" +#include "singletons/Toasts.hpp" #include "singletons/WindowManager.hpp" #include "util/IsBigEndian.hpp" #include "util/PostToThread.hpp" @@ -45,16 +47,19 @@ Application::Application(Settings &_settings, Paths &_paths) , fonts(&this->emplace()) , emotes(&this->emplace()) , windows(&this->emplace()) + , toasts(&this->emplace()) , accounts(&this->emplace()) , commands(&this->emplace()) , highlights(&this->emplace()) + , notifications(&this->emplace()) , ignores(&this->emplace()) , taggedUsers(&this->emplace()) , moderationActions(&this->emplace()) , twitch2(&this->emplace()) , chatterinoBadges(&this->emplace()) , logging(&this->emplace()) + { this->instance = this; diff --git a/src/Application.hpp b/src/Application.hpp index 531fafea3..90e2781af 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -16,6 +16,7 @@ class IgnoreController; class TaggedUsersController; class AccountController; class ModerationActions; +class NotificationController; class Theme; class WindowManager; @@ -26,6 +27,7 @@ class Emotes; class Settings; class Fonts; class Resources2; +class Toasts; class ChatterinoBadges; class Application @@ -53,10 +55,12 @@ public: Fonts *const fonts{}; Emotes *const emotes{}; WindowManager *const windows{}; + Toasts *const toasts{}; AccountController *const accounts{}; CommandController *const commands{}; HighlightController *const highlights{}; + NotificationController *const notifications{}; IgnoreController *const ignores{}; TaggedUsersController *const taggedUsers{}; ModerationActions *const moderationActions{}; diff --git a/src/common/Common.hpp b/src/common/Common.hpp index 7f22bf27d..bea206f78 100644 --- a/src/common/Common.hpp +++ b/src/common/Common.hpp @@ -17,6 +17,7 @@ enum class HighlightState { None, Highlighted, NewMessage, + Notification, }; inline QString qS(const std::string &string) diff --git a/src/common/DownloadManager.cpp b/src/common/DownloadManager.cpp new file mode 100644 index 000000000..f1f701fbc --- /dev/null +++ b/src/common/DownloadManager.cpp @@ -0,0 +1,79 @@ +#include "DownloadManager.hpp" + +#include "singletons/Paths.hpp" + +#include + +namespace chatterino { + +DownloadManager::DownloadManager(QObject *parent) + : QObject(parent) +{ + manager = new QNetworkAccessManager; +} + +DownloadManager::~DownloadManager() +{ + manager->deleteLater(); +} + +void DownloadManager::setFile(QString fileURL, const QString &channelName) +{ + QString filePath = fileURL; + QString saveFilePath; + QStringList filePathList = filePath.split('/'); + saveFilePath = + getPaths()->twitchProfileAvatars + "/twitch/" + channelName + ".png"; + QNetworkRequest request; + request.setUrl(QUrl(fileURL)); + reply = manager->get(request); + + file = new QFile; + file->setFileName(saveFilePath); + file->open(QIODevice::WriteOnly); + + connect(reply, SIGNAL(downloadProgress(qint64, qint64)), this, + SLOT(onDownloadProgress(qint64, qint64))); + connect(manager, SIGNAL(finished(QNetworkReply *)), this, + SLOT(onFinished(QNetworkReply *))); + connect(reply, SIGNAL(readyRead()), this, SLOT(onReadyRead())); + connect(reply, SIGNAL(finished()), this, SLOT(onReplyFinished())); +} + +void DownloadManager::onDownloadProgress(qint64 bytesRead, qint64 bytesTotal) +{ + qDebug(QString::number(bytesRead).toLatin1() + " - " + + QString::number(bytesTotal).toLatin1()); +} + +void DownloadManager::onFinished(QNetworkReply *reply) +{ + switch (reply->error()) { + case QNetworkReply::NoError: { + qDebug("file is downloaded successfully."); + } break; + default: { + qDebug(reply->errorString().toLatin1()); + }; + } + + if (file->isOpen()) { + file->close(); + file->deleteLater(); + } + emit downloadComplete(); +} + +void DownloadManager::onReadyRead() +{ + file->write(reply->readAll()); +} + +void DownloadManager::onReplyFinished() +{ + if (file->isOpen()) { + file->close(); + file->deleteLater(); + } +} +} // namespace chatterino diff --git a/src/common/DownloadManager.hpp b/src/common/DownloadManager.hpp new file mode 100644 index 000000000..a160570c3 --- /dev/null +++ b/src/common/DownloadManager.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "Application.hpp" + +#include +#include +#include +#include +#include +#include + +namespace chatterino { + +class DownloadManager : public QObject +{ + Q_OBJECT +public: + explicit DownloadManager(QObject *parent = nullptr); + virtual ~DownloadManager(); + void setFile(QString fileURL, const QString &channelName); + +private: + QNetworkAccessManager *manager; + QNetworkReply *reply; + QFile *file; + +private slots: + void onDownloadProgress(qint64, qint64); + void onFinished(QNetworkReply *); + void onReadyRead(); + void onReplyFinished(); + +signals: + void downloadComplete(); +}; +} // namespace chatterino diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp new file mode 100644 index 000000000..2b68b130f --- /dev/null +++ b/src/controllers/notifications/NotificationController.cpp @@ -0,0 +1,203 @@ +#include "controllers/notifications/NotificationController.hpp" + +#include "Application.hpp" +#include "common/NetworkRequest.hpp" +#include "common/Outcome.hpp" +#include "controllers/notifications/NotificationModel.hpp" +#include "debug/Log.hpp" +#include "providers/twitch/TwitchApi.hpp" +#include "providers/twitch/TwitchServer.hpp" +#include "singletons/Toasts.hpp" +#include "singletons/WindowManager.hpp" +#include "widgets/Window.hpp" + +#ifdef Q_OS_WIN +# include +#endif + +#include +#include +#include +#include + +namespace chatterino { + +void NotificationController::initialize(Settings &settings, Paths &paths) +{ + this->initialized_ = true; + for (const QString &channelName : this->twitchSetting_.getValue()) { + this->channelMap[Platform::Twitch].appendItem(channelName); + } + + this->channelMap[Platform::Twitch].delayedItemsChanged.connect([this] { // + this->twitchSetting_.setValue( + this->channelMap[Platform::Twitch].getVector()); + }); + /* + for (const QString &channelName : this->mixerSetting_.getValue()) { + this->channelMap[Platform::Mixer].appendItem(channelName); + } + + this->channelMap[Platform::Mixer].delayedItemsChanged.connect([this] { // + this->mixerSetting_.setValue( + this->channelMap[Platform::Mixer].getVector()); + });*/ + + liveStatusTimer_ = new QTimer(); + + this->fetchFakeChannels(); + + QObject::connect(this->liveStatusTimer_, &QTimer::timeout, + [=] { this->fetchFakeChannels(); }); + this->liveStatusTimer_->start(60 * 1000); +} + +void NotificationController::updateChannelNotification( + const QString &channelName, Platform p) +{ + if (isChannelNotified(channelName, p)) { + removeChannelNotification(channelName, p); + } else { + addChannelNotification(channelName, p); + } +} + +bool NotificationController::isChannelNotified(const QString &channelName, + Platform p) +{ + for (const auto &channel : this->channelMap[p].getVector()) { + if (channelName.toLower() == channel.toLower()) { + return true; + } + } + return false; +} + +void NotificationController::addChannelNotification(const QString &channelName, + Platform p) +{ + channelMap[p].appendItem(channelName); +} + +void NotificationController::removeChannelNotification( + const QString &channelName, Platform p) +{ + for (std::vector::size_type i = 0; + i != channelMap[p].getVector().size(); i++) { + if (channelMap[p].getVector()[i].toLower() == channelName.toLower()) { + channelMap[p].removeItem(i); + i--; + } + } +} +void NotificationController::playSound() +{ + static auto player = new QMediaPlayer; + static QUrl currentPlayerUrl; + + QUrl highlightSoundUrl; + if (getSettings()->notificationCustomSound) { + highlightSoundUrl = QUrl::fromLocalFile( + getSettings()->notificationPathSound.getValue()); + } else { + highlightSoundUrl = QUrl("qrc:/sounds/ping2.wav"); + } + if (currentPlayerUrl != highlightSoundUrl) { + player->setMedia(highlightSoundUrl); + + currentPlayerUrl = highlightSoundUrl; + } + player->play(); +} + +NotificationModel *NotificationController::createModel(QObject *parent, + Platform p) +{ + NotificationModel *model = new NotificationModel(parent); + model->init(&this->channelMap[p]); + return model; +} + +void NotificationController::fetchFakeChannels() +{ + for (std::vector::size_type i = 0; + i != channelMap[Platform::Twitch].getVector().size(); i++) { + auto chan = getApp()->twitch.server->getChannelOrEmpty( + channelMap[Platform::Twitch].getVector()[i]); + if (chan->isEmpty()) { + getFakeTwitchChannelLiveStatus( + channelMap[Platform::Twitch].getVector()[i]); + } + } +} + +void NotificationController::getFakeTwitchChannelLiveStatus( + const QString &channelName) +{ + TwitchApi::findUserId(channelName, [channelName, this](QString roomID) { + if (roomID.isEmpty()) { + log("[TwitchChannel:{}] Refreshing live status (Missing ID)", + channelName); + removeFakeChannel(channelName); + return; + } + log("[TwitchChannel:{}] Refreshing live status", channelName); + + QString url("https://api.twitch.tv/kraken/streams/" + roomID); + auto request = NetworkRequest::twitchRequest(url); + request.setCaller(QThread::currentThread()); + + request.onSuccess([this, channelName](auto result) -> Outcome { + rapidjson::Document document = result.parseRapidJson(); + if (!document.IsObject()) { + log("[TwitchChannel:refreshLiveStatus]root is not an object"); + return Failure; + } + + if (!document.HasMember("stream")) { + log("[TwitchChannel:refreshLiveStatus] Missing stream in root"); + return Failure; + } + + const auto &stream = document["stream"]; + + if (!stream.IsObject()) { + // Stream is offline (stream is most likely null) + // removeFakeChannel(channelName); + return Failure; + } + // Stream is live + auto i = std::find(fakeTwitchChannels.begin(), + fakeTwitchChannels.end(), channelName); + + if (!(i != fakeTwitchChannels.end())) { + fakeTwitchChannels.push_back(channelName); + if (Toasts::isEnabled()) { + getApp()->toasts->sendChannelNotification(channelName, + Platform::Twitch); + } + if (getSettings()->notificationPlaySound) { + getApp()->notifications->playSound(); + } + if (getSettings()->notificationFlashTaskbar) { + QApplication::alert( + getApp()->windows->getMainWindow().window(), 2500); + } + } + return Success; + }); + + request.execute(); + }); +} + +void NotificationController::removeFakeChannel(const QString channelName) +{ + auto i = std::find(fakeTwitchChannels.begin(), fakeTwitchChannels.end(), + channelName); + if (i != fakeTwitchChannels.end()) { + fakeTwitchChannels.erase(i); + } +} + +} // namespace chatterino diff --git a/src/controllers/notifications/NotificationController.hpp b/src/controllers/notifications/NotificationController.hpp new file mode 100644 index 000000000..228348add --- /dev/null +++ b/src/controllers/notifications/NotificationController.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "common/SignalVector.hpp" +#include "common/Singleton.hpp" +#include "singletons/Settings.hpp" + +#include + +namespace chatterino { + +class Settings; +class Paths; + +class NotificationModel; + +enum class Platform : uint8_t { + Twitch, // 0 + // Mixer, // 1 +}; + +class NotificationController final : public Singleton, private QObject +{ +public: + virtual void initialize(Settings &settings, Paths &paths) override; + + bool isChannelNotified(const QString &channelName, Platform p); + void updateChannelNotification(const QString &channelName, Platform p); + void addChannelNotification(const QString &channelName, Platform p); + void removeChannelNotification(const QString &channelName, Platform p); + + void playSound(); + + UnsortedSignalVector getVector(Platform p); + + std::map> channelMap; + + NotificationModel *createModel(QObject *parent, Platform p); + +private: + bool initialized_ = false; + + void fetchFakeChannels(); + void removeFakeChannel(const QString channelName); + void getFakeTwitchChannelLiveStatus(const QString &channelName); + + std::vector fakeTwitchChannels; + QTimer *liveStatusTimer_; + + ChatterinoSetting> twitchSetting_ = { + "/notifications/twitch"}; + /* + ChatterinoSetting> mixerSetting_ = { + "/notifications/mixer"}; + */ +}; + +} // namespace chatterino diff --git a/src/controllers/notifications/NotificationModel.cpp b/src/controllers/notifications/NotificationModel.cpp new file mode 100644 index 000000000..5de8bc903 --- /dev/null +++ b/src/controllers/notifications/NotificationModel.cpp @@ -0,0 +1,28 @@ +#include "NotificationModel.hpp" + +#include "Application.hpp" +#include "singletons/Settings.hpp" +#include "util/StandardItemHelper.hpp" + +namespace chatterino { + +NotificationModel::NotificationModel(QObject *parent) + : SignalVectorModel(1, parent) +{ +} + +// turn a vector item into a model row +QString NotificationModel::getItemFromRow(std::vector &row, + const QString &original) +{ + return QString(row[0]->data(Qt::DisplayRole).toString()); +} + +// turn a model +void NotificationModel::getRowFromItem(const QString &item, + std::vector &row) +{ + setStringItem(row[0], item); +} + +} // namespace chatterino diff --git a/src/controllers/notifications/NotificationModel.hpp b/src/controllers/notifications/NotificationModel.hpp new file mode 100644 index 000000000..83d14ba08 --- /dev/null +++ b/src/controllers/notifications/NotificationModel.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include "common/SignalVectorModel.hpp" +#include "controllers/notifications/NotificationController.hpp" + +namespace chatterino { + +class NotificationController; + +class NotificationModel : public SignalVectorModel +{ + explicit NotificationModel(QObject *parent); + +protected: + // turn a vector item into a model row + virtual QString getItemFromRow(std::vector &row, + const QString &original) override; + + // turns a row in the model into a vector item + virtual void getRowFromItem(const QString &item, + std::vector &row) override; + + friend class NotificationController; +}; + +} // namespace chatterino diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index 5fb50f61c..6b50e8399 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -26,6 +26,7 @@ enum class MessageFlag : uint16_t { Untimeout = (1 << 9), PubSub = (1 << 10), Subscription = (1 << 11), + Notification = (1 << 12), }; using MessageFlags = FlagsEnum; diff --git a/src/providers/twitch/TwitchApi.cpp b/src/providers/twitch/TwitchApi.cpp index 33e982191..eb4ddb0cf 100644 --- a/src/providers/twitch/TwitchApi.cpp +++ b/src/providers/twitch/TwitchApi.cpp @@ -6,6 +6,7 @@ #include "providers/twitch/TwitchCommon.hpp" #include +#include namespace chatterino { diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index afc2ad366..ee57e3e5f 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -4,6 +4,7 @@ #include "common/Common.hpp" #include "common/NetworkRequest.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/notifications/NotificationController.hpp" #include "debug/Log.hpp" #include "messages/Message.hpp" #include "providers/bttv/BttvEmotes.hpp" @@ -14,7 +15,10 @@ #include "providers/twitch/TwitchParseCheerEmotes.hpp" #include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" +#include "singletons/Toasts.hpp" +#include "singletons/WindowManager.hpp" #include "util/PostToThread.hpp" +#include "widgets/Window.hpp" #include #include @@ -86,6 +90,12 @@ TwitchChannel::TwitchChannel(const QString &name, { log("[TwitchChannel:{}] Opened", name); + this->tabHighlightRequested.connect([](HighlightState state) {}); + this->liveStatusChanged.connect([this]() { + if (this->isLive() == 1) { + } + }); + this->managedConnect(getApp()->accounts->twitch.currentUserChanged, [=] { this->setMod(false); }); @@ -385,6 +395,30 @@ void TwitchChannel::setLive(bool newLiveStatus) auto guard = this->streamStatus_.access(); if (guard->live != newLiveStatus) { gotNewLiveStatus = true; + if (newLiveStatus) { + if (getApp()->notifications->isChannelNotified( + this->getName(), Platform::Twitch)) { + if (Toasts::isEnabled()) { + getApp()->toasts->sendChannelNotification( + this->getName(), Platform::Twitch); + } + if (getSettings()->notificationPlaySound) { + getApp()->notifications->playSound(); + } + if (getSettings()->notificationFlashTaskbar) { + QApplication::alert( + getApp()->windows->getMainWindow().window(), 2500); + } + } + auto live = makeSystemMessage(this->getName() + " is live"); + this->addMessage(live); + this->tabHighlightRequested.invoke( + HighlightState::Notification); + } else { + auto offline = + makeSystemMessage(this->getName() + " is offline"); + this->addMessage(offline); + } guard->live = newLiveStatus; } } @@ -466,7 +500,6 @@ Outcome TwitchChannel::parseLiveStatus(const rapidjson::Document &document) { auto status = this->streamStatus_.access(); - status->live = true; status->viewerCount = stream["viewers"].GetUint(); status->game = stream["game"].GetString(); status->title = streamChannel["status"].GetString(); @@ -495,7 +528,7 @@ Outcome TwitchChannel::parseLiveStatus(const rapidjson::Document &document) } } } - + setLive(true); // Signal all listeners that the stream status has been updated this->liveStatusChanged.invoke(); diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 82ae58d40..a4a180135 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -19,6 +19,8 @@ namespace chatterino { +enum class HighlightState; + struct Emote; using EmotePtr = std::shared_ptr; class EmoteMap; @@ -90,6 +92,7 @@ public: pajlada::Signals::NoArgSignal userStateChanged; pajlada::Signals::NoArgSignal liveStatusChanged; pajlada::Signals::NoArgSignal roomModesChanged; + pajlada::Signals::Signal tabHighlightRequested; protected: void addRecentChatter(const MessagePtr &message) override; diff --git a/src/singletons/Paths.cpp b/src/singletons/Paths.cpp index 90a094e76..2fd8cef85 100644 --- a/src/singletons/Paths.cpp +++ b/src/singletons/Paths.cpp @@ -130,6 +130,8 @@ void Paths::initSubDirectories() this->cacheDirectory_ = makePath("Cache"); this->messageLogDirectory = makePath("Logs"); this->miscDirectory = makePath("Misc"); + this->twitchProfileAvatars = makePath("ProfileAvatars"); + QDir().mkdir(this->twitchProfileAvatars + "/twitch"); } Paths *getPaths() diff --git a/src/singletons/Paths.hpp b/src/singletons/Paths.hpp index de67e8065..1df1fc77f 100644 --- a/src/singletons/Paths.hpp +++ b/src/singletons/Paths.hpp @@ -28,6 +28,9 @@ public: // Hash of QCoreApplication::applicationFilePath() QString applicationFilePathHash; + // Profile avatars for twitch /cache/twitch + QString twitchProfileAvatars; + bool createFolder(const QString &folderPath); bool isPortable(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 03c5195ab..87b95eda6 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -152,6 +152,18 @@ public: BoolSetting inlineWhispers = {"/whispers/enableInlineWhispers", true}; + /// Notifications + BoolSetting notificationFlashTaskbar = {"/notifications/enableFlashTaskbar", + false}; + BoolSetting notificationPlaySound = {"/notifications/enablePlaySound", + false}; + BoolSetting notificationCustomSound = {"/notifications/customPlaySound", + false}; + QStringSetting notificationPathSound = {"/notifications/highlightSoundPath", + "qrc:/sounds/ping3.wav"}; + + BoolSetting notificationToast = {"/notifications/enableToast", false}; + /// External tools // Streamlink BoolSetting streamlinkUseCustomPath = {"/external/streamlink/useCustomPath", diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index e39a6c873..470fd7543 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -105,6 +105,10 @@ void Theme::actuallyUpdate(double hue, double multiplier) QColor("#000"), {QColor("#b4d7ff"), QColor("#b4d7ff"), QColor("#b4d7ff")}, {QColor("#00aeef"), QColor("#00aeef"), QColor("#00aeef")}}; + this->tabs.notified = { + fg, + {QColor("#252525"), QColor("#252525"), QColor("#252525")}, + {QColor("#F824A8"), QColor("#F824A8"), QColor("#F824A8")}}; } else { this->tabs.regular = { QColor("#aaa"), @@ -123,6 +127,10 @@ void Theme::actuallyUpdate(double hue, double multiplier) QColor("#fff"), {QColor("#555555"), QColor("#555555"), QColor("#555555")}, {QColor("#00aeef"), QColor("#00aeef"), QColor("#00aeef")}}; + this->tabs.notified = { + fg, + {QColor("#252525"), QColor("#252525"), QColor("#252525")}, + {QColor("#F824A8"), QColor("#F824A8"), QColor("#F824A8")}}; } this->splits.input.focusedLine = highlighted; @@ -150,7 +158,7 @@ void Theme::actuallyUpdate(double hue, double multiplier) // QColor("#777"), QColor("#666")}}; this->tabs.bottomLine = this->tabs.selected.backgrounds.regular.color(); - } + } // namespace chatterino // Split bool flat = isLight_; @@ -232,7 +240,7 @@ void Theme::actuallyUpdate(double hue, double multiplier) isLightTheme() ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64); this->updated.invoke(); -} +} // namespace chatterino QColor Theme::blendColors(const QColor &color1, const QColor &color2, qreal ratio) diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index 5ee6f6d58..6c88e7428 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -49,6 +49,7 @@ public: TabColors newMessage; TabColors highlighted; TabColors selected; + TabColors notified; QColor border; QColor bottomLine; } tabs; diff --git a/src/singletons/Toasts.cpp b/src/singletons/Toasts.cpp new file mode 100644 index 000000000..33df8e3b5 --- /dev/null +++ b/src/singletons/Toasts.cpp @@ -0,0 +1,192 @@ +#include "Toasts.hpp" + +#include "Application.hpp" +#include "common/DownloadManager.hpp" +#include "common/NetworkRequest.hpp" +#include "controllers/notifications/NotificationController.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchCommon.hpp" +#include "providers/twitch/TwitchServer.hpp" + +#ifdef Q_OS_WIN + +# include + +#endif + +#include +#include +#include +#include +#include + +#include + +namespace chatterino { + +bool Toasts::isEnabled() +{ +#ifdef Q_OS_WIN + return WinToastLib::WinToast::isCompatible() && + getSettings()->notificationToast; +#endif + return false; +} + +void Toasts::sendChannelNotification(const QString &channelName, Platform p) +{ + // Fetch user profile avatar + if (p == Platform::Twitch) { + QFileInfo check_file(getPaths()->twitchProfileAvatars + "/twitch/" + + channelName + ".png"); + if (check_file.exists() && check_file.isFile()) { +#ifdef Q_OS_WIN + this->sendWindowsNotification(channelName, p); +#endif + // OSX + + // LINUX + + } else { + this->fetchChannelAvatar( + channelName, [this, channelName, p](QString avatarLink) { + DownloadManager *manager = new DownloadManager(); + manager->setFile(avatarLink, channelName); + manager->connect( + manager, &DownloadManager::downloadComplete, + [this, channelName, p]() { +#ifdef Q_OS_WIN + this->sendWindowsNotification(channelName, p); +#endif + // OSX + + // LINUX + }); + }); + } + } + return; +} + +#ifdef Q_OS_WIN + +class CustomHandler : public WinToastLib::IWinToastHandler +{ +private: + QString channelName_; + Platform platform_; + +public: + CustomHandler(QString channelName, Platform p) + : channelName_(channelName) + , platform_(p) + { + } + void toastActivated() const + { + QString link; + if (platform_ == Platform::Twitch) { + link = "http://www.twitch.tv/" + channelName_; + } + QDesktopServices::openUrl(QUrl(link)); + } + + void toastActivated(int actionIndex) const + { + } + + void toastFailed() const + { + } + + void toastDismissed(WinToastDismissalReason state) const + { + } +}; + +void Toasts::sendWindowsNotification(const QString &channelName, Platform p) +{ + WinToastLib::WinToastTemplate templ = WinToastLib::WinToastTemplate( + WinToastLib::WinToastTemplate::ImageAndText03); + QString str = channelName + " is live!"; + std::string utf8_text = str.toUtf8().constData(); + std::wstring widestr = std::wstring(utf8_text.begin(), utf8_text.end()); + + templ.setTextField(widestr, WinToastLib::WinToastTemplate::FirstLine); + templ.setTextField(L"Click here to open in browser", + WinToastLib::WinToastTemplate::SecondLine); + QString Path; + if (p == Platform::Twitch) { + Path = getPaths()->twitchProfileAvatars + "/twitch/" + channelName + + ".png"; + } + std::string temp_Utf8 = Path.toUtf8().constData(); + std::wstring imagePath = std::wstring(temp_Utf8.begin(), temp_Utf8.end()); + templ.setImagePath(imagePath); + if (getSettings()->notificationPlaySound) { + templ.setAudioOption( + WinToastLib::WinToastTemplate::AudioOption::Silent); + } + WinToastLib::WinToast::instance()->setAppName(L"Chatterino2"); + int mbstowcs(wchar_t * aumi_version, const char *CHATTERINO_VERSION, + size_t size); + std::string(CHATTERINO_VERSION); + std::wstring aumi_version = + std::wstring(CHATTERINO_VERSION.begin(), CHATTERINO_VERSION.end()); + WinToastLib::WinToast::instance()->setAppUserModelId( + WinToastLib::WinToast::configureAUMI(L"", L"Chatterino 2", L"", + aumi_version)); + WinToastLib::WinToast::instance()->initialize(); + WinToastLib::WinToast::instance()->showToast( + templ, new CustomHandler(channelName, p)); +} + +#endif + +void Toasts::fetchChannelAvatar(const QString channelName, + std::function successCallback) +{ + QString requestUrl("https://api.twitch.tv/kraken/users?login=" + + channelName); + + NetworkRequest request(requestUrl); + request.setCaller(QThread::currentThread()); + request.makeAuthorizedV5(getDefaultClientID()); + request.setTimeout(30000); + request.onSuccess([successCallback](auto result) mutable -> Outcome { + auto root = result.parseJson(); + if (!root.value("users").isArray()) { + // log("API Error while getting user id, users is not an array"); + successCallback(""); + return Failure; + } + auto users = root.value("users").toArray(); + if (users.size() != 1) { + // log("API Error while getting user id, users array size is not + // 1"); + successCallback(""); + return Failure; + } + if (!users[0].isObject()) { + // log("API Error while getting user id, first user is not an + // object"); + successCallback(""); + return Failure; + } + auto firstUser = users[0].toObject(); + auto avatar = firstUser.value("logo"); + if (!avatar.isString()) { + // log("API Error: while getting user avatar, first user object " + // "`avatar` key " + // "is not a " + // "string"); + successCallback(""); + return Failure; + } + successCallback(avatar.toString()); + return Success; + }); + + request.execute(); +} +} // namespace chatterino diff --git a/src/singletons/Toasts.hpp b/src/singletons/Toasts.hpp new file mode 100644 index 000000000..3a245346f --- /dev/null +++ b/src/singletons/Toasts.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "Application.hpp" +#include "common/Singleton.hpp" + +namespace chatterino { + +enum class Platform : uint8_t; + +class Toasts final : public Singleton +{ +public: + void sendChannelNotification(const QString &channelName, Platform p); + + static bool isEnabled(); + +private: +#ifdef Q_OS_WIN + void sendWindowsNotification(const QString &channelName, Platform p); +#endif + static void fetchChannelAvatar( + const QString channelName, + std::function successCallback); +}; +} // namespace chatterino diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index a4dbec696..d21008bd1 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -17,6 +17,7 @@ #include "widgets/settingspages/LogsPage.hpp" #include "widgets/settingspages/LookPage.hpp" #include "widgets/settingspages/ModerationPage.hpp" +#include "widgets/settingspages/NotificationPage.hpp" #include "widgets/settingspages/SpecialChannelsPage.hpp" #include @@ -102,6 +103,7 @@ void SettingsDialog::addTabs() this->addTab(new KeyboardSettingsPage); // this->addTab(new LogsPage); this->addTab(new ModerationPage); + this->addTab(new NotificationPage); // this->addTab(new SpecialChannelsPage); this->addTab(new BrowserExtensionPage); this->addTab(new ExternalToolsPage); diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index d300e3e02..b97558acb 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -9,6 +9,7 @@ #include "messages/Message.hpp" #include "messages/MessageElement.hpp" #include "messages/layouts/MessageLayout.hpp" +#include "providers/twitch/TwitchChannel.hpp" #include "messages/layouts/MessageLayoutElement.hpp" #include "providers/twitch/TwitchServer.hpp" #include "singletons/Settings.hpp" @@ -535,6 +536,14 @@ void ChannelView::setChannel(ChannelPtr newChannel) this->layoutMessages(); this->queueUpdate(); + + // Notifications + TwitchChannel *tc = dynamic_cast(newChannel.get()); + if (tc != nullptr) { + tc->tabHighlightRequested.connect([this](HighlightState state) { + this->tabHighlightRequested.invoke(HighlightState::Notification); + }); + } } void ChannelView::detachChannel() diff --git a/src/widgets/helper/NotebookTab.cpp b/src/widgets/helper/NotebookTab.cpp index bb01fc460..4225d904b 100644 --- a/src/widgets/helper/NotebookTab.cpp +++ b/src/widgets/helper/NotebookTab.cpp @@ -171,8 +171,8 @@ void NotebookTab::setHighlightState(HighlightState newHighlightStyle) if (this->isSelected()) { return; } - - if (this->highlightState_ != HighlightState::Highlighted) { + if (this->highlightState_ != HighlightState::Highlighted && + this->highlightState_ != HighlightState::Notification) { this->highlightState_ = newHighlightStyle; this->update(); @@ -239,6 +239,8 @@ void NotebookTab::paintEvent(QPaintEvent *) colors = this->theme->tabs.selected; } else if (this->highlightState_ == HighlightState::Highlighted) { colors = this->theme->tabs.highlighted; + } else if (this->highlightState_ == HighlightState::Notification) { + colors = this->theme->tabs.notified; } else if (this->highlightState_ == HighlightState::NewMessage) { colors = this->theme->tabs.newMessage; } else { diff --git a/src/widgets/settingspages/NotificationPage.cpp b/src/widgets/settingspages/NotificationPage.cpp new file mode 100644 index 000000000..e07b6915c --- /dev/null +++ b/src/widgets/settingspages/NotificationPage.cpp @@ -0,0 +1,123 @@ +#include "NotificationPage.hpp" + +#include "Application.hpp" +#include "controllers/notifications/NotificationController.hpp" +#include "controllers/notifications/NotificationModel.hpp" +#include "singletons/Settings.hpp" +#include "util/LayoutCreator.hpp" +#include "widgets/helper/EditableModelView.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace chatterino { + +NotificationPage::NotificationPage() + : SettingsPage("Notifications", "") +{ + LayoutCreator layoutCreator(this); + + auto layout = layoutCreator.emplace().withoutMargin(); + { + auto tabs = layout.emplace(); + { + auto settings = tabs.appendTab(new QVBoxLayout, "Options"); + { + settings.emplace("Enable for selected channels"); + settings.append(this->createCheckBox( + "Flash taskbar", + getSettings()->notificationFlashTaskbar)); + settings.append(this->createCheckBox( + "Playsound (doesn't mute the Windows 8.x sound of toasts)", + getSettings()->notificationPlaySound)); +#ifdef Q_OS_WIN + settings.append(this->createCheckBox( + "Enable toasts (currently only for windows 8.x or 10)", + getSettings()->notificationToast)); +#endif + auto customSound = + layout.emplace().withoutMargin(); + { + customSound.append(this->createCheckBox( + "Custom sound", + getSettings()->notificationCustomSound)); + auto selectFile = customSound.emplace( + "Select custom sound file"); + QObject::connect( + selectFile.getElement(), &QPushButton::clicked, this, + [this] { + auto fileName = QFileDialog::getOpenFileName( + this, tr("Open Sound"), "", + tr("Audio Files (*.mp3 *.wav)")); + getSettings()->notificationPathSound = + fileName; + }); + } + + settings->addStretch(1); + } + auto twitchChannels = tabs.appendTab(new QVBoxLayout, "Twitch"); + { + EditableModelView *view = + twitchChannels + .emplace( + getApp()->notifications->createModel( + nullptr, Platform::Twitch)) + .getElement(); + view->setTitles({"Twitch channels"}); + + view->getTableView()->horizontalHeader()->setSectionResizeMode( + QHeaderView::Fixed); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::Stretch); + + QTimer::singleShot(1, [view] { + view->getTableView()->resizeColumnsToContents(); + view->getTableView()->setColumnWidth(0, 200); + }); + + view->addButtonPressed.connect([] { + getApp() + ->notifications->channelMap[Platform::Twitch] + .appendItem("channel"); + }); + } + /* + auto mixerChannels = tabs.appendTab(new QVBoxLayout, "Mixer"); + { + EditableModelView *view = + mixerChannels + .emplace( + getApp()->notifications->createModel( + nullptr, Platform::Mixer)) + .getElement(); + view->setTitles({"Mixer channels"}); + + view->getTableView()->horizontalHeader()->setSectionResizeMode( + QHeaderView::Fixed); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::Stretch); + + QTimer::singleShot(1, [view] { + view->getTableView()->resizeColumnsToContents(); + view->getTableView()->setColumnWidth(0, 200); + }); + + view->addButtonPressed.connect([] { + getApp() + ->notifications->channelMap[Platform::Mixer] + .appendItem("channel"); + }); + } + */ + } + } +} +} // namespace chatterino diff --git a/src/widgets/settingspages/NotificationPage.hpp b/src/widgets/settingspages/NotificationPage.hpp new file mode 100644 index 000000000..d0e8c12a2 --- /dev/null +++ b/src/widgets/settingspages/NotificationPage.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "widgets/settingspages/SettingsPage.hpp" + +class QPushButton; +class QListWidget; + +class QVBoxLayout; + +namespace chatterino { + +class NotificationPage : public SettingsPage +{ +public: + NotificationPage(); + +private: +}; + +} // namespace chatterino diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index ab90a39d4..61edee4df 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/notifications/NotificationController.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchServer.hpp" #include "singletons/Resources.hpp" @@ -211,6 +212,22 @@ std::unique_ptr SplitHeader::createMainMenu() &Split::openBrowserPlayer); #endif menu->addAction("Open streamlink", this->split_, &Split::openInStreamlink); + + auto action = new QAction(this); + action->setText("Notify when live"); + action->setCheckable(true); + + QObject::connect(menu.get(), &QMenu::aboutToShow, this, [action, this]() { + action->setChecked(getApp()->notifications->isChannelNotified( + this->split_->getChannel()->getName(), Platform::Twitch)); + }); + action->connect(action, &QAction::triggered, this, [this]() { + getApp()->notifications->updateChannelNotification( + this->split_->getChannel()->getName(), Platform::Twitch); + }); + + menu->addAction(action); + menu->addSeparator(); menu->addAction("Reload channel emotes", this, SLOT(reloadChannelEmotes())); menu->addAction("Reconnect", this, SLOT(reconnect()));