feat: backend баннеров
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
# Баннеры (informer) в web-версии MAX
|
||||||
|
|
||||||
|
Баннеры вроде «Публичные каналы — для всех» внутри кода называются **informer**.
|
||||||
|
Они **полностью приходят с сервера** по WebSocket запросом с опкодом `302`.
|
||||||
|
В JS-бандлах текста баннеров нет — локально хранятся только счётчики показов.
|
||||||
|
|
||||||
|
Исходники: `dump/web.max.ru/_app/immutable/chunks/BWjA24QQ.js` (логика),
|
||||||
|
`_app/immutable/nodes/9.Dqm5zlvE.js` (рендер тайла + аналитика).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
1. В общем sync-ответе сессии сервер выставляет бит `1` в bitmask-поле `updates`:
|
||||||
|
```js
|
||||||
|
e.updates && new Pu(e.updates).get(1) && await J.banners.resync()
|
||||||
|
```
|
||||||
|
2. Клиент шлёт запрос с опкодом **302**:
|
||||||
|
```js
|
||||||
|
Ah = Y(async ({bannersSync:e}, {send:t}) => await t(302, {bannersSync:e}))
|
||||||
|
// resync(): let e = await Ah({bannersSync:0}); await this.upsert(e);
|
||||||
|
```
|
||||||
|
3. Сервер отвечает `{showTime, updateTime, banners:[…]}`.
|
||||||
|
4. Какой баннер показать — считается на клиенте (`activeBanner`): фильтр `canShow`,
|
||||||
|
затем выбор с максимальным `priority`.
|
||||||
|
5. Весь механизм гейтится серверным конфигом `informer-enabled`.
|
||||||
|
6. Состояние показов хранится в `localStorage['__oneme_informer']`:
|
||||||
|
```js
|
||||||
|
informerState = { lastShowedBannerId: null, banners: { /* id -> {showCounter, closedTime} */ } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример ответа сервера
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ver": 10,
|
||||||
|
"cmd": 1,
|
||||||
|
"seq": 14,
|
||||||
|
"opcode": 302,
|
||||||
|
"payload": {
|
||||||
|
"showTime": 86400000,
|
||||||
|
"updateTime": 1784123975588,
|
||||||
|
"banners": [
|
||||||
|
{
|
||||||
|
"id": "public_channels_cid_test",
|
||||||
|
"title": "Публичные каналы — для всех",
|
||||||
|
"settings": 1,
|
||||||
|
"description": "Привлекайте новую аудиторию",
|
||||||
|
"priority": 16,
|
||||||
|
"repeat": 1,
|
||||||
|
"rerun": 5400000000000000,
|
||||||
|
"animojiId": 1446,
|
||||||
|
"url": "https://max.ru/max_news/AZ9kk1KhJAo",
|
||||||
|
"type": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конверт (обёртка WS-сообщения)
|
||||||
|
|
||||||
|
| Поле | Значение | На что влияет |
|
||||||
|
|---|---|---|
|
||||||
|
| `ver` | 10 | Версия протокола WebSocket. |
|
||||||
|
| `cmd` | 1 | Тип пакета. `1` = ответ на запрос (response), не server-push. |
|
||||||
|
| `seq` | 14 | Номер запроса. Клиент послал `302 {bannersSync:0}` под этим seq — сервер отвечает тем же seq для матчинга. |
|
||||||
|
| `opcode` | 302 | Код операции = синхронизация баннеров/informer. |
|
||||||
|
| `payload` | {…} | Полезная нагрузка, попадает в `banners.upsert(e)`. |
|
||||||
|
|
||||||
|
## payload — верхний уровень
|
||||||
|
|
||||||
|
**`showTime`: 86400000** (24 ч в мс)
|
||||||
|
Сколько времени баннер считается «свежим» после первого показа.
|
||||||
|
```js
|
||||||
|
isShowTimeOver = ka.now - this.showAt > this.showTime
|
||||||
|
```
|
||||||
|
Прошло больше `showTime` с момента первого показа → `canShow` = false, баннер пропадает.
|
||||||
|
|
||||||
|
**`updateTime`: 1784123975588** (unix-мс)
|
||||||
|
Версия набора баннеров. Новые данные применяются только если пришло свежее:
|
||||||
|
```js
|
||||||
|
e.banners && e.updateTime > this.informer.updateTime && (…apply…)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Объект баннера
|
||||||
|
|
||||||
|
**`id`: `"public_channels_cid_test"`**
|
||||||
|
Уникальный ключ. По нему хранятся `showCounter` и `closedTime` в `localStorage['__oneme_informer']`
|
||||||
|
и запоминается `lastShowedBannerId` (чтобы не показать один баннер подряд дважды).
|
||||||
|
Суффикс `_test` = тестовый баннер.
|
||||||
|
|
||||||
|
**`title`: «Публичные каналы — для всех»** — верхняя (цветная) строка тайла.
|
||||||
|
|
||||||
|
**`description`: «Привлекайте новую аудиторию»** — вторая строка. Оба текста только с сервера.
|
||||||
|
|
||||||
|
**`settings`: 1** — битовая маска флагов отображения (`new Pu(settings)`). Биты:
|
||||||
|
- `1` = TEXT_ANIMATION — анимация текста
|
||||||
|
- `2` = HIDE_CLOSE_BUTTON — скрыть крестик
|
||||||
|
- `4` = HIDE_ON_CLICK — закрывать по клику
|
||||||
|
- `8` = ICON_THEME_COLOR — красить иконку в цвет темы
|
||||||
|
|
||||||
|
Здесь `1` → только анимация текста; крестик «×» есть, клик ведёт по ссылке.
|
||||||
|
|
||||||
|
**`priority`: 16**
|
||||||
|
Приоритет при нескольких баннерах — показывается один с максимальным:
|
||||||
|
```js
|
||||||
|
e.reduce((a,b) => b.priority > a.priority ? b : a)
|
||||||
|
```
|
||||||
|
При одном баннере роли не играет.
|
||||||
|
|
||||||
|
**`repeat`: 1**
|
||||||
|
Максимум показов:
|
||||||
|
```js
|
||||||
|
hasRemainingShows = showCounter===0 ? true : showCounter < repeat
|
||||||
|
```
|
||||||
|
`1` → показать ровно один раз.
|
||||||
|
|
||||||
|
**`rerun`: 5400000000000000**
|
||||||
|
Кулдаун (мс) перед повторным показом после закрытия крестиком:
|
||||||
|
```js
|
||||||
|
isCloseCooldownOver = closedAt ? (rerun>0 ? Date.now()-closedAt > rerun : false) : true
|
||||||
|
```
|
||||||
|
Значение огромное (~171 000 лет) → «закрыл — больше не увидишь».
|
||||||
|
|
||||||
|
**`animojiId`: 1446**
|
||||||
|
ID аниможи-иконки слева в тайле:
|
||||||
|
```js
|
||||||
|
J.assets.animojisCache.getLazy(bu.resolveId(this.$.animojiId))
|
||||||
|
```
|
||||||
|
|
||||||
|
**`url`: `https://max.ru/max_news/AZ9kk1KhJAo`**
|
||||||
|
Куда ведёт клик (вместе с `type:1`).
|
||||||
|
|
||||||
|
**`type`: 1** — тип баннера:
|
||||||
|
- `0` TYPE_TEXT — просто текст
|
||||||
|
- `1` TYPE_LINK — кликабельный, открывает `url` ← этот случай
|
||||||
|
- `2` TYPE_UPDATE — баннер обновления приложения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог по этому баннеру
|
||||||
|
|
||||||
|
Тестовый кликабельный баннер про публичные каналы: показывается один раз (`repeat:1`),
|
||||||
|
с анимацией текста (`settings:1`), «живёт» сутки после показа (`showTime`), по клику
|
||||||
|
открывает `max.ru/max_news/...`; если закрыть крестиком — не возвращается практически
|
||||||
|
никогда (`rerun`). Приоритет 16 нужен только при конкуренции нескольких баннеров.
|
||||||
|
|
||||||
|
Убрать/подменить баннер локально можно, перехватив ответ на опкод `302`
|
||||||
|
либо отключив серверный флаг `informer-enabled`.
|
||||||
@@ -11,6 +11,7 @@ import '../../core/storage/app_database.dart';
|
|||||||
import '../../core/storage/spoofing_service.dart';
|
import '../../core/storage/spoofing_service.dart';
|
||||||
import '../../core/storage/token_storage.dart';
|
import '../../core/storage/token_storage.dart';
|
||||||
import '../../core/utils/logger.dart';
|
import '../../core/utils/logger.dart';
|
||||||
|
import 'banners.dart';
|
||||||
import 'chats.dart';
|
import 'chats.dart';
|
||||||
import 'complaints.dart';
|
import 'complaints.dart';
|
||||||
import 'contacts.dart';
|
import 'contacts.dart';
|
||||||
@@ -41,6 +42,7 @@ class AccountModule {
|
|||||||
late final PrivacyModule _privacy = PrivacyModule(_api);
|
late final PrivacyModule _privacy = PrivacyModule(_api);
|
||||||
late final ProfileModule _profile = ProfileModule(_api);
|
late final ProfileModule _profile = ProfileModule(_api);
|
||||||
late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile);
|
late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile);
|
||||||
|
late final BannersModule banners = BannersModule(_api);
|
||||||
final _loginStatusController = StreamController<LoginStatus>.broadcast();
|
final _loginStatusController = StreamController<LoginStatus>.broadcast();
|
||||||
final _noticeController = StreamController<AccountNotice>.broadcast();
|
final _noticeController = StreamController<AccountNotice>.broadcast();
|
||||||
bool _loggedIn = false;
|
bool _loggedIn = false;
|
||||||
@@ -331,6 +333,7 @@ class AccountModule {
|
|||||||
ContactCache.clear();
|
ContactCache.clear();
|
||||||
TranscriptionCache.clear();
|
TranscriptionCache.clear();
|
||||||
ComplaintsModule.clear();
|
ComplaintsModule.clear();
|
||||||
|
banners.clear();
|
||||||
chats.resetForAccountSwitch();
|
chats.resetForAccountSwitch();
|
||||||
|
|
||||||
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
|
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
|
||||||
@@ -345,6 +348,7 @@ class AccountModule {
|
|||||||
ContactCache.clear();
|
ContactCache.clear();
|
||||||
TranscriptionCache.clear();
|
TranscriptionCache.clear();
|
||||||
ComplaintsModule.clear();
|
ComplaintsModule.clear();
|
||||||
|
banners.clear();
|
||||||
chats.resetForAccountSwitch();
|
chats.resetForAccountSwitch();
|
||||||
|
|
||||||
await _api.connect();
|
await _api.connect();
|
||||||
@@ -376,6 +380,7 @@ class AccountModule {
|
|||||||
ContactCache.clear();
|
ContactCache.clear();
|
||||||
TranscriptionCache.clear();
|
TranscriptionCache.clear();
|
||||||
ComplaintsModule.clear();
|
ComplaintsModule.clear();
|
||||||
|
banners.clear();
|
||||||
chats.resetForAccountSwitch();
|
chats.resetForAccountSwitch();
|
||||||
await ContactsModule.primeCacheFromDb(accountId);
|
await ContactsModule.primeCacheFromDb(accountId);
|
||||||
|
|
||||||
@@ -426,6 +431,7 @@ class AccountModule {
|
|||||||
ContactCache.clear();
|
ContactCache.clear();
|
||||||
TranscriptionCache.clear();
|
TranscriptionCache.clear();
|
||||||
ComplaintsModule.clear();
|
ComplaintsModule.clear();
|
||||||
|
banners.clear();
|
||||||
chats.resetForAccountSwitch();
|
chats.resetForAccountSwitch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +617,12 @@ class AccountModule {
|
|||||||
}
|
}
|
||||||
await chats.applyFavorites(profile.id);
|
await chats.applyFavorites(profile.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await banners.initFromLogin(profile.id, data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('Баннеры: $e');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _saveLoginInfo(data, profile.id);
|
await _saveLoginInfo(data, profile.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ class AnimojiModule {
|
|||||||
return list.length <= 6 ? list : list.sublist(0, 6);
|
return list.length <= 6 ? list : list.sublist(0, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Animoji? cached(int id) => _byId[id];
|
||||||
|
|
||||||
|
Future<Animoji?> fetchById(int id) async {
|
||||||
|
final known = _byId[id];
|
||||||
|
if (known != null) return known;
|
||||||
|
final map = await _api.sendRequestMap(Opcode.assetsGetByIds, {
|
||||||
|
'type': 'ANIMOJI',
|
||||||
|
'ids': [id],
|
||||||
|
});
|
||||||
|
final list = map?['animojis'];
|
||||||
|
if (list is! List) return null;
|
||||||
|
for (final e in list) {
|
||||||
|
if (e is! Map) continue;
|
||||||
|
final animoji = Animoji.fromMap(e);
|
||||||
|
if (animoji != null) _byId[animoji.id] = animoji;
|
||||||
|
}
|
||||||
|
return _byId[id];
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> ensureLoaded() {
|
Future<void> ensureLoaded() {
|
||||||
return _loading ??= _load().catchError((Object e) {
|
return _loading ??= _load().catchError((Object e) {
|
||||||
_loading = null;
|
_loading = null;
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../api.dart';
|
||||||
|
import '../../core/protocol/opcode_map.dart';
|
||||||
|
import '../../core/protocol/packet.dart';
|
||||||
|
import '../../core/storage/app_database.dart';
|
||||||
|
import '../../core/utils/logger.dart';
|
||||||
|
import '../../models/informer_banner.dart';
|
||||||
|
|
||||||
|
class BannersModule {
|
||||||
|
static const _snapshotKey = 'informer_banners';
|
||||||
|
static const _stateKey = 'informer_state';
|
||||||
|
static const _enabledKey = 'informer_enabled';
|
||||||
|
static const _serverFlag = 'informer-enabled';
|
||||||
|
static const int _resyncMask = 1;
|
||||||
|
static const int _defaultShowTime = 86400000;
|
||||||
|
|
||||||
|
final Api _api;
|
||||||
|
|
||||||
|
BannersModule(this._api) {
|
||||||
|
_api.pushStream
|
||||||
|
.where((p) => p.opcode == Opcode.notifBanners)
|
||||||
|
.listen(_handlePush);
|
||||||
|
}
|
||||||
|
|
||||||
|
final ValueNotifier<InformerBanner?> activeBanner = ValueNotifier(null);
|
||||||
|
|
||||||
|
int? _accountId;
|
||||||
|
bool _enabled = true;
|
||||||
|
int _updateTime = 0;
|
||||||
|
int _showTime = _defaultShowTime;
|
||||||
|
List<InformerBanner> _banners = const [];
|
||||||
|
final Map<String, BannerShowState> _showState = {};
|
||||||
|
String? _lastShownId;
|
||||||
|
String? _pinnedId;
|
||||||
|
Future<void>? _syncing;
|
||||||
|
|
||||||
|
bool get isEnabled => _enabled;
|
||||||
|
int get showTime => _showTime;
|
||||||
|
List<InformerBanner> get banners => List.unmodifiable(_banners);
|
||||||
|
|
||||||
|
BannerShowState stateOf(String bannerId) =>
|
||||||
|
_showState[bannerId] ?? const BannerShowState();
|
||||||
|
|
||||||
|
Future<void> initFromLogin(
|
||||||
|
int accountId,
|
||||||
|
Map<dynamic, dynamic> loginData,
|
||||||
|
) async {
|
||||||
|
await load(accountId);
|
||||||
|
|
||||||
|
final config = loginData['config'];
|
||||||
|
final serverConfig = config is Map ? config['server'] : null;
|
||||||
|
if (serverConfig is Map && serverConfig.containsKey(_serverFlag)) {
|
||||||
|
await _setEnabled(accountId, _parseFlag(serverConfig[_serverFlag]));
|
||||||
|
}
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
var applied = false;
|
||||||
|
final inline = loginData['banners'];
|
||||||
|
if (inline is Map) {
|
||||||
|
applied = await applyPayload(accountId, inline.cast<dynamic, dynamic>());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applied && !_needsResync(loginData['updates'])) return;
|
||||||
|
await syncFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load(int accountId) async {
|
||||||
|
_accountId = accountId;
|
||||||
|
_pinnedId = null;
|
||||||
|
await _restoreSnapshot(accountId);
|
||||||
|
await _restoreShowState(accountId);
|
||||||
|
final enabled = await AppDatabase.getSyncValue(accountId, _enabledKey);
|
||||||
|
_enabled = enabled != '0';
|
||||||
|
_recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> syncFromServer() {
|
||||||
|
return _syncing ??= _sync().whenComplete(() => _syncing = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> applyPayload(
|
||||||
|
int accountId,
|
||||||
|
Map<dynamic, dynamic> payload,
|
||||||
|
) async {
|
||||||
|
final raw = payload['banners'];
|
||||||
|
if (raw is! List) return false;
|
||||||
|
|
||||||
|
final updateTime = _int(payload['updateTime']);
|
||||||
|
if (_updateTime != 0 && updateTime != null && updateTime <= _updateTime) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsed = <InformerBanner>[];
|
||||||
|
for (final entry in raw) {
|
||||||
|
if (entry is! Map) continue;
|
||||||
|
final banner = InformerBanner.fromMap(entry);
|
||||||
|
if (banner != null) parsed.add(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
_banners = parsed;
|
||||||
|
if (updateTime != null) _updateTime = updateTime;
|
||||||
|
final showTime = _int(payload['showTime']);
|
||||||
|
if (showTime != null && showTime > 0) _showTime = showTime;
|
||||||
|
|
||||||
|
final known = parsed.map((b) => b.id).toSet();
|
||||||
|
_showState.removeWhere((id, _) => !known.contains(id));
|
||||||
|
if (_pinnedId != null && !known.contains(_pinnedId)) _pinnedId = null;
|
||||||
|
if (_lastShownId != null && !known.contains(_lastShownId)) {
|
||||||
|
_lastShownId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _persistSnapshot(accountId);
|
||||||
|
await _persistShowState(accountId);
|
||||||
|
_recompute();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markShown(InformerBanner banner) async {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final current = stateOf(banner.id);
|
||||||
|
_showState[banner.id] = current.copyWith(
|
||||||
|
showCounter: current.showCounter + 1,
|
||||||
|
showAt: current.showAt ?? now,
|
||||||
|
);
|
||||||
|
_lastShownId = banner.id;
|
||||||
|
_pinnedId = banner.id;
|
||||||
|
await _persistShowState(_accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close(InformerBanner banner) async {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
_showState[banner.id] = stateOf(banner.id).copyWith(closedAt: now);
|
||||||
|
if (_pinnedId == banner.id) _pinnedId = null;
|
||||||
|
await _persistShowState(_accountId);
|
||||||
|
_recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markClicked(InformerBanner banner) async {
|
||||||
|
if (!banner.closesOnClick) return;
|
||||||
|
await close(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh() {
|
||||||
|
_pinnedId = null;
|
||||||
|
_recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_accountId = null;
|
||||||
|
_enabled = true;
|
||||||
|
_updateTime = 0;
|
||||||
|
_showTime = _defaultShowTime;
|
||||||
|
_banners = const [];
|
||||||
|
_showState.clear();
|
||||||
|
_lastShownId = null;
|
||||||
|
_pinnedId = null;
|
||||||
|
activeBanner.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sync() async {
|
||||||
|
final accountId = _accountId;
|
||||||
|
if (accountId == null || !_enabled) return;
|
||||||
|
try {
|
||||||
|
final packet = await _api.sendRequest(Opcode.bannersSync, {
|
||||||
|
'bannersSync': 0,
|
||||||
|
});
|
||||||
|
throwIfPacketError(packet);
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is Map) {
|
||||||
|
await applyPayload(accountId, payload.cast<dynamic, dynamic>());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('Баннеры: синхронизация не удалась: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePush(Packet packet) {
|
||||||
|
final accountId = _accountId;
|
||||||
|
if (accountId == null || !_enabled) return;
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is Map && payload['banners'] is List) {
|
||||||
|
unawaited(applyPayload(accountId, payload.cast<dynamic, dynamic>()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(syncFromServer());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recompute() {
|
||||||
|
if (!_enabled) {
|
||||||
|
activeBanner.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pinned = _pinnedId;
|
||||||
|
if (pinned != null) {
|
||||||
|
for (final banner in _banners) {
|
||||||
|
if (banner.id == pinned) {
|
||||||
|
activeBanner.value = banner;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pinnedId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
var candidates = _banners.where((b) => _canShow(b, now)).toList();
|
||||||
|
if (candidates.length > 1 && _lastShownId != null) {
|
||||||
|
final others = candidates.where((b) => b.id != _lastShownId).toList();
|
||||||
|
if (others.isNotEmpty) candidates = others;
|
||||||
|
}
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
activeBanner.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeBanner.value = candidates.reduce(
|
||||||
|
(a, b) => b.priority > a.priority ? b : a,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _canShow(InformerBanner banner, int now) {
|
||||||
|
final state = stateOf(banner.id);
|
||||||
|
if (state.showCounter > 0 && state.showCounter >= banner.repeat) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final showAt = state.showAt;
|
||||||
|
if (showAt != null && now - showAt > _showTime) return false;
|
||||||
|
final closedAt = state.closedAt;
|
||||||
|
if (closedAt != null) {
|
||||||
|
if (banner.rerun <= 0) return false;
|
||||||
|
if (now - closedAt <= banner.rerun) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _needsResync(dynamic updates) {
|
||||||
|
final mask = _int(updates);
|
||||||
|
return mask != null && mask & _resyncMask != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setEnabled(int accountId, bool value) async {
|
||||||
|
_enabled = value;
|
||||||
|
await AppDatabase.setSyncValue(accountId, _enabledKey, value ? '1' : '0');
|
||||||
|
if (!value) activeBanner.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreSnapshot(int accountId) async {
|
||||||
|
_banners = const [];
|
||||||
|
_updateTime = 0;
|
||||||
|
_showTime = _defaultShowTime;
|
||||||
|
final raw = await AppDatabase.getSyncValue(accountId, _snapshotKey);
|
||||||
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
final list = map['banners'];
|
||||||
|
final parsed = <InformerBanner>[];
|
||||||
|
if (list is List) {
|
||||||
|
for (final entry in list) {
|
||||||
|
if (entry is! Map) continue;
|
||||||
|
final banner = InformerBanner.fromMap(entry);
|
||||||
|
if (banner != null) parsed.add(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_banners = parsed;
|
||||||
|
_updateTime = _int(map['updateTime']) ?? 0;
|
||||||
|
final showTime = _int(map['showTime']);
|
||||||
|
if (showTime != null && showTime > 0) _showTime = showTime;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreShowState(int accountId) async {
|
||||||
|
_showState.clear();
|
||||||
|
_lastShownId = null;
|
||||||
|
final raw = await AppDatabase.getSyncValue(accountId, _stateKey);
|
||||||
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
_lastShownId = map['lastShowedBannerId']?.toString();
|
||||||
|
final entries = map['banners'];
|
||||||
|
if (entries is Map) {
|
||||||
|
entries.forEach((key, value) {
|
||||||
|
if (value is Map) {
|
||||||
|
_showState[key.toString()] = BannerShowState.fromJson(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistSnapshot(int accountId) async {
|
||||||
|
await AppDatabase.setSyncValue(
|
||||||
|
accountId,
|
||||||
|
_snapshotKey,
|
||||||
|
jsonEncode({
|
||||||
|
'updateTime': _updateTime,
|
||||||
|
'showTime': _showTime,
|
||||||
|
'banners': _banners.map((b) => b.toJson()).toList(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistShowState(int? accountId) async {
|
||||||
|
if (accountId == null) return;
|
||||||
|
await AppDatabase.setSyncValue(
|
||||||
|
accountId,
|
||||||
|
_stateKey,
|
||||||
|
jsonEncode({
|
||||||
|
'lastShowedBannerId': _lastShownId,
|
||||||
|
'banners': {
|
||||||
|
for (final entry in _showState.entries)
|
||||||
|
entry.key: entry.value.toJson(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _parseFlag(dynamic value) {
|
||||||
|
if (value is bool) return value;
|
||||||
|
if (value is num) return value != 0;
|
||||||
|
if (value is String) {
|
||||||
|
final v = value.trim().toLowerCase();
|
||||||
|
return v == 'true' || v == '1' || v == 'yes' || v == 'on';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _int(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,6 +196,7 @@ abstract class Opcode {
|
|||||||
static const int profileDeleteTime = 200; // Таймер удаления профиля
|
static const int profileDeleteTime = 200; // Таймер удаления профиля
|
||||||
static const int authQrApprove = 290; // Подтверждение QR-входа
|
static const int authQrApprove = 290; // Подтверждение QR-входа
|
||||||
static const int chatSuggest = 300; // Предложения чатов
|
static const int chatSuggest = 300; // Предложения чатов
|
||||||
|
static const int bannersSync = 302; // Синхронизация баннеров (informer)
|
||||||
|
|
||||||
// ── Polls ──────────────────────────────────────────────────────────
|
// ── Polls ──────────────────────────────────────────────────────────
|
||||||
static const int sendVote = 304; // Голосование
|
static const int sendVote = 304; // Голосование
|
||||||
@@ -378,6 +379,7 @@ abstract class Opcode {
|
|||||||
profileDeleteTime: 'PROFILE_DELETE_TIME',
|
profileDeleteTime: 'PROFILE_DELETE_TIME',
|
||||||
authQrApprove: 'AUTH_QR_APPROVE',
|
authQrApprove: 'AUTH_QR_APPROVE',
|
||||||
chatSuggest: 'CHAT_SUGGEST',
|
chatSuggest: 'CHAT_SUGGEST',
|
||||||
|
bannersSync: 'BANNERS_SYNC',
|
||||||
sendVote: 'SEND_VOTE',
|
sendVote: 'SEND_VOTE',
|
||||||
votersListByAnswer: 'VOTERS_LIST_BY_ANSWER',
|
votersListByAnswer: 'VOTERS_LIST_BY_ANSWER',
|
||||||
getPollUpdates: 'GET_POLL_UPDATES',
|
getPollUpdates: 'GET_POLL_UPDATES',
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ final webAppModule = WebAppModule(api);
|
|||||||
final digitalIdModule = DigitalIdModule(webAppModule);
|
final digitalIdModule = DigitalIdModule(webAppModule);
|
||||||
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
||||||
final storiesModule = StoriesModule(api);
|
final storiesModule = StoriesModule(api);
|
||||||
|
final bannersModule = accountModule.banners;
|
||||||
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
|
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
|
||||||
RouteObserver<PageRoute<dynamic>>();
|
RouteObserver<PageRoute<dynamic>>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
abstract class BannerType {
|
||||||
|
static const int text = 0;
|
||||||
|
static const int link = 1;
|
||||||
|
static const int update = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class BannerSettings {
|
||||||
|
static const int textAnimation = 1;
|
||||||
|
static const int hideCloseButton = 2;
|
||||||
|
static const int hideOnClick = 4;
|
||||||
|
static const int iconThemeColor = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InformerBanner {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final int settings;
|
||||||
|
final int priority;
|
||||||
|
final int repeat;
|
||||||
|
final int rerun;
|
||||||
|
final int? animojiId;
|
||||||
|
final String? url;
|
||||||
|
final int type;
|
||||||
|
|
||||||
|
const InformerBanner({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.description = '',
|
||||||
|
this.settings = 0,
|
||||||
|
this.priority = 0,
|
||||||
|
this.repeat = 0,
|
||||||
|
this.rerun = 0,
|
||||||
|
this.animojiId,
|
||||||
|
this.url,
|
||||||
|
this.type = BannerType.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get animatesText => settings & BannerSettings.textAnimation != 0;
|
||||||
|
bool get hidesCloseButton => settings & BannerSettings.hideCloseButton != 0;
|
||||||
|
bool get closesOnClick => settings & BannerSettings.hideOnClick != 0;
|
||||||
|
bool get tintsIconWithTheme =>
|
||||||
|
settings & BannerSettings.iconThemeColor != 0;
|
||||||
|
|
||||||
|
bool get isLink =>
|
||||||
|
type == BannerType.link && url != null && url!.trim().isNotEmpty;
|
||||||
|
bool get isUpdate => type == BannerType.update;
|
||||||
|
bool get isClickable => isLink || isUpdate;
|
||||||
|
|
||||||
|
static InformerBanner? fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
final id = map['id']?.toString();
|
||||||
|
if (id == null || id.isEmpty) return null;
|
||||||
|
final title = map['title']?.toString() ?? '';
|
||||||
|
final description = map['description']?.toString() ?? '';
|
||||||
|
if (title.isEmpty && description.isEmpty) return null;
|
||||||
|
return InformerBanner(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
settings: _int(map['settings']) ?? 0,
|
||||||
|
priority: _int(map['priority']) ?? 0,
|
||||||
|
repeat: _int(map['repeat']) ?? 0,
|
||||||
|
rerun: _int(map['rerun']) ?? 0,
|
||||||
|
animojiId: _int(map['animojiId']),
|
||||||
|
url: map['url']?.toString(),
|
||||||
|
type: _int(map['type']) ?? BannerType.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'settings': settings,
|
||||||
|
'priority': priority,
|
||||||
|
'repeat': repeat,
|
||||||
|
'rerun': rerun,
|
||||||
|
'animojiId': animojiId,
|
||||||
|
'url': url,
|
||||||
|
'type': type,
|
||||||
|
};
|
||||||
|
|
||||||
|
static int? _int(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BannerShowState {
|
||||||
|
final int showCounter;
|
||||||
|
final int? showAt;
|
||||||
|
final int? closedAt;
|
||||||
|
|
||||||
|
const BannerShowState({this.showCounter = 0, this.showAt, this.closedAt});
|
||||||
|
|
||||||
|
BannerShowState copyWith({int? showCounter, int? showAt, int? closedAt}) =>
|
||||||
|
BannerShowState(
|
||||||
|
showCounter: showCounter ?? this.showCounter,
|
||||||
|
showAt: showAt ?? this.showAt,
|
||||||
|
closedAt: closedAt ?? this.closedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
static BannerShowState fromJson(Map<dynamic, dynamic> map) => BannerShowState(
|
||||||
|
showCounter: InformerBanner._int(map['showCounter']) ?? 0,
|
||||||
|
showAt: InformerBanner._int(map['showAt']),
|
||||||
|
closedAt: InformerBanner._int(map['closedTime']),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'showCounter': showCounter,
|
||||||
|
'showAt': showAt,
|
||||||
|
'closedTime': closedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user