From e9cddc7379fea11f00e8feebf6cde39244a7d29b Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:53:19 -0700 Subject: [PATCH 1/7] Clean up stale UI listeners --- .../avatars/contact_avatar_widget.dart | 12 +++++++++- .../widgets/tile/conversation_tile.dart | 22 +++++++++++++++---- .../tile/pinned_conversation_tile.dart | 10 ++++++++- .../pages/messages_view.dart | 11 +++++++--- .../effects/screen_effects_widget.dart | 12 ++++++++-- .../message/attachment/attachment_holder.dart | 9 +++++++- .../interactive/interactive_holder.dart | 10 ++++++++- .../widgets/message/message_holder.dart | 11 +++++++++- .../widgets/message/misc/bubble_effects.dart | 11 +++++++++- .../widgets/message/text/text_bubble.dart | 16 ++++++++++++-- .../timestamp/delivered_indicator.dart | 12 +++++++++- lib/app/wrappers/stateful_boilerplate.dart | 9 +++++++- lib/app/wrappers/tablet_mode_wrapper.dart | 17 +++++++++++--- 13 files changed, 140 insertions(+), 22 deletions(-) diff --git a/lib/app/components/avatars/contact_avatar_widget.dart b/lib/app/components/avatars/contact_avatar_widget.dart index 25e7910556..63a2278933 100644 --- a/lib/app/components/avatars/contact_avatar_widget.dart +++ b/lib/app/components/avatars/contact_avatar_widget.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/database/models.dart'; @@ -37,11 +39,13 @@ class ContactAvatarWidget extends StatefulWidget { class _ContactAvatarWidgetState extends OptimizedState { Contact? get contact => widget.contact ?? widget.handle?.contact; String get keyPrefix => widget.handle?.address ?? randomString(8); + late final StreamSubscription _avatarRefreshSubscription; @override void initState() { super.initState(); - eventDispatcher.stream.listen((event) { + _avatarRefreshSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 != 'refresh-avatar') return; if (event.item2[0] != widget.handle?.address) return; widget.handle?.color = event.item2[1]; @@ -49,6 +53,12 @@ class _ContactAvatarWidgetState extends OptimizedState { }); } + @override + void dispose() { + _avatarRefreshSubscription.cancel(); + super.dispose(); + } + void onAvatarTap() async { if (!ss.settings.colorfulAvatars.value && !ss.settings.colorfulBubbles.value) return; diff --git a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart index de10e12257..e92f0ca0ce 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart @@ -220,6 +220,7 @@ class ConversationTile extends CustomStateful { class _ConversationTileState extends CustomState with AutomaticKeepAliveClientMixin { ConversationListController get listController => controller.listController; + late final StreamSubscription _highlightSubscription; @override bool get wantKeepAlive => true; @@ -237,7 +238,8 @@ class _ConversationTileState extends CustomState { class _ChatTitleState extends CustomState { String title = "Unknown"; StreamSubscription? sub; + StreamSubscription? eventSub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -356,7 +365,8 @@ class _ChatTitleState extends CustomState class _PinnedConversationTileState extends CustomState { ConversationListController get listController => controller.listController; Offset? longPressPosition; + late final StreamSubscription _highlightSubscription; @override void initState() { @@ -53,7 +54,8 @@ class _PinnedConversationTileState extends CustomState { final RxBool latestMessageDeliveredState = false.obs; final RxBool jumpingToOldestUnread = false.obs; final Map messageFocusNodes = {}; + late final StreamSubscription _eventSubscription; ConversationViewController get controller => widget.controller; @@ -135,7 +136,8 @@ class MessagesViewState extends OptimizedState { void initState() { super.initState(); - eventDispatcher.stream.listen((e) async { + _eventSubscription = eventDispatcher.stream.listen((e) async { + if (!mounted) return; if (e.item1 == "refresh-messagebloc" && e.item2 == chat.guid) { // Clear state items noMoreMessages = false; @@ -200,9 +202,12 @@ class MessagesViewState extends OptimizedState { @override void dispose() { + _eventSubscription.cancel(); if (!kIsWeb && !kIsDesktop) smartReply.close(); - chat.lastReadMessageGuid = _messages.first.guid; - chat.save(updateLastReadMessageGuid: true); + if (_messages.isNotEmpty) { + chat.lastReadMessageGuid = _messages.first.guid; + chat.save(updateLastReadMessageGuid: true); + } messageService.close(force: widget.customService != null); if (controller.bottomMessageFocusNode != null && messageFocusNodes.containsValue(controller.bottomMessageFocusNode)) { controller.bottomMessageFocusNode = null; diff --git a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart index 7924e089d2..4354b7e017 100644 --- a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart +++ b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/app/animations/balloon_classes.dart'; @@ -36,6 +37,7 @@ class _ScreenEffectsWidgetState extends OptimizedState with late final SpotlightController spotlightController; late final LaserController laserController; String screenSelected = ""; + late final StreamSubscription _effectSubscription; @override void initState() { @@ -51,7 +53,7 @@ class _ScreenEffectsWidgetState extends OptimizedState with laserController = LaserController(vsync: this, windowSize: Size(ns.width(context), context.height)); }); - eventDispatcher.stream.listen((event) async { + _effectSubscription = eventDispatcher.stream.listen((event) async { if (event.item1 == 'play-effect' && mounted && screenSelected.isEmpty) { setState(() { screenSelected = event.item2['type']; @@ -126,6 +128,12 @@ class _ScreenEffectsWidgetState extends OptimizedState with }); } + @override + void dispose() { + _effectSubscription.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { @@ -157,4 +165,4 @@ class _ScreenEffectsWidgetState extends OptimizedState with ), ); } -} \ No newline at end of file +} diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart index 3c89499897..2f4d62ae2c 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart @@ -44,12 +44,14 @@ class _AttachmentHolderState extends CustomState getAudioTranscriptsFromAttributedBody(message.attributedBody)[part.part]; late dynamic content; late bool selected = controller.cvController?.isSelected(message.guid!) ?? false; + Worker? _selectionWorker; @override void initState() { forceDelete = false; if (controller.cvController != null && !iOS) { - ever>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -65,6 +67,11 @@ class _AttachmentHolderState extends CustomState>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -86,6 +88,12 @@ class _InteractiveHolderState extends CustomState true; 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..1e15e9bba7 100644 --- a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:bluebubbles/app/components/custom/custom_bouncing_scroll_physics.dart'; @@ -84,6 +85,7 @@ class _MessageHolderState extends CustomState keys = []; bool gaveHapticFeedback = false; final RxBool tapped = false.obs; + late final StreamSubscription _avatarRefreshSubscription; @override void initState() { @@ -105,7 +107,8 @@ class _MessageHolderState extends CustomState GlobalKey()); } - eventDispatcher.stream.listen((event) { + _avatarRefreshSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 != 'refresh-avatar') return; if (event.item2[0] != message.handle?.address) return; message.handle?.color = event.item2[1]; @@ -113,6 +116,12 @@ class _MessageHolderState extends CustomState { late MovieTween tween; Control controller = Control.stop; Size size = Size.zero; + late final StreamSubscription _effectSubscription; @override void initState() { getTween(); - eventDispatcher.stream.listen((event) async { + _effectSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == 'play-bubble-effect' && event.item2 == '${widget.part}/${widget.message.guid}') { size = widget.globalKey?.currentContext?.size ?? Size.zero; setState(() { @@ -58,6 +61,12 @@ class _BubbleEffectsState extends OptimizedState { super.initState(); } + @override + void dispose() { + _effectSubscription.cancel(); + super.dispose(); + } + void getTween() { if (effect == MessageEffect.gentle) { tween = MovieTween() diff --git a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart index 08d497377b..a42e901bac 100644 --- a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart +++ b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; @@ -36,6 +37,8 @@ class _TextBubbleState extends CustomState>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -76,6 +81,13 @@ class _TextBubbleState extends CustomState getBubbleColors() { if (selected && !iOS) return [context.theme.colorScheme.tertiaryContainer, context.theme.colorScheme.tertiaryContainer]; List bubbleColors = [context.theme.colorScheme.properSurface, context.theme.colorScheme.properSurface]; diff --git a/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart b/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart index 1d85e62366..0f5021b724 100644 --- a/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart +++ b/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; @@ -22,19 +24,27 @@ class DeliveredIndicator extends CustomStateful { class _DeliveredIndicatorState extends CustomState { Message get message => controller.message; bool get showAvatar => (controller.cvController?.chat ?? cm.activeChat!.chat).isGroup; + late final StreamSubscription _messageUpdateSubscription; @override void initState() { forceDelete = false; super.initState(); - eventDispatcher.stream.listen((event) { + _messageUpdateSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == "message-updated-${message.guid}") { setState(() {}); } }); } + @override + void dispose() { + _messageUpdateSubscription.cancel(); + super.dispose(); + } + bool get shouldShow { if (controller.audioWasKept.value != null) return true; if (widget.forceShow || message.guid!.contains("temp")) return true; diff --git a/lib/app/wrappers/stateful_boilerplate.dart b/lib/app/wrappers/stateful_boilerplate.dart index f7285a80f3..cfff176107 100644 --- a/lib/app/wrappers/stateful_boilerplate.dart +++ b/lib/app/wrappers/stateful_boilerplate.dart @@ -28,6 +28,7 @@ abstract class CustomStateful extends StatefulWidg abstract class CustomState extends State with ThemeHelpers { // completer to check if the page animation is complete final animCompleted = Completer(); + late final void Function(R) _updateWidgetCallback; @protected /// Convenience getter for the [GetxController] @@ -55,7 +56,8 @@ abstract class CustomState(tag: _tag); super.dispose(); } diff --git a/lib/app/wrappers/tablet_mode_wrapper.dart b/lib/app/wrappers/tablet_mode_wrapper.dart index ba6caed091..bfc88cbe24 100644 --- a/lib/app/wrappers/tablet_mode_wrapper.dart +++ b/lib/app/wrappers/tablet_mode_wrapper.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/helpers/ui/theme_helpers.dart'; @@ -43,6 +44,8 @@ class _TabletModeWrapperState extends OptimizedState { late final RxDouble _ratio; double? _maxWidth; bool? altLayoutCache; + late final StreamSubscription _eventSubscription; + late final Worker _ratioWorker; get _width1 => max(min(_ratio * _maxWidth!, widget.maxWidthLeft ?? double.infinity), widget.minWidthLeft ?? double.negativeInfinity); @@ -52,7 +55,8 @@ class _TabletModeWrapperState extends OptimizedState { void initState() { super.initState(); _ratio = RxDouble((ss.prefs.getDouble('splitRatio') ?? widget.initialRatio).clamp(widget.minRatio, widget.maxRatio)); - eventDispatcher.stream.listen((event) { + _eventSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == 'split-refresh') { _ratio.value = ss.prefs.getDouble('splitRatio') ?? _ratio.value; setState(() {}); @@ -61,12 +65,19 @@ class _TabletModeWrapperState extends OptimizedState { setState(() {}); } }); - debounce(_ratio, (val) async { + _ratioWorker = debounce(_ratio, (val) async { await ss.prefs.setDouble('splitRatio', val); eventDispatcher.emit('split-refresh', null); }); } + @override + void dispose() { + _eventSubscription.cancel(); + _ratioWorker.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { if (!showAltLayout) { @@ -137,4 +148,4 @@ class _TabletModeWrapperState extends OptimizedState { }, ); } -} \ No newline at end of file +} From fb72106d9033f1acae4586fde8e188f803b1a0e5 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:12:26 -0700 Subject: [PATCH 2/7] Fix UI lifecycle and relay retry edge cases --- .../avatars/contact_avatar_widget.dart | 4 +- .../widgets/conversation_list_fab.dart | 47 +++++++++++------- .../pages/messages_view.dart | 48 +++++++++++-------- .../settings/pages/theming/theming_panel.dart | 4 +- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/lib/app/components/avatars/contact_avatar_widget.dart b/lib/app/components/avatars/contact_avatar_widget.dart index 63a2278933..3a70a84992 100644 --- a/lib/app/components/avatars/contact_avatar_widget.dart +++ b/lib/app/components/avatars/contact_avatar_widget.dart @@ -38,12 +38,14 @@ class ContactAvatarWidget extends StatefulWidget { class _ContactAvatarWidgetState extends OptimizedState { Contact? get contact => widget.contact ?? widget.handle?.contact; - String get keyPrefix => widget.handle?.address ?? randomString(8); + late final String _keyPrefix; + String get keyPrefix => _keyPrefix; late final StreamSubscription _avatarRefreshSubscription; @override void initState() { super.initState(); + _keyPrefix = widget.handle?.address ?? randomString(8); _avatarRefreshSubscription = eventDispatcher.stream.listen((event) { if (!mounted) return; if (event.item1 != 'refresh-avatar') return; diff --git a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart index 54a78d9114..ff3449eb09 100644 --- a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart +++ b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/app/layouts/conversation_list/pages/conversation_list.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/app/wrappers/theme_switcher.dart'; @@ -18,6 +20,8 @@ class ConversationListFAB extends CustomStateful { } class _ConversationListFABState extends CustomState { + late final StreamSubscription _avatarOnlySubscription; + void _focusBackToList() { if (!FocusScope.of(context).focusInDirection(TraversalDirection.left)) { FocusScope.of(context).previousFocus(); @@ -31,27 +35,29 @@ class _ConversationListFABState extends CustomState controller.openNewChatCreator(context), }; + void _handleMaterialScroll() { + if (!mounted || !material) return; + if (controller.materialScrollStartPosition - controller.materialScrollController.offset < -75 + && controller.materialScrollController.position.userScrollDirection == ScrollDirection.reverse + && controller.showMaterialFABText) { + setState(() { + controller.showMaterialFABText = false; + }); + } else if (controller.materialScrollStartPosition - controller.materialScrollController.offset > 75 + && controller.materialScrollController.position.userScrollDirection == ScrollDirection.forward + && !controller.showMaterialFABText) { + setState(() { + controller.showMaterialFABText = true; + }); + } + } + @override void initState() { super.initState(); - controller.materialScrollController.addListener(() { - if (!material) return; - if (controller.materialScrollStartPosition - controller.materialScrollController.offset < -75 - && controller.materialScrollController.position.userScrollDirection == ScrollDirection.reverse - && controller.showMaterialFABText) { - setState(() { - controller.showMaterialFABText = false; - }); - } else if (controller.materialScrollStartPosition - controller.materialScrollController.offset > 75 - && controller.materialScrollController.position.userScrollDirection == ScrollDirection.forward - && !controller.showMaterialFABText) { - setState(() { - controller.showMaterialFABText = true; - }); - } - }); - ns.listener.stream.listen((event) { + controller.materialScrollController.addListener(_handleMaterialScroll); + _avatarOnlySubscription = ns.listener.stream.listen((event) { if (!mounted) return; if (ns.isAvatarOnly(context) && controller.showMaterialFABText) { setState(() { @@ -61,6 +67,13 @@ class _ConversationListFABState extends CustomState Column( diff --git a/lib/app/layouts/conversation_view/pages/messages_view.dart b/lib/app/layouts/conversation_view/pages/messages_view.dart index 437b9e6b22..b3a8ec4e5b 100644 --- a/lib/app/layouts/conversation_view/pages/messages_view.dart +++ b/lib/app/layouts/conversation_view/pages/messages_view.dart @@ -141,6 +141,7 @@ class MessagesViewState extends OptimizedState { if (e.item1 == "refresh-messagebloc" && e.item2 == chat.guid) { // Clear state items noMoreMessages = false; + fetching = false; _messages = []; // Reload the state after refreshing messageService.reload(); @@ -293,28 +294,35 @@ class MessagesViewState extends OptimizedState { if (noMoreMessages || fetching) return; fetching = true; - // Start loading the next chunk of messages - noMoreMessages = !(await messageService.loadChunk(_messages.length, controller, limit: limit).catchError((e, stack) { - Logger.error("Failed to fetch message chunk!", error: e, trace: stack); - return true; - })); - - if (noMoreMessages) return setState(() {}); + try { + // Start loading the next chunk of messages + noMoreMessages = !(await messageService.loadChunk(_messages.length, controller, limit: limit).catchError((e, stack) { + Logger.error("Failed to fetch message chunk!", error: e, trace: stack); + return true; + })); - final oldLength = _messages.length; - _messages = messageService.struct.messages; - _messages.sort(Message.sort); - fetching = false; - _messages.sublist(max(oldLength - 1, 0)).forEachIndexed((i, m) { if (!mounted) return; - final c = mwc(m); - c.cvController = controller; - listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0)); - }); - _syncBottomMessageFocusNode(); - // should only happen when a reaction is the most recent message - if (oldLength == 0) { - setState(() {}); + + if (noMoreMessages) { + setState(() {}); + return; + } + + final oldLength = _messages.length; + _messages = messageService.struct.messages; + _messages.sort(Message.sort); + _messages.sublist(max(oldLength - 1, 0)).forEachIndexed((i, m) { + final c = mwc(m); + c.cvController = controller; + listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0)); + }); + _syncBottomMessageFocusNode(); + // should only happen when a reaction is the most recent message + if (oldLength == 0) { + setState(() {}); + } + } finally { + fetching = false; } } diff --git a/lib/app/layouts/settings/pages/theming/theming_panel.dart b/lib/app/layouts/settings/pages/theming/theming_panel.dart index 3ed3d92d0a..0926739cf6 100644 --- a/lib/app/layouts/settings/pages/theming/theming_panel.dart +++ b/lib/app/layouts/settings/pages/theming/theming_panel.dart @@ -463,7 +463,7 @@ class _ThemingPanelState extends CustomState 2) { + if (controller.refreshRates.length > 1) { return SettingsHeader( iosSubtitle: iosSubtitle, materialSubtitle: materialSubtitle, @@ -474,7 +474,7 @@ class _ThemingPanelState extends CustomState 2) { + if (controller.refreshRates.length > 1) { return SettingsSection( backgroundColor: tileColor, children: [ From bdff2133a0c311dc247b440ef5c5328598801202 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:26:44 -0700 Subject: [PATCH 3/7] Avoid eager full-resolution image decoding --- .../layouts/conversation_view/pages/conversation_view.dart | 2 ++ lib/services/ui/chat/conversation_view_controller.dart | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/app/layouts/conversation_view/pages/conversation_view.dart b/lib/app/layouts/conversation_view/pages/conversation_view.dart index bbe813269d..2d5ec90f7f 100644 --- a/lib/app/layouts/conversation_view/pages/conversation_view.dart +++ b/lib/app/layouts/conversation_view/pages/conversation_view.dart @@ -49,6 +49,8 @@ class ConversationViewState extends OptimizedState { cm.activeChat!.controller = controller; Logger.debug("Conversation View initialized for ${chat.guid}"); + controller.updatePoster(); + if (widget.onInit != null) { Future.delayed(Duration.zero, widget.onInit!); } diff --git a/lib/services/ui/chat/conversation_view_controller.dart b/lib/services/ui/chat/conversation_view_controller.dart index a26709b9cc..295f44b145 100644 --- a/lib/services/ui/chat/conversation_view_controller.dart +++ b/lib/services/ui/chat/conversation_view_controller.dart @@ -199,7 +199,6 @@ class ConversationViewController extends StatefulController with GetSingleTicker _subjectWasLastFocused = true; } }); - updatePoster(); } void updatePoster() async { @@ -301,9 +300,6 @@ class ConversationViewController extends StatefulController with GetSingleTicker return; } imageData[attachment.guid!] = tmpData; - try { - await precacheImage(MemoryImage(tmpData), queued.item3); - } catch (_) {} queued.item4.complete(tmpData); await _processNextImage(); From a201e979d330338a8463524eb20e6c9ab88012d7 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:48:15 -0700 Subject: [PATCH 4/7] Refresh group avatars without background controller churn --- .../avatars/contact_avatar_group_widget.dart | 16 +++++++++++++++- lib/database/io/chat.dart | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/app/components/avatars/contact_avatar_group_widget.dart b/lib/app/components/avatars/contact_avatar_group_widget.dart index 1bae06b89f..957f9c713a 100644 --- a/lib/app/components/avatars/contact_avatar_group_widget.dart +++ b/lib/app/components/avatars/contact_avatar_group_widget.dart @@ -31,7 +31,7 @@ class ContactAvatarGroupWidget extends StatefulWidget { } class _ContactAvatarGroupWidgetState extends OptimizedState { - late final List participants = widget.chat?.participants ?? widget.participants ?? []; + late List participants; final Map materialGeneration = { 2: [24.5/40, 10.5/40, [Alignment.topRight, Alignment.bottomLeft]], 3: [21.5/40, 9/40, [Alignment.bottomRight, Alignment.bottomLeft, Alignment.topCenter]], @@ -41,6 +41,20 @@ class _ContactAvatarGroupWidgetState extends OptimizedState.from(widget.chat?.participants ?? widget.participants ?? []); participants.sort((a, b) { bool avatarA = a.contact?.avatar?.isNotEmpty ?? false; bool avatarB = b.contact?.avatar?.isNotEmpty ?? false; diff --git a/lib/database/io/chat.dart b/lib/database/io/chat.dart index db4c8a7df5..82ee7ed437 100644 --- a/lib/database/io/chat.dart +++ b/lib/database/io/chat.dart @@ -390,7 +390,11 @@ class Chat { RxDouble sendProgress = 0.0.obs; void handlesChanged() { - var cachedChat = cvc(this).chat; + // Group updates can arrive while the app is backgrounded. Do not create a + // full conversation controller just to mirror a relation that has no + // visible UI; that leaks controller resources and can wake rendering work. + if (!Get.isRegistered(tag: guid)) return; + var cachedChat = Get.find(tag: guid).chat; cachedChat.handles = handles; // someone can't keep their objects in sync... cachedChat._participants = []; } From a4b0581bbceb7673cb836ada575601d33cfc54a6 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:10:17 -0700 Subject: [PATCH 5/7] Harden partial widget initialization and profile loading --- .../avatars/contact_avatar_widget.dart | 4 ++-- .../widgets/conversation_list_fab.dart | 4 ++-- .../widgets/tile/conversation_tile.dart | 4 ++-- .../tile/pinned_conversation_tile.dart | 4 ++-- .../pages/messages_view.dart | 4 ++-- .../effects/screen_effects_widget.dart | 4 ++-- .../widgets/message/message_holder.dart | 4 ++-- .../widgets/message/misc/bubble_effects.dart | 4 ++-- .../widgets/message/text/text_bubble.dart | 4 ++-- .../timestamp/delivered_indicator.dart | 4 ++-- lib/app/wrappers/tablet_mode_wrapper.dart | 8 +++---- lib/services/rustpush/rustpush_service.dart | 22 ++++++++++++++++++- 12 files changed, 45 insertions(+), 25 deletions(-) diff --git a/lib/app/components/avatars/contact_avatar_widget.dart b/lib/app/components/avatars/contact_avatar_widget.dart index 3a70a84992..4a5bb12a7a 100644 --- a/lib/app/components/avatars/contact_avatar_widget.dart +++ b/lib/app/components/avatars/contact_avatar_widget.dart @@ -40,7 +40,7 @@ class _ContactAvatarWidgetState extends OptimizedState { Contact? get contact => widget.contact ?? widget.handle?.contact; late final String _keyPrefix; String get keyPrefix => _keyPrefix; - late final StreamSubscription _avatarRefreshSubscription; + StreamSubscription? _avatarRefreshSubscription; @override void initState() { @@ -57,7 +57,7 @@ class _ContactAvatarWidgetState extends OptimizedState { @override void dispose() { - _avatarRefreshSubscription.cancel(); + _avatarRefreshSubscription?.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart index ff3449eb09..9762f788fd 100644 --- a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart +++ b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart @@ -20,7 +20,7 @@ class ConversationListFAB extends CustomStateful { } class _ConversationListFABState extends CustomState { - late final StreamSubscription _avatarOnlySubscription; + StreamSubscription? _avatarOnlySubscription; void _focusBackToList() { if (!FocusScope.of(context).focusInDirection(TraversalDirection.left)) { @@ -70,7 +70,7 @@ class _ConversationListFABState extends CustomState { class _ConversationTileState extends CustomState with AutomaticKeepAliveClientMixin { ConversationListController get listController => controller.listController; - late final StreamSubscription _highlightSubscription; + StreamSubscription? _highlightSubscription; @override bool get wantKeepAlive => true; @@ -252,7 +252,7 @@ class _ConversationTileState extends CustomState class _PinnedConversationTileState extends CustomState { ConversationListController get listController => controller.listController; Offset? longPressPosition; - late final StreamSubscription _highlightSubscription; + StreamSubscription? _highlightSubscription; @override void initState() { @@ -68,7 +68,7 @@ class _PinnedConversationTileState extends CustomState { final RxBool latestMessageDeliveredState = false.obs; final RxBool jumpingToOldestUnread = false.obs; final Map messageFocusNodes = {}; - late final StreamSubscription _eventSubscription; + StreamSubscription? _eventSubscription; ConversationViewController get controller => widget.controller; @@ -203,7 +203,7 @@ class MessagesViewState extends OptimizedState { @override void dispose() { - _eventSubscription.cancel(); + _eventSubscription?.cancel(); if (!kIsWeb && !kIsDesktop) smartReply.close(); if (_messages.isNotEmpty) { chat.lastReadMessageGuid = _messages.first.guid; diff --git a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart index 4354b7e017..f283180966 100644 --- a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart +++ b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart @@ -37,7 +37,7 @@ class _ScreenEffectsWidgetState extends OptimizedState with late final SpotlightController spotlightController; late final LaserController laserController; String screenSelected = ""; - late final StreamSubscription _effectSubscription; + StreamSubscription? _effectSubscription; @override void initState() { @@ -130,7 +130,7 @@ class _ScreenEffectsWidgetState extends OptimizedState with @override void dispose() { - _effectSubscription.cancel(); + _effectSubscription?.cancel(); super.dispose(); } 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 1e15e9bba7..033d3cb0f8 100644 --- a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart @@ -85,7 +85,7 @@ class _MessageHolderState extends CustomState keys = []; bool gaveHapticFeedback = false; final RxBool tapped = false.obs; - late final StreamSubscription _avatarRefreshSubscription; + StreamSubscription? _avatarRefreshSubscription; @override void initState() { @@ -118,7 +118,7 @@ class _MessageHolderState extends CustomState { late MovieTween tween; Control controller = Control.stop; Size size = Size.zero; - late final StreamSubscription _effectSubscription; + StreamSubscription? _effectSubscription; @override void initState() { @@ -63,7 +63,7 @@ class _BubbleEffectsState extends OptimizedState { @override void dispose() { - _effectSubscription.cancel(); + _effectSubscription?.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart index a42e901bac..0e305e8aee 100644 --- a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart +++ b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart @@ -37,7 +37,7 @@ class _TextBubbleState extends CustomState { class _DeliveredIndicatorState extends CustomState { Message get message => controller.message; bool get showAvatar => (controller.cvController?.chat ?? cm.activeChat!.chat).isGroup; - late final StreamSubscription _messageUpdateSubscription; + StreamSubscription? _messageUpdateSubscription; @override void initState() { @@ -41,7 +41,7 @@ class _DeliveredIndicatorState extends CustomState { late final RxDouble _ratio; double? _maxWidth; bool? altLayoutCache; - late final StreamSubscription _eventSubscription; - late final Worker _ratioWorker; + StreamSubscription? _eventSubscription; + Worker? _ratioWorker; get _width1 => max(min(_ratio * _maxWidth!, widget.maxWidthLeft ?? double.infinity), widget.minWidthLeft ?? double.negativeInfinity); @@ -73,8 +73,8 @@ class _TabletModeWrapperState extends OptimizedState { @override void dispose() { - _eventSubscription.cancel(); - _ratioWorker.dispose(); + _eventSubscription?.cancel(); + _ratioWorker?.dispose(); super.dispose(); } diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 6deb3c1e42..2c854c3468 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -2828,7 +2828,27 @@ class RustPushService extends GetxService { } List profilesDownloading = []; - Future handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { + final Map _profileRetryAfter = {}; + + Future handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { + final profileKey = shared.cloudKitRecordKey; + final retryAfter = _profileRetryAfter[profileKey]; + if (retryAfter != null && retryAfter.isAfter(DateTime.now())) return; + + try { + await _handleSharedProfile(shared, sender, targets); + _profileRetryAfter.remove(profileKey); + } catch (error) { + // Shared profile payloads are optional message metadata. A malformed + // CloudKit plist must not escape an unawaited profile task and disturb + // message delivery or repeatedly consume CPU while the same payload is + // replayed. + _profileRetryAfter[profileKey] = DateTime.now().add(const Duration(minutes: 10)); + Logger.warn("Skipping shared profile payload after ${error.runtimeType}"); + } + } + + Future _handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { var myHandles = await api.getHandles(state: pushService.state!.client); if (myHandles.contains(sender)) { for (var target in targets) { From 350306939dd758757b8d0f95b542d1d7673c83a1 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:27:39 -0700 Subject: [PATCH 6/7] Close remaining UI lifecycle races --- lib/app/animations/balloon_classes.dart | 13 +-- lib/app/animations/celebration_class.dart | 11 ++- lib/app/animations/fireworks_classes.dart | 13 +-- lib/app/animations/laser_classes.dart | 13 +-- lib/app/animations/love_classes.dart | 13 +-- lib/app/animations/spotlight_classes.dart | 13 +-- .../tile/cupertino_conversation_tile.dart | 4 +- .../tile/material_conversation_tile.dart | 4 +- .../tile/pinned_conversation_tile.dart | 4 +- .../widgets/tile/pinned_tile_text_bubble.dart | 4 +- .../tile/samsung_conversation_tile.dart | 4 +- .../effects/screen_effects_widget.dart | 89 ++++++++++++------- .../widgets/header/cupertino_header.dart | 4 +- .../widgets/header/material_header.dart | 4 +- lib/services/rustpush/rustpush_service.dart | 13 ++- 15 files changed, 132 insertions(+), 74 deletions(-) diff --git a/lib/app/animations/balloon_classes.dart b/lib/app/animations/balloon_classes.dart index f7e220dc3e..38bf95a2ee 100644 --- a/lib/app/animations/balloon_classes.dart +++ b/lib/app/animations/balloon_classes.dart @@ -14,7 +14,7 @@ class BalloonController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; bool isPlaying = false; bool requestedToStop = false; @@ -28,6 +28,7 @@ class BalloonController implements Listenable { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -53,7 +54,8 @@ class BalloonController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -82,8 +84,9 @@ class BalloonController implements Listenable { return element.position.y < -100 || element.position.x < -100; }); if (balloons.isEmpty && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; stopFunc?.call(); @@ -132,4 +135,4 @@ const List primaries = [ Colors.lightGreen, Colors.orange, Colors.yellow, -]; \ No newline at end of file +]; diff --git a/lib/app/animations/celebration_class.dart b/lib/app/animations/celebration_class.dart index 82875f76e6..dd3dddc7cb 100644 --- a/lib/app/animations/celebration_class.dart +++ b/lib/app/animations/celebration_class.dart @@ -14,6 +14,7 @@ class CelebrationController extends FireworkController { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -41,7 +42,8 @@ class CelebrationController extends FireworkController { @override void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } @override @@ -63,8 +65,9 @@ class CelebrationController extends FireworkController { particles.removeWhere((element) => element.alpha <= 0); if (particles.isEmpty && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; hasCreatedParticles = false; @@ -94,4 +97,4 @@ class CelebrationController extends FireworkController { )); } } -} \ No newline at end of file +} diff --git a/lib/app/animations/fireworks_classes.dart b/lib/app/animations/fireworks_classes.dart index 268d805d6c..baa7bae345 100644 --- a/lib/app/animations/fireworks_classes.dart +++ b/lib/app/animations/fireworks_classes.dart @@ -23,7 +23,7 @@ class FireworkController implements Listenable { Size windowSize; double globalHue = 42; - late Ticker ticker; + Ticker? ticker; bool hasCreatedParticles = false; bool isPlaying = false; @@ -41,6 +41,7 @@ class FireworkController implements Listenable { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -66,7 +67,8 @@ class FireworkController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -113,8 +115,9 @@ class FireworkController implements Listenable { }); particles.removeWhere((element) => element.alpha <= 0); if (particles.isEmpty && requestedToStop && hasCreatedParticles) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; hasCreatedParticles = false; @@ -283,4 +286,4 @@ class FireworkRocket extends FireworkObjectWithTrail { position += vp; } } -} \ No newline at end of file +} diff --git a/lib/app/animations/laser_classes.dart b/lib/app/animations/laser_classes.dart index 3644b718ac..a0d12b04c5 100644 --- a/lib/app/animations/laser_classes.dart +++ b/lib/app/animations/laser_classes.dart @@ -16,7 +16,7 @@ class LaserController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; late double size; double globalHue = 42; @@ -34,6 +34,7 @@ class LaserController implements Listenable { autoLaunchDuration = const Duration(milliseconds: 500); lastAutoLaunch = Duration.zero; position = Point((bubbleDimensions.left + bubbleDimensions.right) / 2, (bubbleDimensions.top + bubbleDimensions.bottom) / 2); + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -58,7 +59,8 @@ class LaserController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -103,8 +105,9 @@ class LaserController implements Listenable { } if (elapsedDuration.inSeconds > 5 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; laser = null; @@ -199,4 +202,4 @@ class LaserBeam { } } -enum Direction {up, down} \ No newline at end of file +enum Direction {up, down} diff --git a/lib/app/animations/love_classes.dart b/lib/app/animations/love_classes.dart index 9619ee3c69..792c8cd8a6 100644 --- a/lib/app/animations/love_classes.dart +++ b/lib/app/animations/love_classes.dart @@ -14,7 +14,7 @@ class LoveController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; bool isPlaying = false; @@ -30,6 +30,7 @@ class LoveController implements Listenable { autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; position = startPos; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -55,7 +56,8 @@ class LoveController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -75,8 +77,9 @@ class LoveController implements Listenable { heart!.update(); if (heart!.position.y < -200 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; heart = null; @@ -128,4 +131,4 @@ class LoveObject { velocity *= acceleration; velocity.clamp(0.5, 2); } -} \ No newline at end of file +} diff --git a/lib/app/animations/spotlight_classes.dart b/lib/app/animations/spotlight_classes.dart index 557a04049f..dd7c7fa53d 100644 --- a/lib/app/animations/spotlight_classes.dart +++ b/lib/app/animations/spotlight_classes.dart @@ -14,7 +14,7 @@ class SpotlightController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; late double size; @@ -32,6 +32,7 @@ class SpotlightController implements Listenable { lastAutoLaunch = Duration.zero; position = Point((bubbleDimensions.left + bubbleDimensions.right) / 2, (bubbleDimensions.top + bubbleDimensions.bottom) / 2); size = max(bubbleDimensions.width, bubbleDimensions.height) + 50; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -57,7 +58,8 @@ class SpotlightController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -76,8 +78,9 @@ class SpotlightController implements Listenable { spotlight!.update(elapsedDuration); if (spotlight!.stop < 0 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; spotlight = null; @@ -116,4 +119,4 @@ class SpotlightObject { stop = stop - 0.05; } } -} \ No newline at end of file +} diff --git a/lib/app/layouts/conversation_list/widgets/tile/cupertino_conversation_tile.dart b/lib/app/layouts/conversation_list/widgets/tile/cupertino_conversation_tile.dart index 59b0f27b1a..7317feac8f 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/cupertino_conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/cupertino_conversation_tile.dart @@ -182,7 +182,7 @@ class CupertinoTrailing extends CustomStateful { class _CupertinoTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -240,7 +240,7 @@ class _CupertinoTrailingState extends CustomState { class _MaterialTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -235,7 +235,7 @@ class _MaterialTrailingState extends CustomState { class _ChatTitleState extends CustomState { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -331,7 +331,7 @@ class _ChatTitleState extends CustomState { class _SamsungTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -205,7 +205,7 @@ class _SamsungTrailingState extends CustomState with late final LaserController laserController; String screenSelected = ""; StreamSubscription? _effectSubscription; + bool _controllersInitialized = false; + int _effectGeneration = 0; + + bool _isEffectActive(int generation) => mounted && generation == _effectGeneration; + + void _clearEffect(int generation) { + if (!_isEffectActive(generation)) return; + setState(() { + screenSelected = ""; + }); + } @override void initState() { super.initState(); - updateObx(() { - fireworkController = FireworkController(vsync: this, windowSize: Size(ns.width(context), context.height)); - celebrationController = CelebrationController(vsync: this, windowSize: Size(ns.width(context), context.height)); - confettiController = ConfettiController(duration: const Duration(seconds: 1)); - balloonController = BalloonController(vsync: this, windowSize: Size(ns.width(context), context.height)); - loveController = LoveController(vsync: this, windowSize: Size(ns.width(context), context.height)); - spotlightController = SpotlightController(vsync: this, windowSize: Size(ns.width(context), context.height)); - laserController = LaserController(vsync: this, windowSize: Size(ns.width(context), context.height)); - }); - _effectSubscription = eventDispatcher.stream.listen((event) async { - if (event.item1 == 'play-effect' && mounted && screenSelected.isEmpty) { + if (event.item1 == 'play-effect' && mounted && _controllersInitialized && screenSelected.isEmpty) { + final generation = ++_effectGeneration; setState(() { screenSelected = event.item2['type']; }); @@ -63,74 +65,101 @@ class _ScreenEffectsWidgetState extends OptimizedState with fireworkController.windowSize = Size(ns.width(context), context.height); fireworkController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; fireworkController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "celebration" && !celebrationController.isPlaying) { celebrationController.windowSize = Size(ns.width(context), context.height); celebrationController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; celebrationController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "balloons" && !balloonController.isPlaying) { balloonController.windowSize = Size(ns.width(context), context.height); balloonController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; balloonController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "love" && !loveController.isPlaying) { if (rect != null) { loveController.windowSize = Size(ns.width(context), context.height); loveController.start(Point((rect!.left + rect!.right) / 2, (rect!.top + rect!.bottom) / 2)); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; loveController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "spotlight" && !spotlightController.isPlaying) { if (rect != null) { spotlightController.windowSize = Size(ns.width(context), context.height); spotlightController.start(rect!); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; spotlightController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "lasers" && !laserController.isPlaying) { if (rect != null) { laserController.windowSize = Size(ns.width(context), context.height); laserController.start(rect!); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; laserController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "confetti") { confettiController.play(); await Future.delayed(const Duration(seconds: 1)); - screenSelected = ""; + _clearEffect(generation); + } else { + _clearEffect(generation); } } }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controllersInitialized) return; + final windowSize = Size(ns.width(context), context.height); + fireworkController = FireworkController(vsync: this, windowSize: windowSize); + celebrationController = CelebrationController(vsync: this, windowSize: windowSize); + confettiController = ConfettiController(duration: const Duration(seconds: 1)); + balloonController = BalloonController(vsync: this, windowSize: windowSize); + loveController = LoveController(vsync: this, windowSize: windowSize); + spotlightController = SpotlightController(vsync: this, windowSize: windowSize); + laserController = LaserController(vsync: this, windowSize: windowSize); + _controllersInitialized = true; + } + @override void dispose() { + _effectGeneration++; _effectSubscription?.cancel(); + if (_controllersInitialized) { + fireworkController.dispose(); + celebrationController.dispose(); + confettiController.dispose(); + balloonController.dispose(); + loveController.dispose(); + spotlightController.dispose(); + laserController.dispose(); + } super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart index 43e4c44e23..5b60474f8c 100644 --- a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart +++ b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart @@ -448,7 +448,7 @@ class _ChatIconAndTitle extends CustomStateful { class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, ConversationViewController> { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; late String cachedGuid; @@ -523,7 +523,7 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver @override void dispose() { - sub.cancel(); + sub?.cancel(); sub2.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/header/material_header.dart b/lib/app/layouts/conversation_view/widgets/header/material_header.dart index 3511cfafa7..6c315cc8fd 100644 --- a/lib/app/layouts/conversation_view/widgets/header/material_header.dart +++ b/lib/app/layouts/conversation_view/widgets/header/material_header.dart @@ -421,7 +421,7 @@ class _ChatIconAndTitle extends CustomStateful { class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, ConversationViewController> { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -491,7 +491,7 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver @override void dispose() { - sub.cancel(); + sub?.cancel(); sub2.cancel(); super.dispose(); } diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 2c854c3468..df92d93ba0 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -2829,11 +2829,21 @@ class RustPushService extends GetxService { List profilesDownloading = []; final Map _profileRetryAfter = {}; + static const int _maxProfileRetryEntries = 128; + + void _pruneProfileRetryAfter(DateTime now) { + _profileRetryAfter.removeWhere((_, expiry) => !expiry.isAfter(now)); + while (_profileRetryAfter.length >= _maxProfileRetryEntries) { + _profileRetryAfter.remove(_profileRetryAfter.keys.first); + } + } Future handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { final profileKey = shared.cloudKitRecordKey; + final now = DateTime.now(); + _pruneProfileRetryAfter(now); final retryAfter = _profileRetryAfter[profileKey]; - if (retryAfter != null && retryAfter.isAfter(DateTime.now())) return; + if (retryAfter != null && retryAfter.isAfter(now)) return; try { await _handleSharedProfile(shared, sender, targets); @@ -2843,6 +2853,7 @@ class RustPushService extends GetxService { // CloudKit plist must not escape an unawaited profile task and disturb // message delivery or repeatedly consume CPU while the same payload is // replayed. + _pruneProfileRetryAfter(DateTime.now()); _profileRetryAfter[profileKey] = DateTime.now().add(const Duration(minutes: 10)); Logger.warn("Skipping shared profile payload after ${error.runtimeType}"); } From 88f3815eff2b223f6ab62030177a129fafdfe858 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:54:02 -0700 Subject: [PATCH 7/7] Fix recycled message controller remounts --- lib/app/wrappers/stateful_boilerplate.dart | 6 ++-- test/wrappers/stateful_boilerplate_test.dart | 35 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 test/wrappers/stateful_boilerplate_test.dart diff --git a/lib/app/wrappers/stateful_boilerplate.dart b/lib/app/wrappers/stateful_boilerplate.dart index cfff176107..ec1868f2f5 100644 --- a/lib/app/wrappers/stateful_boilerplate.dart +++ b/lib/app/wrappers/stateful_boilerplate.dart @@ -8,7 +8,7 @@ import 'package:get/get.dart'; /// [GetxController] with support for optimized state management class StatefulController extends GetxController { final Map> updateWidgetFunctions = {}; - late final void Function(VoidCallback) updateObx; + late void Function(VoidCallback) updateObx; void updateWidgets(Object? arg) { updateWidgetFunctions[T]?.forEach((e) => e.call(arg)); @@ -50,8 +50,8 @@ abstract class CustomState { + const _TestWidget({required super.parentController}); + + @override + State<_TestWidget> createState() => _TestWidgetState(); +} + +class _TestWidgetState extends CustomState<_TestWidget, void, _TestController> { + @override + void initState() { + forceDelete = false; + super.initState(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + testWidgets('rebinds updates when a retained controller row is remounted', (tester) async { + final controller = _TestController(); + + await tester.pumpWidget(MaterialApp(home: _TestWidget(parentController: controller))); + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pumpWidget(MaterialApp(home: _TestWidget(parentController: controller))); + + expect(tester.takeException(), isNull); + }); +}