feat: завершение веб-сессии и панель состояния подписки
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'dart:ui' show PlatformDispatcher;
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
|
||||||
@@ -22,6 +23,26 @@ class WebPushSubscription {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WebPushLinkInfo {
|
||||||
|
final String endpoint;
|
||||||
|
final DateTime? linkedAt;
|
||||||
|
final String deviceId;
|
||||||
|
|
||||||
|
const WebPushLinkInfo({
|
||||||
|
required this.endpoint,
|
||||||
|
required this.linkedAt,
|
||||||
|
required this.deviceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get shortEndpoint {
|
||||||
|
final token = endpoint.split('/').last;
|
||||||
|
if (token.length <= 24) return token;
|
||||||
|
return '${token.substring(0, 12)}…${token.substring(token.length - 8)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get host => Uri.tryParse(endpoint)?.host ?? endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
class WebPushQrTrack {
|
class WebPushQrTrack {
|
||||||
final String trackId;
|
final String trackId;
|
||||||
final String qrLink;
|
final String qrLink;
|
||||||
@@ -64,6 +85,7 @@ class WebPushService {
|
|||||||
static const String _tokenKey = 'webpush_login_token';
|
static const String _tokenKey = 'webpush_login_token';
|
||||||
static const String _deviceIdKey = 'webpush_device_id';
|
static const String _deviceIdKey = 'webpush_device_id';
|
||||||
static const String _endpointKey = 'webpush_endpoint';
|
static const String _endpointKey = 'webpush_endpoint';
|
||||||
|
static const String _linkedAtKey = 'webpush_linked_at';
|
||||||
|
|
||||||
static const String _appVersion = '26.8.8';
|
static const String _appVersion = '26.8.8';
|
||||||
static const Duration _defaultPoll = Duration(seconds: 5);
|
static const Duration _defaultPoll = Duration(seconds: 5);
|
||||||
@@ -72,11 +94,27 @@ class WebPushService {
|
|||||||
MaxWebSocketSession? _authSocket;
|
MaxWebSocketSession? _authSocket;
|
||||||
MaxWebDevice? _device;
|
MaxWebDevice? _device;
|
||||||
|
|
||||||
|
final ValueNotifier<int> changes = ValueNotifier<int>(0);
|
||||||
|
|
||||||
|
void _notifyChanged() => changes.value++;
|
||||||
|
|
||||||
Future<bool> isAuthorized() async =>
|
Future<bool> isAuthorized() async =>
|
||||||
(await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false;
|
(await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false;
|
||||||
|
|
||||||
Future<String?> linkedEndpoint() => TokenStorage.readSecure(_endpointKey);
|
Future<String?> linkedEndpoint() => TokenStorage.readSecure(_endpointKey);
|
||||||
|
|
||||||
|
Future<WebPushLinkInfo?> linkInfo() async {
|
||||||
|
final endpoint = await TokenStorage.readSecure(_endpointKey);
|
||||||
|
if (endpoint == null || endpoint.isEmpty) return null;
|
||||||
|
|
||||||
|
final stamp = await TokenStorage.readSecure(_linkedAtKey);
|
||||||
|
return WebPushLinkInfo(
|
||||||
|
endpoint: endpoint,
|
||||||
|
linkedAt: stamp == null ? null : DateTime.tryParse(stamp),
|
||||||
|
deviceId: await deviceId(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> deviceId() async {
|
Future<String> deviceId() async {
|
||||||
final saved = await TokenStorage.readSecure(_deviceIdKey);
|
final saved = await TokenStorage.readSecure(_deviceIdKey);
|
||||||
if (saved != null && saved.isNotEmpty) return saved;
|
if (saved != null && saved.isNotEmpty) return saved;
|
||||||
@@ -171,6 +209,7 @@ class WebPushService {
|
|||||||
Future<void> finishAuth(String loginToken) async {
|
Future<void> finishAuth(String loginToken) async {
|
||||||
await TokenStorage.writeSecure(_tokenKey, loginToken);
|
await TokenStorage.writeSecure(_tokenKey, loginToken);
|
||||||
await cancelAuth();
|
await cancelAuth();
|
||||||
|
_notifyChanged();
|
||||||
logger.i('WebPush: WEB-сессия авторизована по QR');
|
logger.i('WebPush: WEB-сессия авторизована по QR');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +244,11 @@ class WebPushService {
|
|||||||
'publicKey': subscription.publicKey,
|
'publicKey': subscription.publicKey,
|
||||||
});
|
});
|
||||||
await TokenStorage.writeSecure(_endpointKey, subscription.endpoint);
|
await TokenStorage.writeSecure(_endpointKey, subscription.endpoint);
|
||||||
|
await TokenStorage.writeSecure(
|
||||||
|
_linkedAtKey,
|
||||||
|
DateTime.now().toIso8601String(),
|
||||||
|
);
|
||||||
|
_notifyChanged();
|
||||||
logger.i('WebPush: подписка зарегистрирована');
|
logger.i('WebPush: подписка зарегистрирована');
|
||||||
} finally {
|
} finally {
|
||||||
await socket.close();
|
await socket.close();
|
||||||
@@ -213,8 +257,55 @@ class WebPushService {
|
|||||||
|
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
await cancelAuth();
|
await cancelAuth();
|
||||||
|
|
||||||
|
final token = await TokenStorage.readSecure(_tokenKey);
|
||||||
|
final endpoint = await TokenStorage.readSecure(_endpointKey);
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await _terminateWebSession(token, endpoint);
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('WebPush: веб-сессию завершить не удалось ($e)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await TokenStorage.deleteSecure(_tokenKey);
|
await TokenStorage.deleteSecure(_tokenKey);
|
||||||
await TokenStorage.deleteSecure(_endpointKey);
|
await TokenStorage.deleteSecure(_endpointKey);
|
||||||
|
await TokenStorage.deleteSecure(_linkedAtKey);
|
||||||
|
_notifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _terminateWebSession(String token, String? endpoint) async {
|
||||||
|
final socket = MaxWebSocketSession(device: await device());
|
||||||
|
try {
|
||||||
|
await socket.connect();
|
||||||
|
await socket.request(Opcode.login, <String, Object?>{
|
||||||
|
'token': token,
|
||||||
|
'chatsCount': 0,
|
||||||
|
'interactive': false,
|
||||||
|
'chatsSync': 0,
|
||||||
|
'contactsSync': 0,
|
||||||
|
'presenceSync': -1,
|
||||||
|
'draftsSync': 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (endpoint != null && endpoint.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await socket.request(Opcode.config, <String, Object?>{
|
||||||
|
'subscribe': false,
|
||||||
|
'pushToken': endpoint,
|
||||||
|
'secretKey': '',
|
||||||
|
'publicKey': '',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('WebPush: подписку снять не удалось ($e)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await socket.request(Opcode.logout, <String, Object?>{});
|
||||||
|
logger.i('WebPush: веб-сессия завершена');
|
||||||
|
} finally {
|
||||||
|
await socket.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MaxWebSocketSession _requireSocket() {
|
MaxWebSocketSession _requireSocket() {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
import '../../../backend/api.dart';
|
import '../../../backend/api.dart';
|
||||||
|
import '../../../core/utils/format.dart';
|
||||||
import '../../../core/utils/haptics.dart';
|
import '../../../core/utils/haptics.dart';
|
||||||
import '../../../core/utils/link_opener.dart';
|
import '../../../core/utils/link_opener.dart';
|
||||||
import '../../../core/webpush/max_web_socket.dart';
|
import '../../../core/webpush/max_web_socket.dart';
|
||||||
import '../../../core/webpush/web_push_service.dart';
|
import '../../../core/webpush/web_push_service.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../main.dart' show accountModule, api;
|
import '../../../main.dart' show accountModule, api;
|
||||||
|
import '../../widgets/confirm_dialog.dart';
|
||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/section_header.dart';
|
import '../../widgets/section_header.dart';
|
||||||
@@ -36,19 +38,26 @@ class _WebPushScreenState extends State<WebPushScreen> {
|
|||||||
_Stage _stage = _Stage.loading;
|
_Stage _stage = _Stage.loading;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
bool _linked = false;
|
bool _linked = false;
|
||||||
|
WebPushLinkInfo? _link;
|
||||||
String? _trackId;
|
String? _trackId;
|
||||||
String? _passwordHint;
|
String? _passwordHint;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WebPushService.instance.changes.addListener(_onServiceChanged);
|
||||||
_reload();
|
_reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onServiceChanged() {
|
||||||
|
if (mounted) _reload();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final listener = _routeAnimationListener;
|
final listener = _routeAnimationListener;
|
||||||
if (listener != null) _routeAnimation?.removeStatusListener(listener);
|
if (listener != null) _routeAnimation?.removeStatusListener(listener);
|
||||||
|
WebPushService.instance.changes.removeListener(_onServiceChanged);
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_passwordFocus.dispose();
|
_passwordFocus.dispose();
|
||||||
WebPushService.instance.cancelAuth();
|
WebPushService.instance.cancelAuth();
|
||||||
@@ -58,10 +67,11 @@ class _WebPushScreenState extends State<WebPushScreen> {
|
|||||||
Future<void> _reload() async {
|
Future<void> _reload() async {
|
||||||
final service = WebPushService.instance;
|
final service = WebPushService.instance;
|
||||||
final authorized = await service.isAuthorized();
|
final authorized = await service.isAuthorized();
|
||||||
final endpoint = await service.linkedEndpoint();
|
final link = await service.linkInfo();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_linked = endpoint != null && endpoint.isNotEmpty;
|
_link = link;
|
||||||
|
_linked = link != null;
|
||||||
_stage = authorized ? _Stage.ready : _Stage.intro;
|
_stage = authorized ? _Stage.ready : _Stage.intro;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,15 +162,28 @@ class _WebPushScreenState extends State<WebPushScreen> {
|
|||||||
setState(() => _stage = _Stage.ready);
|
setState(() => _stage = _Stage.ready);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _signOut() => _run(() async {
|
Future<void> _signOut() async {
|
||||||
await WebPushService.instance.signOut();
|
final l10n = AppLocalizations.of(context)!;
|
||||||
if (!mounted) return;
|
final confirmed = await showConfirmDialog(
|
||||||
setState(() {
|
context,
|
||||||
_linked = false;
|
title: l10n.webPushSignOut,
|
||||||
_trackId = null;
|
message: l10n.webPushSignOutConfirm,
|
||||||
_stage = _Stage.intro;
|
confirmLabel: l10n.webPushSignOutAction,
|
||||||
|
destructive: true,
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
await _run(() async {
|
||||||
|
await WebPushService.instance.signOut();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_linked = false;
|
||||||
|
_link = null;
|
||||||
|
_trackId = null;
|
||||||
|
_stage = _Stage.intro;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -243,6 +266,10 @@ class _WebPushScreenState extends State<WebPushScreen> {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
_explainer(cs, _linked ? l10n.webPushLinkedBody : l10n.webPushInstallBody),
|
_explainer(cs, _linked ? l10n.webPushLinkedBody : l10n.webPushInstallBody),
|
||||||
|
if (_link != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_linkDetails(cs, l10n, _link!),
|
||||||
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_primary(l10n.webPushOpenSite, () {
|
_primary(l10n.webPushOpenSite, () {
|
||||||
Haptics.tap();
|
Haptics.tap();
|
||||||
@@ -263,6 +290,53 @@ class _WebPushScreenState extends State<WebPushScreen> {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Widget _detailRow(ColorScheme cs, String label, String value) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 104,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _linkDetails(
|
||||||
|
ColorScheme cs,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
WebPushLinkInfo link,
|
||||||
|
) => SettingsPanel(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_detailRow(cs, l10n.webPushStatusService, link.host),
|
||||||
|
_detailRow(cs, l10n.webPushStatusToken, link.shortEndpoint),
|
||||||
|
if (link.linkedAt != null)
|
||||||
|
_detailRow(
|
||||||
|
cs,
|
||||||
|
l10n.webPushStatusLinkedAt,
|
||||||
|
formatDateTimeWords(link.linkedAt!),
|
||||||
|
),
|
||||||
|
_detailRow(cs, l10n.webPushStatusDevice, link.deviceId),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Widget _explainer(ColorScheme cs, String text) => SettingsPanel(
|
Widget _explainer(ColorScheme cs, String text) => SettingsPanel(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
|
|||||||
+7
-1
@@ -1209,5 +1209,11 @@
|
|||||||
"webPushNotAuthorized": "Sign in under \"Notifications via PWA\" first",
|
"webPushNotAuthorized": "Sign in under \"Notifications via PWA\" first",
|
||||||
"webPushConnect": "Connect notifications",
|
"webPushConnect": "Connect notifications",
|
||||||
"webPushWaitingBody": "Komet is approving the web session from this device. This usually takes a few seconds.",
|
"webPushWaitingBody": "Komet is approving the web session from this device. This usually takes a few seconds.",
|
||||||
"webPushNeedsOnline": "No connection to the server. Wait for it and try again."
|
"webPushNeedsOnline": "No connection to the server. Wait for it and try again.",
|
||||||
|
"webPushSignOutConfirm": "The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.",
|
||||||
|
"webPushSignOutAction": "Disconnect",
|
||||||
|
"webPushStatusService": "Service",
|
||||||
|
"webPushStatusToken": "Token",
|
||||||
|
"webPushStatusLinkedAt": "Linked",
|
||||||
|
"webPushStatusDevice": "Device"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5317,6 +5317,42 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'No connection to the server. Wait for it and try again.'**
|
/// **'No connection to the server. Wait for it and try again.'**
|
||||||
String get webPushNeedsOnline;
|
String get webPushNeedsOnline;
|
||||||
|
|
||||||
|
/// No description provided for @webPushSignOutConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.'**
|
||||||
|
String get webPushSignOutConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @webPushSignOutAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disconnect'**
|
||||||
|
String get webPushSignOutAction;
|
||||||
|
|
||||||
|
/// No description provided for @webPushStatusService.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Service'**
|
||||||
|
String get webPushStatusService;
|
||||||
|
|
||||||
|
/// No description provided for @webPushStatusToken.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Token'**
|
||||||
|
String get webPushStatusToken;
|
||||||
|
|
||||||
|
/// No description provided for @webPushStatusLinkedAt.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Linked'**
|
||||||
|
String get webPushStatusLinkedAt;
|
||||||
|
|
||||||
|
/// No description provided for @webPushStatusDevice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Device'**
|
||||||
|
String get webPushStatusDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -2806,4 +2806,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get webPushNeedsOnline =>
|
String get webPushNeedsOnline =>
|
||||||
'No connection to the server. Wait for it and try again.';
|
'No connection to the server. Wait for it and try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushSignOutConfirm =>
|
||||||
|
'The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushSignOutAction => 'Disconnect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushStatusService => 'Service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushStatusToken => 'Token';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushStatusLinkedAt => 'Linked';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushStatusDevice => 'Device';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2818,4 +2818,23 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get webPushNeedsOnline =>
|
String get webPushNeedsOnline =>
|
||||||
'Нет связи с сервером. Дождитесь подключения и попробуйте снова.';
|
'Нет связи с сервером. Дождитесь подключения и попробуйте снова.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushSignOutConfirm =>
|
||||||
|
'Веб-сессия будет завершена и исчезнет из списка устройств. Чтобы вернуть уведомления, подключение придётся пройти заново.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushSignOutAction => 'Отключить';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushStatusService => 'Сервис';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushStatusToken => 'Токен';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushStatusLinkedAt => 'Привязан';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get webPushStatusDevice => 'Устройство';
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -953,5 +953,11 @@
|
|||||||
"webPushNotAuthorized": "Сначала войдите в разделе «Уведомления через PWA»",
|
"webPushNotAuthorized": "Сначала войдите в разделе «Уведомления через PWA»",
|
||||||
"webPushConnect": "Подключить уведомления",
|
"webPushConnect": "Подключить уведомления",
|
||||||
"webPushWaitingBody": "Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.",
|
"webPushWaitingBody": "Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.",
|
||||||
"webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова."
|
"webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова.",
|
||||||
|
"webPushSignOutConfirm": "Веб-сессия будет завершена и исчезнет из списка устройств. Чтобы вернуть уведомления, подключение придётся пройти заново.",
|
||||||
|
"webPushSignOutAction": "Отключить",
|
||||||
|
"webPushStatusService": "Сервис",
|
||||||
|
"webPushStatusToken": "Токен",
|
||||||
|
"webPushStatusLinkedAt": "Привязан",
|
||||||
|
"webPushStatusDevice": "Устройство"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user