From 1f80ba164cbba2dbfe05015611c7ead35a39df97 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:05:34 -0700 Subject: [PATCH 1/3] Fix reply navigation and message lifecycle races --- .../widgets/message/message_holder.dart | 35 ++++++++++++------- .../widgets/message/reaction/reaction.dart | 8 ++--- .../ui/message/message_widget_controller.dart | 4 +-- lib/services/ui/message/messages_service.dart | 4 +-- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart index 1710febcea..91b8a42a45 100644 --- a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart @@ -15,6 +15,7 @@ import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/popup/ import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reaction/reaction_holder.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_bubble.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_line_painter.dart'; +import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/text/text_bubble.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/timestamp/message_timestamp.dart'; @@ -182,6 +183,12 @@ class _MessageHolderState extends CustomState reactionsForPart(int part) { return reactions.where((s) => (s.associatedMessagePart ?? 0) == part); } + final replyTarget = replyTo; + MessageWidgetController? replyController; + if (replyTarget?.guid != null) { + replyController = getActiveMwc(replyTarget!.guid!) ?? mwc(replyTarget); + replyController.cvController ??= widget.cvController; + } /// Layout tree /// - Timestamp /// - Stack (see code comment) @@ -272,12 +279,12 @@ class _MessageHolderState extends CustomState tapped.value = !tapped.value : null, child: IgnorePointer( ignoring: widget.cvController.inSelectMode.value, diff --git a/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart index 860caa1bb9..34ddead58e 100644 --- a/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart +++ b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart @@ -12,7 +12,6 @@ import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; import 'package:universal_io/io.dart'; @@ -34,8 +33,7 @@ class ReactionWidget extends StatefulWidget { class ReactionWidgetState extends OptimizedState { late Message reaction = widget.reaction; - late final StreamSubscription sub; - bool hasStream = false; + StreamSubscription? sub; List? get reactions => widget.reactions; bool get reactionIsFromMe => reaction.isFromMe!; @@ -70,8 +68,6 @@ class ReactionWidgetState extends OptimizedState { getActiveMwc(widget.message!.guid!)?.updateAssociatedMessage(reaction, updateHolder: false); } }); - - hasStream = true; } else if (kIsWeb && widget.message != null) { sub = WebListeners.messageUpdate.listen((tuple) { final _message = tuple.item1; @@ -125,7 +121,7 @@ class ReactionWidgetState extends OptimizedState { @override void dispose() { - if (!kIsWeb && hasStream) sub.cancel(); + sub?.cancel(); super.dispose(); } diff --git a/lib/services/ui/message/message_widget_controller.dart b/lib/services/ui/message/message_widget_controller.dart index 2fb6a8edcc..8f60c98076 100644 --- a/lib/services/ui/message/message_widget_controller.dart +++ b/lib/services/ui/message/message_widget_controller.dart @@ -9,10 +9,8 @@ import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; -import 'package:bluebubbles/utils/logger/logger.dart'; MessageWidgetController mwc(Message message) => Get.isRegistered(tag: message.guid) ? Get.find(tag: message.guid) @@ -31,7 +29,7 @@ class MessageWidgetController extends StatefulController with GetSingleTickerPro String? newMessageGuid; ConversationViewController? cvController; late final String tag; - late final StreamSubscription? sub; + StreamSubscription? sub; bool built = false; static const maxBubbleSizeFactor = 0.75; diff --git a/lib/services/ui/message/messages_service.dart b/lib/services/ui/message/messages_service.dart index 83ec39d670..cc35a49951 100644 --- a/lib/services/ui/message/messages_service.dart +++ b/lib/services/ui/message/messages_service.dart @@ -19,7 +19,7 @@ String? lastReloadedChat() => Get.isRegistered(tag: 'lastReloadedChat') class MessagesService extends GetxController { static final Map cachedBubbleSizes = {}; late Chat chat; - late StreamSubscription countSub; + StreamSubscription? countSub; final ChatMessages struct = ChatMessages(); late Function(Message) newFunc; late Function(Message, {String? oldGuid}) updateFunc; @@ -85,7 +85,7 @@ class MessagesService extends GetxController { @override void onClose() { if (_init) { - countSub.cancel(); + countSub?.cancel(); } _init = false; super.onClose(); From 16953133a77358bd75272c5d11ab0c1e133fabcc Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:57:19 -0700 Subject: [PATCH 2/3] Make conversation keyboard dismissal reliable --- .../pages/conversation_view.dart | 27 ++++++++++++++----- .../ui/chat/conversation_view_controller.dart | 11 +++++++- .../conversation_view_controller_test.dart | 26 ++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 test/services/conversation_view_controller_test.dart diff --git a/lib/app/layouts/conversation_view/pages/conversation_view.dart b/lib/app/layouts/conversation_view/pages/conversation_view.dart index bbe813269d..b93454f003 100644 --- a/lib/app/layouts/conversation_view/pages/conversation_view.dart +++ b/lib/app/layouts/conversation_view/pages/conversation_view.dart @@ -93,6 +93,12 @@ class ConversationViewState extends OptimizedState { canPop: false, onPopInvoked: (didPop) async { if (didPop) return; + if (controller.keyboardOpen || + controller.focusNode.hasFocus || + controller.subjectFocusNode.hasFocus) { + controller.dismissKeyboard(); + return; + } if (controller.inSelectMode.value) { controller.inSelectMode.value = false; controller.selected.clear(); @@ -155,10 +161,20 @@ class ConversationViewState extends OptimizedState { Expanded( child: Stack( children: [ - MessagesView( - key: Key(chat.guid), - customService: widget.customService, - controller: controller, + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) { + if (controller.keyboardOpen || + controller.focusNode.hasFocus || + controller.subjectFocusNode.hasFocus) { + controller.dismissKeyboard(); + } + }, + child: MessagesView( + key: Key(chat.guid), + customService: widget.customService, + controller: controller, + ), ), Align( alignment: iOS ? Alignment.bottomRight : Alignment.bottomCenter, @@ -218,8 +234,7 @@ class ConversationViewState extends OptimizedState { if (ss.settings.swipeToCloseKeyboard.value && details.delta.dy > 0 && controller.keyboardOpen) { - controller.focusNode.unfocus(); - controller.subjectFocusNode.unfocus(); + controller.dismissKeyboard(); } else if (ss.settings.swipeToOpenKeyboard.value && details.delta.dy < 0 && !controller.keyboardOpen) { diff --git a/lib/services/ui/chat/conversation_view_controller.dart b/lib/services/ui/chat/conversation_view_controller.dart index a26709b9cc..654dfb6dcb 100644 --- a/lib/services/ui/chat/conversation_view_controller.dart +++ b/lib/services/ui/chat/conversation_view_controller.dart @@ -11,6 +11,7 @@ import 'package:bluebubbles/services/services.dart'; import 'package:emojis/emoji.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:get/get.dart'; import 'package:google_ml_kit/google_ml_kit.dart' hide Message; @@ -99,6 +100,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker bool keyboardOpen = false; double _keyboardOffset = 0; Timer? _scrollDownDebounce; + StreamSubscription? keyboardVisibilitySubscription; Future Function(Tuple7, AttributedBody, String, String?, int?, String?, PayloadData?>, bool, DateTime?)? sendFunc; bool isProcessingImage = false; @@ -158,7 +160,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker updateContactInfo(); textController.mentionables = mentionables; - KeyboardVisibilityController().onChange.listen((bool visible) async { + keyboardVisibilitySubscription = KeyboardVisibilityController().onChange.listen((bool visible) async { keyboardOpen = visible; if (scrollController.hasClients) { _keyboardOffset = scrollController.offset; @@ -229,9 +231,16 @@ class ConversationViewController extends StatefulController with GetSingleTicker scrollController.dispose(); headerBackFocusNode.dispose(); shareSubscription?.cancel(); + keyboardVisibilitySubscription?.cancel(); super.onClose(); } + void dismissKeyboard() { + focusNode.unfocus(); + subjectFocusNode.unfocus(); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + Future scrollToBottom() async { if (scrollController.positions.isNotEmpty && scrollController.positions.first.extentBefore > 0) { await scrollController.animateTo( diff --git a/test/services/conversation_view_controller_test.dart b/test/services/conversation_view_controller_test.dart new file mode 100644 index 0000000000..f278b29979 --- /dev/null +++ b/test/services/conversation_view_controller_test.dart @@ -0,0 +1,26 @@ +import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/services/ui/chat/conversation_view_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('dismissKeyboard releases the conversation composer focus', (tester) async { + final controller = ConversationViewController(Chat(guid: 'iMessage;-;keyboard-test')); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField(focusNode: controller.focusNode), + ), + ), + ); + controller.focusNode.requestFocus(); + await tester.pump(); + expect(controller.focusNode.hasFocus, isTrue); + + controller.dismissKeyboard(); + await tester.pump(); + + expect(controller.focusNode.hasFocus, isFalse); + }); +} From dd66b56847dfe528724371e7b77707cd822ac91d Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:06:42 -0700 Subject: [PATCH 3/3] Load complete reply threads with originators --- .../message/reply/reply_thread_popup.dart | 95 ++++++++++++------- lib/database/global/chat_messages.dart | 24 ++++- lib/database/html/message.dart | 3 +- lib/database/io/message.dart | 3 +- lib/services/ui/message/messages_service.dart | 6 +- test/helpers/chat_messages_test.dart | 70 ++++++++++++++ 6 files changed, 159 insertions(+), 42 deletions(-) create mode 100644 test/helpers/chat_messages_test.dart diff --git a/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart b/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart index f8f5ca31fc..08e872df58 100644 --- a/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart +++ b/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart @@ -5,7 +5,6 @@ import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; -import 'package:collection/collection.dart'; import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -13,9 +12,36 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:flutter_acrylic/flutter_acrylic.dart'; -void showReplyThread(BuildContext context, Message message, MessagePart part, MessagesService service, ConversationViewController cvController) { +Future showReplyThread(BuildContext context, Message message, MessagePart part, MessagesService service, ConversationViewController cvController) async { + cvController.dismissKeyboard(); final originatorPart = message.threadOriginatorGuid != null ? message.normalizedThreadPart : part.part; - final _messages = service.struct.threads(message.threadOriginatorGuid ?? message.guid!, originatorPart); + final originatorGuid = message.threadOriginatorGuid ?? message.guid!; + final messagesByGuid = {}; + + if (!kIsWeb) { + final originator = Message.findOne(guid: originatorGuid); + final query = Database.messages.query(Message_.threadOriginatorGuid.equals(originatorGuid)).build(); + final storedReplies = query.find(); + query.close(); + + for (final stored in [ + if (originator != null) originator, + ...storedReplies, + ]) { + if (stored.guid == null || stored.associatedMessageGuid != null) continue; + if (stored.guid != originatorGuid && stored.normalizedThreadPart != originatorPart) continue; + stored.fetchAttachments(); + stored.fetchAssociatedMessages(service: service); + stored.handle = stored.getHandle(); + messagesByGuid[stored.guid!] = stored; + } + } + + // Prefer live in-memory messages when both sources contain the same item. + for (final live in service.struct.threads(originatorGuid, originatorPart)) { + if (live.guid != null) messagesByGuid[live.guid!] = live; + } + final _messages = messagesByGuid.values.toList(); _messages.sort((a, b) => Message.sort(a, b, descending: false)); _buildThreadView(_messages, originatorPart, cvController, context); } @@ -92,39 +118,40 @@ void _buildThreadView(List _messages, int? originatorPart, Conversation ), Container( child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Center( - child: SingleChildScrollView( - controller: controller, - child: Column( - children: _messages.mapIndexed((index, e) => GestureDetector( - onTap: () { - Navigator.of(context).pop(); - if (originatorPart == null && ss.settings.skin.value == Skins.iOS) { - // pop twice to remove convo details page - Navigator.of(context).pop(); - } - ms(cvController.chat.guid).jumpToMessage.call(e.guid!); - }, - child: AbsorbPointer( - absorbing: true, - child: Padding( - padding: const EdgeInsets.only(left: 5.0, right: 5.0), - child: MessageHolder( - cvController: cvController, - message: _messages[index], - oldMessageGuid: index > 0 ? _messages[index - 1].guid : null, - newMessageGuid: index < _messages.length - 1 ? _messages[index + 1].guid : null, - isReplyThread: true, - replyPart: index == 0 ? originatorPart : null, - ), - ), + child: ListView.builder( + controller: controller, + padding: const EdgeInsets.symmetric(vertical: 16), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: _messages.length, + itemBuilder: (context, index) { + final threadMessage = _messages[index]; + final messageController = getActiveMwc(threadMessage.guid!) ?? mwc(threadMessage); + messageController.cvController = cvController; + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + if (originatorPart == null && ss.settings.skin.value == Skins.iOS) { + // pop twice to remove convo details page + Navigator.of(context).pop(); + } + ms(cvController.chat.guid).jumpToMessage.call(threadMessage.guid!); + }, + child: AbsorbPointer( + absorbing: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: MessageHolder( + cvController: cvController, + message: threadMessage, + oldMessageGuid: index > 0 ? _messages[index - 1].guid : null, + newMessageGuid: index < _messages.length - 1 ? _messages[index + 1].guid : null, + isReplyThread: true, + replyPart: index == 0 ? originatorPart : null, ), - )).toList(), + ), ), - ), - ), + ); + }, ), ), ), diff --git a/lib/database/global/chat_messages.dart b/lib/database/global/chat_messages.dart index 131468a6a0..b4d5663471 100644 --- a/lib/database/global/chat_messages.dart +++ b/lib/database/global/chat_messages.dart @@ -12,9 +12,18 @@ class ChatMessages { List get messages => _messages.values.toList(); List get reactions => _reactions.values.toList(); List get attachments => _attachments.values.toList(); - List threads(String originatorGuid, int originatorPart, {bool returnOriginator = true}) => - _threads[originatorGuid]?.values.where((e) => - (e.normalizedThreadPart == originatorPart && e.guid != originatorGuid) || (returnOriginator ? e.guid == originatorGuid : false)).toList() ?? []; + List threads(String originatorGuid, int originatorPart, {bool returnOriginator = true}) { + final thread = _threads[originatorGuid]; + if (returnOriginator && thread?[originatorGuid] == null) { + final originator = _messages[originatorGuid]; + if (originator != null) { + addThreadOriginator(originator); + } + } + return _threads[originatorGuid]?.values.where((e) => + (e.normalizedThreadPart == originatorPart && e.guid != originatorGuid) || + (returnOriginator && e.guid == originatorGuid)).toList() ?? []; + } void addMessages(List __messages) { for (Message m in __messages) { @@ -27,8 +36,13 @@ class ChatMessages { } if (m.threadOriginatorGuid != null && !m.guid!.startsWith("temp") && m.associatedMessageGuid == null) { // add threaded messages - _threads[m.threadOriginatorGuid!] ??= {}; - _threads[m.threadOriginatorGuid]![m.guid!] = m; + final originatorGuid = m.threadOriginatorGuid!; + _threads[originatorGuid] ??= {}; + _threads[originatorGuid]![m.guid!] = m; + final loadedOriginator = _messages[originatorGuid]; + if (loadedOriginator != null) { + _threads[originatorGuid]![originatorGuid] = loadedOriginator; + } } if (_threads.keys.contains(m.guid)) { // add thread 'originator' diff --git a/lib/database/html/message.dart b/lib/database/html/message.dart index d0e2319c42..ee61d3f3bd 100644 --- a/lib/database/html/message.dart +++ b/lib/database/html/message.dart @@ -432,7 +432,8 @@ class Message { return isFromMe != newerMessage.isFromMe; } - int get normalizedThreadPart => threadOriginatorPart == null ? 0 : int.parse(threadOriginatorPart![0]); + int get normalizedThreadPart => + int.tryParse(threadOriginatorPart?.split(':').first ?? '') ?? 0; bool connectToUpper() => threadOriginatorGuid != null; diff --git a/lib/database/io/message.dart b/lib/database/io/message.dart index 4440c2ef6a..9df6da8659 100644 --- a/lib/database/io/message.dart +++ b/lib/database/io/message.dart @@ -1453,7 +1453,8 @@ class Message { return "$part:${run.range[0]}:${run.range[1]}"; } - int get normalizedThreadPart => threadOriginatorPart == null ? 0 : int.parse(threadOriginatorPart![0]); + int get normalizedThreadPart => + int.tryParse(threadOriginatorPart?.split(':').first ?? '') ?? 0; bool connectToUpper() => threadOriginatorGuid != null; diff --git a/lib/services/ui/message/messages_service.dart b/lib/services/ui/message/messages_service.dart index cc35a49951..2e89a5df8e 100644 --- a/lib/services/ui/message/messages_service.dart +++ b/lib/services/ui/message/messages_service.dart @@ -182,7 +182,11 @@ class MessagesService extends GetxController { for (Message m in _messages.where((e) => e.threadOriginatorGuid != null)) { // see if the originator is already loaded final guid = m.threadOriginatorGuid!; - if (struct.getMessage(guid) != null) continue; + final loadedOriginator = struct.getMessage(guid); + if (loadedOriginator != null) { + struct.addThreadOriginator(loadedOriginator); + continue; + } // if not, fetch local and add to data final threadOriginator = Message.findOne(guid: guid); if (threadOriginator != null) { diff --git a/test/helpers/chat_messages_test.dart b/test/helpers/chat_messages_test.dart new file mode 100644 index 0000000000..0b72a67432 --- /dev/null +++ b/test/helpers/chat_messages_test.dart @@ -0,0 +1,70 @@ +import 'package:bluebubbles/database/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('includes the originator when it is loaded before its replies', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-1', text: 'originator'); + final reply = Message( + guid: 'reply-1', + text: 'reply', + threadOriginatorGuid: 'thread-1', + threadOriginatorPart: '0', + ); + + messages.addMessages([originator, reply]); + + expect( + messages.threads('thread-1', 0).map((message) => message.guid), + containsAll(['thread-1', 'reply-1']), + ); + }); + + test('includes the originator when it is loaded after its replies', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-2', text: 'originator'); + final reply = Message( + guid: 'reply-2', + text: 'reply', + threadOriginatorGuid: 'thread-2', + threadOriginatorPart: '0', + ); + + messages.addMessages([reply, originator]); + + expect( + messages.threads('thread-2', 0).map((message) => message.guid), + containsAll(['thread-2', 'reply-2']), + ); + }); + + test('returns every message in a lengthy reply chain', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-long', text: 'originator'); + final replies = List.generate( + 100, + (index) => Message( + guid: 'reply-long-$index', + text: 'reply $index', + threadOriginatorGuid: 'thread-long', + threadOriginatorPart: '0', + ), + ); + + messages.addMessages([originator, ...replies]); + + final thread = messages.threads('thread-long', 0); + expect(thread, hasLength(101)); + expect(thread.map((message) => message.guid).toSet(), hasLength(101)); + }); + + test('keeps multi-digit reply part indexes intact', () { + final reply = Message( + guid: 'reply-part-12', + threadOriginatorGuid: 'thread-multipart', + threadOriginatorPart: '12:4:8', + ); + + expect(reply.normalizedThreadPart, 12); + }); +}