feat: вынес общую логику. Добавил статус 'выбирает стикер...'
This commit is contained in:
+41
-6
@@ -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)');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user