feat: вынес общую логику. Добавил статус 'выбирает стикер...'

This commit is contained in:
torvalds
2026-07-03 00:02:45 +07:00
parent 9a8e5d676f
commit bc0d14c549
6 changed files with 192 additions and 111 deletions
+41 -6
View File
@@ -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<Packet> sendHandshake() async {
final deviceInfo = DeviceInfoPlugin();
@@ -386,6 +401,31 @@ class Api {
if (_autoReconnect) _scheduleReconnect();
}
Future<void> _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<void> _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)');
+79
View File
@@ -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<int, Map<int, ChatActivity>> _users = {};
final Map<int, Map<int, Timer>> _timers = {};
final Map<int, ValueNotifier<ChatActivity?>> _notifiers = {};
ValueListenable<ChatActivity?> listenable(int chatId) =>
_notifiers.putIfAbsent(
chatId,
() => ValueNotifier<ChatActivity?>(_current(chatId)),
);
ChatActivity? activity(int chatId) => _current(chatId);
void mark(int chatId, int userId, ChatActivity activity) {
final timers = _timers.putIfAbsent(chatId, () => <int, Timer>{});
timers[userId]?.cancel();
timers[userId] = Timer(_ttl, () => _remove(chatId, userId));
_users.putIfAbsent(chatId, () => <int, ChatActivity>{})[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);
}
}
-57
View File
@@ -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<int, Set<int>> _users = {};
final Map<int, Map<int, Timer>> _timers = {};
final Map<int, ValueNotifier<bool>> _notifiers = {};
ValueListenable<bool> listenable(int chatId) => _notifiers.putIfAbsent(
chatId,
() => ValueNotifier<bool>(_users[chatId]?.isNotEmpty ?? false),
);
bool isTyping(int chatId) => _users[chatId]?.isNotEmpty ?? false;
void markTyping(int chatId, int userId) {
final timers = _timers.putIfAbsent(chatId, () => <int, Timer>{});
timers[userId]?.cancel();
timers[userId] = Timer(_ttl, () => _remove(chatId, userId));
_users.putIfAbsent(chatId, () => <int>{}).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;
}
}
@@ -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<ChatListScreen>
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<ChatListScreen>
),
),
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<ChatListScreen>
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ValueListenableBuilder<bool>(
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<ChatListScreen>
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<ChatActivity?>(
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!,
);
},
);
}
}
+15 -22
View File
@@ -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<ChatScreen>
}
}
final Set<int> _typingUserIds = {};
final Map<int, Timer> _typingTimers = {};
int _otherStatus = 0;
int? _otherSeenTime;
int? _participantsCount;
@@ -372,6 +371,9 @@ class _ChatScreenState extends State<ChatScreen>
_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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
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) {
+1
View File
@@ -455,6 +455,7 @@ class KometAppState extends State<KometApp>
DebugSessionLog.instance.flushNow();
}
if (state != AppLifecycleState.resumed) return;
api.wakeUp();
CallBridge.instance.checkInitialCall();
if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return;
_rescheduleSwitch();