diff --git a/.gitignore b/.gitignore index 503adc2d..e2284dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ deploy/build_32/* deploy/build_64/* winbuild*.bat .cache/ +.vscode/ # Qt-es diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 0e72ef1a..1fdedc26 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -2,6 +2,7 @@ #include #include +#include #if defined(Q_OS_ANDROID) #include "core/installedAppsImageProvider.h" @@ -100,6 +101,11 @@ void CoreController::initModels() m_apiDevicesModel.reset(new ApiDevicesModel(m_settings, this)); m_engine->rootContext()->setContextProperty("ApiDevicesModel", m_apiDevicesModel.get()); + + m_newsModel.reset(new NewsModel(this)); + m_engine->rootContext()->setContextProperty("NewsModel", m_newsModel.get()); + QObject::connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, + m_newsModel.get(), &NewsModel::saveLocalNews); } void CoreController::initControllers() @@ -151,6 +157,10 @@ void CoreController::initControllers() m_apiPremV1MigrationController.reset(new ApiPremV1MigrationController(m_serversModel, m_settings, this)); m_engine->rootContext()->setContextProperty("ApiPremV1MigrationController", m_apiPremV1MigrationController.get()); + + m_apiNewsController.reset(new ApiNewsController(m_newsModel, m_settings)); + m_engine->rootContext()->setContextProperty("ApiNewsController", m_apiNewsController.get()); + m_apiNewsController->fetchNews(); } void CoreController::initAndroidController() diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 9ae53562..6ee2ae1e 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -8,6 +8,7 @@ #include "ui/controllers/api/apiConfigsController.h" #include "ui/controllers/api/apiSettingsController.h" #include "ui/controllers/api/apiPremV1MigrationController.h" +#include "ui/controllers/api/apiNewsController.h" #include "ui/controllers/appSplitTunnelingController.h" #include "ui/controllers/allowedDnsController.h" #include "ui/controllers/connectionController.h" @@ -43,6 +44,7 @@ #include "ui/models/services/sftpConfigModel.h" #include "ui/models/services/socks5ProxyConfigModel.h" #include "ui/models/sites_model.h" +#include "ui/models/newsmodel.h" #ifndef Q_OS_ANDROID #include "ui/notificationhandler.h" @@ -113,6 +115,7 @@ private: QScopedPointer m_apiSettingsController; QScopedPointer m_apiConfigsController; QScopedPointer m_apiPremV1MigrationController; + QScopedPointer m_apiNewsController; QSharedPointer m_containersModel; QSharedPointer m_defaultServerContainersModel; @@ -120,6 +123,7 @@ private: QSharedPointer m_languageModel; QSharedPointer m_protocolsModel; QSharedPointer m_sitesModel; + QSharedPointer m_newsModel; QSharedPointer m_allowedDnsModel; QSharedPointer m_appSplitTunnelingModel; QSharedPointer m_clientManagementModel; diff --git a/client/images/controls/news-unread.svg b/client/images/controls/news-unread.svg new file mode 100644 index 00000000..22a7b1a0 --- /dev/null +++ b/client/images/controls/news-unread.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/client/images/controls/news.svg b/client/images/controls/news.svg new file mode 100644 index 00000000..92eff99e --- /dev/null +++ b/client/images/controls/news.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/client/images/controls/settings-news.svg b/client/images/controls/settings-news.svg new file mode 100644 index 00000000..39225d46 --- /dev/null +++ b/client/images/controls/settings-news.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/images/controls/unread-dot.svg b/client/images/controls/unread-dot.svg new file mode 100644 index 00000000..3ba4e178 --- /dev/null +++ b/client/images/controls/unread-dot.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/client/resources.qrc b/client/resources.qrc index 72eb15c7..6e5c2255 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -35,6 +35,9 @@ images/controls/mail.svg images/controls/map-pin.svg images/controls/more-vertical.svg + images/controls/news.svg + images/controls/news-unread.svg + images/controls/unread-dot.svg images/controls/plus.svg images/controls/qr-code.svg images/controls/radio-button-inner-circle-pressed.png @@ -49,6 +52,7 @@ images/controls/server.svg images/controls/settings-2.svg images/controls/settings.svg + images/controls/settings-news.svg images/controls/share-2.svg images/controls/split-tunneling.svg images/controls/tag.svg @@ -212,6 +216,8 @@ ui/qml/Pages2/PageSettingsServerServices.qml ui/qml/Pages2/PageSettingsServersList.qml ui/qml/Pages2/PageSettingsSplitTunneling.qml + ui/qml/Pages2/PageSettingsNewsNotifications.qml + ui/qml/Pages2/PageSettingsNewsDetail.qml ui/qml/Pages2/PageProtocolAwgClientSettings.qml ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml diff --git a/client/ui/controllers/api/apiNewsController.cpp b/client/ui/controllers/api/apiNewsController.cpp new file mode 100644 index 00000000..cdf0d20a --- /dev/null +++ b/client/ui/controllers/api/apiNewsController.cpp @@ -0,0 +1,37 @@ +#include "apiNewsController.h" + +#include +#include + +ApiNewsController::ApiNewsController(const QSharedPointer &newsModel, + const std::shared_ptr &settings, + QObject *parent) + : QObject(parent), m_newsModel(newsModel), m_settings(settings) +{ +} + +void ApiNewsController::fetchNews() +{ + GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), + apiDefs::requestTimeoutMsecs, m_settings->isStrictKillSwitchEnabled()); + QByteArray responseBody; + ErrorCode errorCode = gatewayController.get(QString("%1v1/news"), responseBody); + qDebug() << "fetchNews" << errorCode; + if (errorCode != ErrorCode::NoError) { + emit errorOccurred(errorCode); + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(responseBody); + QJsonArray newsArray; + if (doc.isArray()) { + newsArray = doc.array(); + } else if (doc.isObject()) { + QJsonObject obj = doc.object(); + if (obj.value("news").isArray()) { + newsArray = obj.value("news").toArray(); + } + } + + m_newsModel->updateModel(newsArray); +} \ No newline at end of file diff --git a/client/ui/controllers/api/apiNewsController.h b/client/ui/controllers/api/apiNewsController.h new file mode 100644 index 00000000..736c34ec --- /dev/null +++ b/client/ui/controllers/api/apiNewsController.h @@ -0,0 +1,32 @@ +#ifndef APINEWSCONTROLLER_H +#define APINEWSCONTROLLER_H + +#include +#include +#include +#include + +#include "settings.h" +#include "ui/models/newsmodel.h" +#include "core/controllers/gatewayController.h" +#include "core/api/apiDefs.h" + +class ApiNewsController : public QObject +{ + Q_OBJECT +public: + explicit ApiNewsController(const QSharedPointer &newsModel, + const std::shared_ptr &settings, + QObject *parent = nullptr); + + Q_INVOKABLE void fetchNews(); + +signals: + void errorOccurred(ErrorCode errorCode); + +private: + QSharedPointer m_newsModel; + std::shared_ptr m_settings; +}; + +#endif // APINEWSCONTROLLER_H \ No newline at end of file diff --git a/client/ui/controllers/pageController.h b/client/ui/controllers/pageController.h index fc981091..6dd8eda5 100644 --- a/client/ui/controllers/pageController.h +++ b/client/ui/controllers/pageController.h @@ -26,6 +26,8 @@ namespace PageLoader PageSettingsConnection, PageSettingsDns, PageSettingsApplication, + PageSettingsNewsNotifications, + PageSettingsNewsDetail, PageSettingsBackup, PageSettingsAbout, PageSettingsLogging, diff --git a/client/ui/models/newsmodel.cpp b/client/ui/models/newsmodel.cpp new file mode 100644 index 00000000..be7450f6 --- /dev/null +++ b/client/ui/models/newsmodel.cpp @@ -0,0 +1,195 @@ +#include "ui/models/newsmodel.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +NewsModel::NewsModel(QObject *parent) + : QAbstractListModel(parent) +{ + loadLocalNews(); +} + +int NewsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return m_items.size(); +} + +QVariant NewsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return QVariant(); + + const NewsItem &item = m_items.at(index.row()); + switch (role) { + case IdRole: + return item.id; + case TitleRole: + return item.title; + case ContentRole: + return item.content; + case TimestampRole: + return item.timestamp.toString(Qt::ISODate); + case IsReadRole: + return item.read; + case IsProcessedRole: + return index.row() == m_processedIndex; + default: + return QVariant(); + } +} + +QHash NewsModel::roleNames() const +{ + QHash roles; + roles[IdRole] = "id"; + roles[TitleRole] = "title"; + roles[ContentRole] = "content"; + roles[TimestampRole] = "timestamp"; + roles[IsReadRole] = "read"; + roles[IsProcessedRole] = "isProcessed"; + return roles; +} + +void NewsModel::markAsRead(int index) +{ + if (index < 0 || index >= m_items.size()) + return; + if (!m_items[index].read) { + m_items[index].read = true; + QModelIndex idx = createIndex(index, 0); + emit dataChanged(idx, idx, {IsReadRole}); + emit hasUnreadChanged(); + } +} + +int NewsModel::processedIndex() const +{ + return m_processedIndex; +} + +void NewsModel::setProcessedIndex(int index) +{ + if (index < 0 || index >= m_items.size() || m_processedIndex == index) + return; + m_processedIndex = index; + emit processedIndexChanged(index); +} + +void NewsModel::updateModel(const QJsonArray &serverItems) +{ + QSet existingIds; + for (const NewsItem &item : m_items) { + existingIds.insert(item.id); + } + + QList newItems; + for (const QJsonValue &value : serverItems) { + if (!value.isObject()) continue; + const QJsonObject obj = value.toObject(); + QString id = obj.value("id").toString(); + + if (!existingIds.contains(id)) { + NewsItem item; + item.id = id; + item.title = obj.value("header").toString(); + item.content = obj.value("content").toString(); + item.timestamp = QDateTime::fromString(obj.value("timestamp").toString(), Qt::ISODate); + item.read = false; // New news is always unread + newItems.append(item); + existingIds.insert(id); + } + } + + if (!newItems.isEmpty()) { + beginResetModel(); + m_items.append(newItems); + // Sort descending by timestamp (newest first) + std::sort(m_items.begin(), m_items.end(), [](const NewsItem &a, const NewsItem &b) { + return a.timestamp > b.timestamp; + }); + endResetModel(); + emit hasUnreadChanged(); + } +} + +bool NewsModel::hasUnread() const +{ + for (const NewsItem &item : m_items) { + if (!item.read) + return true; + } + return false; +} + +QString NewsModel::localFilePath() const +{ + QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + QDir dir(path); + if (!dir.exists()) { + dir.mkpath("."); + } + return path + "/news.json"; +} + +void NewsModel::loadLocalNews() +{ + QFile file(localFilePath()); + if (!file.exists() || !file.open(QIODevice::ReadOnly)) { + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + + if (!doc.isArray()) { + return; + } + + beginResetModel(); + m_items.clear(); + + QJsonArray newsArray = doc.array(); + for (const QJsonValue &value : newsArray) { + if (!value.isObject()) + continue; + const QJsonObject obj = value.toObject(); + NewsItem item; + item.id = obj.value("id").toString(); + item.title = obj.value("header").toString(); + item.content = obj.value("content").toString(); + item.timestamp = QDateTime::fromString(obj.value("timestamp").toString(), Qt::ISODate); + item.read = obj.value("read").toBool(); + m_items.append(item); + } + endResetModel(); +} + +void NewsModel::saveLocalNews() const +{ + QJsonArray newsArray; + for (const auto &item : m_items) { + QJsonObject obj; + obj["id"] = item.id; + obj["header"] = item.title; + obj["content"] = item.content; + obj["timestamp"] = item.timestamp.toString(Qt::ISODate); + obj["read"] = item.read; + newsArray.append(obj); + } + + QJsonDocument doc(newsArray); + QFile file(localFilePath()); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + file.write(doc.toJson()); + file.close(); + } else { + qWarning() << "Could not save news to" << localFilePath(); + } +} \ No newline at end of file diff --git a/client/ui/models/newsmodel.h b/client/ui/models/newsmodel.h new file mode 100644 index 00000000..5e15bf2b --- /dev/null +++ b/client/ui/models/newsmodel.h @@ -0,0 +1,58 @@ +#ifndef NEWSMODEL_H +#define NEWSMODEL_H + +#include +#include +#include +#include +#include +#include + +struct NewsItem { + QString id; + QString title; + QString content; + QDateTime timestamp; + bool read; +}; + +class NewsModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Roles { + IdRole = Qt::UserRole + 1, + TitleRole, + ContentRole, + TimestampRole, + IsReadRole, + IsProcessedRole + }; + explicit NewsModel(QObject *parent = nullptr); + Q_INVOKABLE void markAsRead(int index); + Q_INVOKABLE void saveLocalNews() const; + + Q_PROPERTY(int processedIndex READ processedIndex WRITE setProcessedIndex NOTIFY processedIndexChanged) + Q_PROPERTY(bool hasUnread READ hasUnread NOTIFY hasUnreadChanged) + int processedIndex() const; + void setProcessedIndex(int index); + + void updateModel(const QJsonArray &items); + bool hasUnread() const; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + +signals: + void processedIndexChanged(int index); + void hasUnreadChanged(); + +private: + QVector m_items; + int m_processedIndex = -1; + void loadLocalNews(); + QString localFilePath() const; +}; + +#endif // NEWSMODEL_H \ No newline at end of file diff --git a/client/ui/qml/Pages2/PageSettings.qml b/client/ui/qml/Pages2/PageSettings.qml index bb83ec92..330f8558 100644 --- a/client/ui/qml/Pages2/PageSettings.qml +++ b/client/ui/qml/Pages2/PageSettings.qml @@ -85,6 +85,21 @@ PageType { DividerType {} + LabelWithButtonType { + id: news + Layout.fillWidth: true + + text: qsTr("News & Notifications") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + leftImageSource: NewsModel.hasUnread ? "qrc:/images/controls/news-unread.svg" : "qrc:/images/controls/news.svg" + + clickedFunction: function() { + PageController.goToPage(PageEnum.PageSettingsNewsNotifications) + } + } + + DividerType {} + LabelWithButtonType { id: backup Layout.fillWidth: true diff --git a/client/ui/qml/Pages2/PageSettingsNewsDetail.qml b/client/ui/qml/Pages2/PageSettingsNewsDetail.qml new file mode 100644 index 00000000..6c462290 --- /dev/null +++ b/client/ui/qml/Pages2/PageSettingsNewsDetail.qml @@ -0,0 +1,68 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import SortFilterProxyModel 0.2 + +PageType { + id: root + property var newsItem + + SortFilterProxyModel { + id: proxyNews + sourceModel: NewsModel + filters: [ ValueFilter { roleName: "isProcessed"; value: true } ] + Component.onCompleted: root.newsItem = proxyNews.get(0) + } + Connections { + target: NewsModel + function onProcessedIndexChanged() { + root.newsItem = proxyNews.get(0) + } + } + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + } + + FlickableType { + id: fl + anchors.top: backButton.bottom + anchors.bottom: parent.bottom + contentHeight: content.height + + ColumnLayout { + id: content + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + headerText: newsItem.title + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: newsItem.content + } + } + } +} \ No newline at end of file diff --git a/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml b/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml new file mode 100644 index 00000000..040b869e --- /dev/null +++ b/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml @@ -0,0 +1,81 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" + +PageType { + id: root + + ColumnLayout { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + anchors.topMargin: 20 + + BackButtonType { + id: backButton + } + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + headerText: qsTr("News & Notifications") + } + } + + ListView { + id: newsList + width: parent.width + anchors.top: header.bottom + anchors.topMargin: 16 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + property bool isFocusable: true + + model: NewsModel + + clip: true + reuseItems: true + + delegate: Item { + implicitWidth: newsList.width + implicitHeight: content.implicitHeight + + ColumnLayout { + id: content + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + LabelWithButtonType { + Layout.fillWidth: true + leftImageSource: read ? "" : "qrc:/images/controls/unread-dot.svg" + isSmallLeftImage: !read + text: title + rightImageSource: "qrc:/images/controls/chevron-right.svg" + + clickedFunction: function() { + NewsModel.markAsRead(index) + NewsModel.processedIndex = index + PageController.goToPage(PageEnum.PageSettingsNewsDetail) + } + } + + DividerType {} + } + } + } +} \ No newline at end of file diff --git a/client/ui/qml/Pages2/PageStart.qml b/client/ui/qml/Pages2/PageStart.qml index 0a21497d..b54648bf 100644 --- a/client/ui/qml/Pages2/PageStart.qml +++ b/client/ui/qml/Pages2/PageStart.qml @@ -367,7 +367,13 @@ PageType { objectName: "settingsTabButton" isSelected: tabBar.currentIndex === 2 - image: "qrc:/images/controls/settings.svg" + image: NewsModel.hasUnread ? "qrc:/images/controls/settings-news.svg" : "qrc:/images/controls/settings.svg" + Binding { + target: settingsTabButton + property: "defaultColor" + value: "transparent" + when: NewsModel.hasUnread + } clickedFunc: function () { tabBarStackView.goToTabBarPage(PageEnum.PageSettings) tabBar.currentIndex = 2