diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 6884616..693822e 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -180,6 +180,21 @@ class Api { _setSessionState(SessionState.disconnected); } + void wakeUp() { + if (!_autoReconnect) return; + switch (_sessionState) { + case SessionState.disconnected: + _reconnectAttempts = 0; + _reconnectTimer?.cancel(); + unawaited(connect()); + case SessionState.connecting: + case SessionState.connected: + _reconnectAttempts = 0; + case SessionState.online: + unawaited(_probeLiveness()); + } + } + Future sendHandshake() async { final deviceInfo = DeviceInfoPlugin(); @@ -386,6 +401,31 @@ class Api { if (_autoReconnect) _scheduleReconnect(); } + Future _probeLiveness() async { + if (_sessionState != SessionState.online) return; + final epoch = _sessionEpoch; + try { + await sendRequest(Opcode.ping, { + 'interactive': !KometSettings.ghostMode.value, + }).timeout(const Duration(seconds: 6)); + } catch (_) { + if (_sessionEpoch != epoch || _sessionState != SessionState.online) { + return; + } + logger.w('Пробный пинг не прошёл — принудительный реконнект'); + await _forceReconnect(); + } + } + + Future _forceReconnect() async { + _cleanup(); + await _connection.disconnect(); + _reconnectAttempts = 0; + _reconnectTimer?.cancel(); + _setSessionState(SessionState.disconnected); + if (_autoReconnect) unawaited(connect()); + } + void _cleanup() { _pingTimer?.cancel(); _dataSubscription?.cancel(); @@ -448,12 +488,7 @@ class Api { } void _scheduleReconnect() { - if (_reconnectAttempts >= ServerConfig.maxReconnectAttempts) { - logger.e('Лимит попыток реконнекта'); - return; - } - - final delaySec = (2 * (1 << _reconnectAttempts)).clamp(2, 30); + final delaySec = (2 * (1 << _reconnectAttempts.clamp(0, 3))).clamp(2, 15); _reconnectAttempts++; logger.i('Реконнект через $delaySecс (попытка $_reconnectAttempts)'); diff --git a/lib/core/storage/chat_activity_store.dart b/lib/core/storage/chat_activity_store.dart new file mode 100644 index 0000000..975e9c1 --- /dev/null +++ b/lib/core/storage/chat_activity_store.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +enum ChatActivity { typing, sticker } + +extension ChatActivityLabel on ChatActivity { + String get label => switch (this) { + ChatActivity.typing => 'Печатает...', + ChatActivity.sticker => 'Выбирает стикер...', + }; +} + +ChatActivity chatActivityFromType(dynamic type) => + type == 'STICKER' ? ChatActivity.sticker : ChatActivity.typing; + +class ChatActivityStore { + ChatActivityStore._(); + + static final ChatActivityStore instance = ChatActivityStore._(); + + static const Duration _ttl = Duration(seconds: 6); + + final Map> _users = {}; + final Map> _timers = {}; + final Map> _notifiers = {}; + + ValueListenable listenable(int chatId) => + _notifiers.putIfAbsent( + chatId, + () => ValueNotifier(_current(chatId)), + ); + + ChatActivity? activity(int chatId) => _current(chatId); + + void mark(int chatId, int userId, ChatActivity activity) { + final timers = _timers.putIfAbsent(chatId, () => {}); + timers[userId]?.cancel(); + timers[userId] = Timer(_ttl, () => _remove(chatId, userId)); + _users.putIfAbsent(chatId, () => {})[userId] = activity; + _sync(chatId); + } + + void clearUser(int chatId, int userId) => _remove(chatId, userId); + + void clearChat(int chatId) { + final timers = _timers.remove(chatId); + if (timers != null) { + for (final timer in timers.values) { + timer.cancel(); + } + } + _users.remove(chatId); + _sync(chatId); + } + + void _remove(int chatId, int userId) { + _timers[chatId]?.remove(userId)?.cancel(); + final users = _users[chatId]; + if (users != null) { + users.remove(userId); + if (users.isEmpty) _users.remove(chatId); + } + _sync(chatId); + } + + ChatActivity? _current(int chatId) { + final users = _users[chatId]; + if (users == null || users.isEmpty) return null; + for (final activity in users.values) { + if (activity == ChatActivity.typing) return ChatActivity.typing; + } + return ChatActivity.sticker; + } + + void _sync(int chatId) { + _notifiers[chatId]?.value = _current(chatId); + } +} diff --git a/lib/core/storage/typing_store.dart b/lib/core/storage/typing_store.dart deleted file mode 100644 index 1d7fd94..0000000 --- a/lib/core/storage/typing_store.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -class TypingStore { - TypingStore._(); - - static final TypingStore instance = TypingStore._(); - - static const Duration _ttl = Duration(seconds: 6); - - final Map> _users = {}; - final Map> _timers = {}; - final Map> _notifiers = {}; - - ValueListenable listenable(int chatId) => _notifiers.putIfAbsent( - chatId, - () => ValueNotifier(_users[chatId]?.isNotEmpty ?? false), - ); - - bool isTyping(int chatId) => _users[chatId]?.isNotEmpty ?? false; - - void markTyping(int chatId, int userId) { - final timers = _timers.putIfAbsent(chatId, () => {}); - timers[userId]?.cancel(); - timers[userId] = Timer(_ttl, () => _remove(chatId, userId)); - _users.putIfAbsent(chatId, () => {}).add(userId); - _sync(chatId); - } - - void clearUser(int chatId, int userId) => _remove(chatId, userId); - - void clearChat(int chatId) { - final timers = _timers.remove(chatId); - if (timers != null) { - for (final timer in timers.values) { - timer.cancel(); - } - } - _users.remove(chatId); - _sync(chatId); - } - - void _remove(int chatId, int userId) { - _timers[chatId]?.remove(userId)?.cancel(); - final users = _users[chatId]; - if (users != null) { - users.remove(userId); - if (users.isEmpty) _users.remove(chatId); - } - _sync(chatId); - } - - void _sync(int chatId) { - _notifiers[chatId]?.value = _users[chatId]?.isNotEmpty ?? false; - } -} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 34a6b17..480391c 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -26,6 +26,7 @@ import '../auth/login_screen.dart'; import '../digital_id/digital_id_web_screen.dart'; import '../../widgets/account_switcher_overlay.dart'; import '../../widgets/animated_text_swap.dart'; +import '../../widgets/connection_status.dart'; import '../../../backend/api.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; @@ -39,7 +40,7 @@ import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/draft_store.dart'; import '../../../core/storage/token_storage.dart'; -import '../../../core/storage/typing_store.dart'; +import '../../../core/storage/chat_activity_store.dart'; import '../../../main.dart' show accountModule, api, messagesModule, appRouteObserver; @@ -520,12 +521,19 @@ class _ChatListScreenState extends State final userId = payload['userId']; if (chatId is! int || userId is! int) return; if (userId == (_profile?.id ?? 0)) return; - TypingStore.instance.markTyping(chatId, userId); + ChatActivityStore.instance.mark( + chatId, + userId, + chatActivityFromType(payload['type']), + ); } void _onTypingMessageEvent(MessageEvent event) { if (event is MessageAddedEvent) { - TypingStore.instance.clearUser(event.chatId, event.message.senderId); + ChatActivityStore.instance.clearUser( + event.chatId, + event.message.senderId, + ); } } @@ -1223,9 +1231,8 @@ class _ChatListScreenState extends State ), ), Text( - _sessionState == SessionState.online - ? (_profile?.firstName ?? 'Чат') - : 'Подключение...', + connectionStatusLabel(_sessionState) ?? + (_profile?.firstName ?? 'Чат'), style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -2393,9 +2400,8 @@ class _ChatListScreenState extends State crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: ValueListenableBuilder( - valueListenable: TypingStore.instance - .listenable(int.tryParse(id) ?? 0), + child: _ActivitySubtitle( + chatId: int.tryParse(id) ?? 0, child: draft != null ? Text.rich( TextSpan( @@ -2435,23 +2441,6 @@ class _ChatListScreenState extends State maxLines: 1, overflow: TextOverflow.ellipsis, ), - builder: (context, typing, base) { - return AnimatedTextSwap( - showAlternate: typing, - alternate: Text( - 'печатает...', - style: TextStyle( - color: cs.primary, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - child: base!, - ); - }, ), ), ?statusIcon, @@ -2762,3 +2751,44 @@ class _AnimatedChatTileState extends State<_AnimatedChatTile> ); } } + +class _ActivitySubtitle extends StatefulWidget { + const _ActivitySubtitle({required this.chatId, required this.child}); + + final int chatId; + final Widget child; + + @override + State<_ActivitySubtitle> createState() => _ActivitySubtitleState(); +} + +class _ActivitySubtitleState extends State<_ActivitySubtitle> { + ChatActivity _lastActivity = ChatActivity.typing; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ValueListenableBuilder( + valueListenable: ChatActivityStore.instance.listenable(widget.chatId), + child: widget.child, + builder: (context, activity, base) { + if (activity != null) _lastActivity = activity; + return AnimatedTextSwap( + showAlternate: activity != null, + alternate: Text( + _lastActivity.label.toLowerCase(), + style: TextStyle( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + child: base!, + ); + }, + ); + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index f1652b4..44af4e6 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -36,6 +36,7 @@ import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/push/push_service.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/storage/chat_activity_store.dart'; import '../../../core/storage/draft_store.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/cache/message_session_cache.dart'; @@ -270,8 +271,6 @@ class _ChatScreenState extends State } } - final Set _typingUserIds = {}; - final Map _typingTimers = {}; int _otherStatus = 0; int? _otherSeenTime; int? _participantsCount; @@ -372,6 +371,9 @@ class _ChatScreenState extends State _messageEventSub = ChatsModule.messageEvents .where((e) => e.chatId == widget.chatId) .listen(_onMessageEvent); + ChatActivityStore.instance + .listenable(widget.chatId) + .addListener(_recomputeHeaderStatus); _connSub = api.stateStream.listen((_) { if (mounted) _recomputeHeaderStatus(); }); @@ -879,10 +881,9 @@ class _ChatScreenState extends State n.dispose(); } _photoUploadProgress.clear(); - for (final t in _typingTimers.values) { - t.cancel(); - } - _typingTimers.clear(); + ChatActivityStore.instance + .listenable(widget.chatId) + .removeListener(_recomputeHeaderStatus); PresenceFetch.revision.removeListener(_onPresenceChanged); _headerStatusNotifier.dispose(); _otherReadTime.dispose(); @@ -2551,7 +2552,8 @@ class _ChatScreenState extends State String _headerStatus() { final conn = connectionStatusLabel(api.state); if (conn != null) return conn; - if (_typingUserIds.isNotEmpty) return 'Печатает...'; + final activity = ChatActivityStore.instance.activity(widget.chatId); + if (activity != null) return activity.label; if (widget.chatType == 'CHAT') { final count = _participantsCount ?? chat?.participants.length ?? 0; return '$count участников'; @@ -2573,24 +2575,15 @@ class _ChatScreenState extends State if (payload['chatId'] != widget.chatId) return; final userId = payload['userId']; if (userId is! int || userId == _myId) return; - - _typingTimers[userId]?.cancel(); - _typingTimers[userId] = Timer(const Duration(seconds: 10), () { - if (!mounted) return; - _typingUserIds.remove(userId); - _typingTimers.remove(userId); - _recomputeHeaderStatus(); - }); - if (_typingUserIds.add(userId)) { - _recomputeHeaderStatus(); - } + ChatActivityStore.instance.mark( + widget.chatId, + userId, + chatActivityFromType(payload['type']), + ); } void _clearTyping(int userId) { - _typingTimers.remove(userId)?.cancel(); - if (_typingUserIds.remove(userId)) { - _recomputeHeaderStatus(); - } + ChatActivityStore.instance.clearUser(widget.chatId, userId); } void _onMessageRead(Packet packet) { diff --git a/lib/main.dart b/lib/main.dart index c705efa..b7bf2b1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -455,6 +455,7 @@ class KometAppState extends State DebugSessionLog.instance.flushNow(); } if (state != AppLifecycleState.resumed) return; + api.wakeUp(); CallBridge.instance.checkInitialCall(); if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return; _rescheduleSwitch();