From 38b18c464da634874946aed43329c50c7e0c88ff Mon Sep 17 00:00:00 2001 From: Frank Stack <294698533+FrankBStack@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:35:46 -0400 Subject: [PATCH] Keep typing indicators alive across chat re-entry Typing state was stored on the ConversationViewController, so leaving a chat and coming back dropped the indicator even though the other side was still typing, and the expiry future kept a reference to the dead controller. Move the per-chat typing state into ChatManager, use a plain Timer for the one-minute expiry, and clear it all on data reset. --- .../filesystem/filesystem_service.dart | 2 + lib/services/rustpush/rustpush_service.dart | 41 ++++++------------- lib/services/ui/chat/chat_manager.dart | 32 +++++++++++++++ .../ui/chat/conversation_view_controller.dart | 5 ++- 4 files changed, 49 insertions(+), 31 deletions(-) diff --git a/lib/services/backend/filesystem/filesystem_service.dart b/lib/services/backend/filesystem/filesystem_service.dart index 7eae2912a5..0f3d7fed5c 100644 --- a/lib/services/backend/filesystem/filesystem_service.dart +++ b/lib/services/backend/filesystem/filesystem_service.dart @@ -1,6 +1,7 @@ import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart' as db; import 'package:bluebubbles/services/ui/contact_service.dart'; +import 'package:bluebubbles/services/ui/chat/chat_manager.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -114,6 +115,7 @@ class FilesystemService extends GetxService { if (kIsWeb) return; db.Database.reset(); cs.contacts.clear(); + cm.clearTyping(); } String uriToFilename(String? uri, String? mimeType) { diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 6deb3c1e42..fbdb22c5b2 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -3729,25 +3729,18 @@ class RustPushService extends GetxService { return; } if (myMsg.message is api.Message_Typing) { - if (myMsg.verificationFailed) return; - final controller = cvc(chat); + if (myMsg.verificationFailed) return; + // typing state lives on ChatManager (not the view controller) so it survives leaving the chat + final typingState = cm.typingFor(chat.guid); var handle = RustPushBBUtils.rustHandleToBB(myMsg.sender!); - - if (controller.typingIndicatorData[handle.address] != null) { - controller.typingIndicatorData[handle.address]?.$1.cancel(); - controller.typingIndicatorData.remove(handle.address); - } + // a fresh packet restarts the expiry clock + typingState.data.remove(handle.address)?.$1.cancel(); var typing = myMsg.message as api.Message_Typing; if (typing.field0) { - if (!controller.showTypingIndicatorFor.any((h) => handle.address == h.address)) { - controller.showTypingIndicatorFor.add(handle); + if (!typingState.handles.any((h) => handle.address == h.address)) { + typingState.handles.add(handle); } - var future = Future.delayed(const Duration(minutes: 1)); - var subscription = future.asStream().listen((any) { - controller.showTypingIndicatorFor.remove(handle); - controller.typingIndicatorData.remove(handle.address); - }); Uint8List? icon; if (typing.field1 != null) { String? i = es.cachedStatus.firstWhereOrNull((i) => i.madridBundleId == typing.field1!.bundleId)?.available?.icon; @@ -3757,27 +3750,17 @@ class RustPushService extends GetxService { icon = typing.field1!.icon; } } - controller.typingIndicatorData[handle.address] = (subscription, icon); + // iMessage only announces when typing starts, so expire on our own if nothing follows + final timer = Timer(const Duration(minutes: 1), () => typingState.stop(handle.address)); + typingState.data[handle.address] = (timer, icon); } else { - var existing = controller.showTypingIndicatorFor.firstWhereOrNull((h) => handle.address == h.address); - if (existing != null) { - controller.showTypingIndicatorFor.remove(existing); - } + typingState.stop(handle.address); } return; } if (myMsg.message is api.Message_Message) { - final controller = cvc(chat); - var handle = RustPushBBUtils.rustHandleToBB(myMsg.sender!); - var existing = controller.showTypingIndicatorFor.firstWhereOrNull((h) => handle.address == h.address); - if (existing != null) { - controller.showTypingIndicatorFor.remove(existing); - } - if (controller.typingIndicatorData[handle.address] != null) { - controller.typingIndicatorData[handle.address]?.$1.cancel(); - controller.typingIndicatorData.remove(handle.address); - } + cm.typingFor(chat.guid).stop(handle.address); if (chat.isRpSms && !myMsg.verificationFailed) { var myHandles = await api.getMyPhoneHandles(state: pushService.state!.client); diff --git a/lib/services/ui/chat/chat_manager.dart b/lib/services/ui/chat/chat_manager.dart index 11caeefe2f..bc57844f2a 100644 --- a/lib/services/ui/chat/chat_manager.dart +++ b/lib/services/ui/chat/chat_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:bluebubbles/database/models.dart'; @@ -16,6 +17,18 @@ class ChatManager extends GetxService { ChatLifecycleManager? activeChat; api.ChannelInterestToken? provider; final Map _chatControllers = {}; + /// Typing indicator state keyed by chat guid. Lives here rather than on the + /// ConversationViewController so it survives leaving and re-entering a chat. + final Map _typing = {}; + + TypingState typingFor(String guid) => _typing.putIfAbsent(guid, TypingState.new); + + void clearTyping() { + for (final state in _typing.values) { + state.clear(); + } + _typing.clear(); + } /// Same as setAllInactive but but removes lastOpenedChat from prefs on next frame void setAllInactiveSync({save = true, bool clearActive = true}) { @@ -228,3 +241,22 @@ class ChatManager extends GetxService { return completer.future; } } + +/// Who is currently typing in a chat, with a per-handle expiry timer and optional app icon. +class TypingState { + final RxList handles = [].obs; + final Map data = {}; + + void stop(String address) { + data.remove(address)?.$1.cancel(); + handles.removeWhere((h) => h.address == address); + } + + void clear() { + for (final entry in data.values) { + entry.$1.cancel(); + } + data.clear(); + handles.clear(); + } +} diff --git a/lib/services/ui/chat/conversation_view_controller.dart b/lib/services/ui/chat/conversation_view_controller.dart index a26709b9cc..988217154a 100644 --- a/lib/services/ui/chat/conversation_view_controller.dart +++ b/lib/services/ui/chat/conversation_view_controller.dart @@ -48,7 +48,8 @@ class ConversationViewController extends StatefulController with GetSingleTicker final Map> mlKitParsedText = {}; // message view items - final RxList showTypingIndicatorFor = [].obs; + // typing state is owned by ChatManager so it outlives this controller (see cm.typingFor) + RxList get showTypingIndicatorFor => cm.typingFor(chat.guid).handles; final RxBool showScrollDown = false.obs; final RxDouble timestampOffset = 0.0.obs; final RxBool inSelectMode = false.obs; @@ -58,7 +59,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker final RxBool recipientNotifsSilenced = false.obs; bool showingOverlays = false; bool _subjectWasLastFocused = false; // If this is false, then message field was last focused (default) - final Map, Uint8List?)> typingIndicatorData = {}; + Map get typingIndicatorData => cm.typingFor(chat.guid).data; FocusNode get lastFocusedNode => _subjectWasLastFocused ? subjectFocusNode : focusNode; SpellCheckTextEditingController get lastFocusedTextController => _subjectWasLastFocused ? subjectTextController : textController;