Compare commits

...

5 Commits

Author SHA1 Message Date
vladimir.kuznetsov
35f5101fa8 test: test endpoint 2025-07-17 11:24:36 +08:00
vladimir.kuznetsov
63c336d96e Merge branch 'dev' of github.com:amnezia-vpn/amnezia-client into HEAD 2025-07-17 11:14:10 +08:00
aiamnezia
09a67572fb Remove news caching 2025-07-16 19:17:47 +04:00
aiamnezia
4b4b81b395 Add localization for news and notifications 2025-07-16 16:54:39 +04:00
aiamnezia
470ce0f9c8 Add news and notifications 2025-06-20 04:23:32 +04:00
19 changed files with 512 additions and 2 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ deploy/build_32/*
deploy/build_64/*
winbuild*.bat
.cache/
.vscode/
# Qt-es

View File

@@ -2,6 +2,7 @@
#include <QDirIterator>
#include <QTranslator>
#include <QCoreApplication>
#if defined(Q_OS_ANDROID)
#include "core/installedAppsImageProvider.h"
@@ -100,6 +101,9 @@ void CoreController::initModels()
m_apiDevicesModel.reset(new ApiDevicesModel(m_settings, this));
m_engine->rootContext()->setContextProperty("ApiDevicesModel", m_apiDevicesModel.get());
m_newsModel.reset(new NewsModel(m_settings, this));
m_engine->rootContext()->setContextProperty("NewsModel", m_newsModel.get());
}
void CoreController::initControllers()
@@ -151,6 +155,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()

View File

@@ -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<ApiSettingsController> m_apiSettingsController;
QScopedPointer<ApiConfigsController> m_apiConfigsController;
QScopedPointer<ApiPremV1MigrationController> m_apiPremV1MigrationController;
QScopedPointer<ApiNewsController> m_apiNewsController;
QSharedPointer<ContainersModel> m_containersModel;
QSharedPointer<ContainersModel> m_defaultServerContainersModel;
@@ -120,6 +123,7 @@ private:
QSharedPointer<LanguageModel> m_languageModel;
QSharedPointer<ProtocolsModel> m_protocolsModel;
QSharedPointer<SitesModel> m_sitesModel;
QSharedPointer<NewsModel> m_newsModel;
QSharedPointer<AllowedDnsModel> m_allowedDnsModel;
QSharedPointer<AppSplitTunnelingModel> m_appSplitTunnelingModel;
QSharedPointer<ClientManagementModel> m_clientManagementModel;

View File

@@ -0,0 +1,14 @@
<svg width="24" height="24" viewBox="0 0 74 74" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_34)">
<path d="M55.5 12.3333H18.5C15.0942 12.3333 12.3333 15.0943 12.3333 18.5V55.5C12.3333 58.9058 15.0942 61.6667 18.5 61.6667H55.5C58.9057 61.6667 61.6666 58.9058 61.6666 55.5V18.5C61.6666 15.0943 58.9057 12.3333 55.5 12.3333Z" stroke="#CBCAC8" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.5833 24.6667H52.4167" stroke="#CBCAC8" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.5833 37H52.4167" stroke="#CBCAC8" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.5833 49.3333H40.0833" stroke="#CBCAC8" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="61.5" cy="12.5" r="15" fill="#FBB36B" stroke="#1C1D21" stroke-width="5"/>
</g>
<defs>
<clipPath id="clip0_4_34">
<rect width="74" height="74" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 982 B

View File

@@ -0,0 +1,8 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#CBCAC8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<!-- Основа газеты -->
<rect x="4" y="4" width="16" height="16" rx="2"/>
<!-- Линии текста -->
<line x1="7" y1="8" x2="17" y2="8"/>
<line x1="7" y1="12" x2="17" y2="12"/>
<line x1="7" y1="16" x2="13" y2="16"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="17.5" cy="17.5" r="15" fill="#FBB36B" stroke="#1C1D21" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 188 B

View File

@@ -35,6 +35,9 @@
<file>images/controls/mail.svg</file>
<file>images/controls/map-pin.svg</file>
<file>images/controls/more-vertical.svg</file>
<file>images/controls/news.svg</file>
<file>images/controls/news-unread.svg</file>
<file>images/controls/unread-dot.svg</file>
<file>images/controls/plus.svg</file>
<file>images/controls/qr-code.svg</file>
<file>images/controls/radio-button-inner-circle-pressed.png</file>
@@ -49,6 +52,7 @@
<file>images/controls/server.svg</file>
<file>images/controls/settings-2.svg</file>
<file>images/controls/settings.svg</file>
<file>images/controls/settings-news.svg</file>
<file>images/controls/share-2.svg</file>
<file>images/controls/split-tunneling.svg</file>
<file>images/controls/tag.svg</file>
@@ -212,6 +216,8 @@
<file>ui/qml/Pages2/PageSettingsServerServices.qml</file>
<file>ui/qml/Pages2/PageSettingsServersList.qml</file>
<file>ui/qml/Pages2/PageSettingsSplitTunneling.qml</file>
<file>ui/qml/Pages2/PageSettingsNewsNotifications.qml</file>
<file>ui/qml/Pages2/PageSettingsNewsDetail.qml</file>
<file>ui/qml/Pages2/PageProtocolAwgClientSettings.qml</file>
<file>ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml</file>
<file>ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml</file>

View File

@@ -14,7 +14,7 @@ namespace
const char cloudFlareNs1[] = "1.1.1.1";
const char cloudFlareNs2[] = "1.0.0.1";
constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/";
constexpr char gatewayEndpoint[] = "http://192.168.0.222:80/";
}
Settings::Settings(QObject *parent) : QObject(parent), m_settings(ORGANIZATION_NAME, APPLICATION_NAME, this)
@@ -578,3 +578,13 @@ void Settings::setAllowedDnsServers(const QStringList &servers)
{
setValue("Conf/allowedDnsServers", servers);
}
QStringList Settings::readNewsIds() const
{
return value("News/readIds").toStringList();
}
void Settings::setReadNewsIds(const QStringList &ids)
{
setValue("News/readIds", ids);
}

View File

@@ -236,6 +236,9 @@ public:
QStringList allowedDnsServers() const;
void setAllowedDnsServers(const QStringList &servers);
QStringList readNewsIds() const;
void setReadNewsIds(const QStringList &ids);
signals:
void saveLogsChanged(bool enabled);
void screenshotsEnabledChanged(bool enabled);

View File

@@ -0,0 +1,40 @@
#include "apiNewsController.h"
#include <QJsonDocument>
#include <QJsonObject>
ApiNewsController::ApiNewsController(const QSharedPointer<NewsModel> &newsModel,
const std::shared_ptr<Settings> &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;
QJsonObject payload;
payload.insert("locale", m_settings->getAppLanguage().name().split("_").first());
ErrorCode errorCode = gatewayController.post(QString("%1v1/news"), payload, 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);
}

View File

@@ -0,0 +1,32 @@
#ifndef APINEWSCONTROLLER_H
#define APINEWSCONTROLLER_H
#include <QObject>
#include <QSharedPointer>
#include <memory>
#include <QJsonArray>
#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> &newsModel,
const std::shared_ptr<Settings> &settings,
QObject *parent = nullptr);
Q_INVOKABLE void fetchNews();
signals:
void errorOccurred(ErrorCode errorCode);
private:
QSharedPointer<NewsModel> m_newsModel;
std::shared_ptr<Settings> m_settings;
};
#endif // APINEWSCONTROLLER_H

View File

@@ -26,6 +26,8 @@ namespace PageLoader
PageSettingsConnection,
PageSettingsDns,
PageSettingsApplication,
PageSettingsNewsNotifications,
PageSettingsNewsDetail,
PageSettingsBackup,
PageSettingsAbout,
PageSettingsLogging,

View File

@@ -0,0 +1,143 @@
#include "ui/models/newsmodel.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QQmlEngine>
#include <QFile>
#include <QDir>
#include <QStandardPaths>
#include <QJsonDocument>
#include <algorithm>
NewsModel::NewsModel(const std::shared_ptr<Settings> &settings, QObject *parent)
: QAbstractListModel(parent)
, m_settings(settings)
{
loadReadIds();
}
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<int, QByteArray> NewsModel::roleNames() const
{
QHash<int, QByteArray> 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;
m_readIds.insert(m_items[index].id);
saveReadIds();
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<QString> existingIds;
for (const NewsItem &item : m_items) {
existingIds.insert(item.id);
}
QList<NewsItem> 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("title").toString();
item.content = obj.value("content").toString();
item.timestamp = QDateTime::fromString(obj.value("timestamp").toString(), Qt::ISODate);
item.read = m_readIds.contains(id);
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;
}
void NewsModel::loadReadIds()
{
QStringList ids = m_settings->readNewsIds();
m_readIds = QSet<QString>(ids.begin(), ids.end());
}
void NewsModel::saveReadIds() const
{
m_settings->setReadNewsIds(QStringList(m_readIds.begin(), m_readIds.end()));
}

View File

@@ -0,0 +1,61 @@
#ifndef NEWSMODEL_H
#define NEWSMODEL_H
#include <QAbstractListModel>
#include <QDateTime>
#include <QVector>
#include <QString>
#include <QJsonArray>
#include <QSet>
#include <memory>
#include "settings.h"
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(const std::shared_ptr<Settings> &settings, QObject *parent = nullptr);
Q_INVOKABLE void markAsRead(int index);
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<int, QByteArray> roleNames() const override;
signals:
void processedIndexChanged(int index);
void hasUnreadChanged();
private:
QVector<NewsItem> m_items;
int m_processedIndex = -1;
std::shared_ptr<Settings> m_settings;
QSet<QString> m_readIds;
void loadReadIds();
void saveReadIds() const;
};
#endif // NEWSMODEL_H

View File

@@ -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

View File

@@ -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
}
}
}
}

View File

@@ -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 {}
}
}
}
}

View File

@@ -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