From e71fbe23df5c776be87d243a548c5ab7c9c1c346 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 1 Nov 2026 07:30:05 -0800 Subject: [PATCH] fix(mobile): keep loaded timeline visible while reconnecting (#8864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning to an already-open channel after a few minutes in the background could replace its messages with the loading skeleton. The relay session drops its socket five seconds into the background, so nearly every return to the app reconnects. When that reconnect took more than two seconds, the channel page covered the loaded timeline with the connection skeleton until the relay came back. The messages provider already keeps the cached timeline through a reconnect, so the overlay was the only thing hiding it. Now a loaded timeline stays visible while the relay reconnects. The skeleton still shows when nothing has loaded yet, and forums keep their existing behavior (skeleton only before any posts load). To keep a reconnect indicator, the iOS title capsule now shows a thin bar in the chat color (the Appearance accent) along its bottom edge while a page with loaded content is not current. For a channel timeline that covers both the reconnect and the catch-up after it (resubscribing and fetching the newest window). The wait has no known length, so the bar sweeps in, pulses until the channel is current, then fades. Reduce Motion shows a static bar. Forums show it only while reconnecting, because their posts provider doesn't refetch after a reconnect. The native navigation view owns the bar, so it survives title rebuilds. Android has no title capsule and is unchanged. ### Demo iPhone 18 Pro simulator on a local relay: background the app for more than 5s with the relay down, return, then let the relay come back. About 8s of relay startup is cut from the middle. https://github.com/user-attachments/assets/e88d0191-766b-4054-bc00-99ed306cd0cc ### Before merge - [x] Kenny Lopez reviews the visible changes ([approved in Buzz #buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)). ### Validation - Updated tests: the reconnect cases in `channel_detail_page_test.dart` and `loading_review_tests.dart` now assert that loaded messages and cached media stay visible and in place during a reconnect. - Added test: the iOS navigation payload sets `titleRefreshing` while a loaded timeline reconnects and clears it on connect. - Added test: `isCatchingUp` holds from reconnect until the newest window merges, and is already false when that state publishes. - Added test: a failed catch-up after reconnect still notifies listeners once, with `isCatchingUp` false, so the bar clears. - `flutter analyze` clean, iOS simulator build succeeds, and the full mobile suite passes on the final head. - Checked visually on an iOS 27 simulator (demo above): loaded messages stay visible on resume, and the bar pulses from foreground until the reconnect's catch-up finishes, then fades. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- mobile/ios/Runner/IosNavigationBar.swift | 97 +++++++++++++++++++ .../channels/channel_detail_page.dart | 16 ++- .../channels/channel_messages_provider.dart | 12 +++ .../lib/shared/widgets/frosted_app_bar.dart | 5 + .../shared/widgets/ios_navigation_bar.dart | 7 ++ .../channels/channel_detail_page_test.dart | 77 +++++++++++++-- .../loading_review_tests.dart | 28 +++--- .../channel_messages_provider_test.dart | 88 +++++++++++++++++ 8 files changed, 301 insertions(+), 29 deletions(-) diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index a425a1cae3..303c65d6e0 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -22,6 +22,87 @@ final class IosNavigationBarFactory: NSObject, FlutterPlatformViewFactory { } } +/// A thin bar along the bottom inner edge of the title capsule while the page +/// reconnects or catches up. Its duration is unknown, so it sweeps in and then +/// pulses until the refresh ends, then fades. The navigation view owns it so +/// it survives title rebuilds. +final class NavigationRefreshBar: UIView { + private static let pulseKey = "refresh-pulse" + private let fill = UIView() + private(set) var isRefreshing = false + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + isAccessibilityElement = false + accessibilityIdentifier = "channel-navigation-refresh" + fill.layer.anchorPoint = CGPoint(x: 0, y: 0.5) + addSubview(fill) + alpha = 0 + } + + required init?(coder: NSCoder) { return nil } + + var color: UIColor? { + get { fill.backgroundColor } + set { fill.backgroundColor = newValue } + } + + override func layoutSubviews() { + super.layoutSubviews() + let transform = fill.transform + fill.transform = .identity + fill.frame = bounds + fill.transform = transform + } + + // Moving between title views drops layer animations; restart the pulse. + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil, isRefreshing { startPulse() } + } + + func setRefreshing(_ refreshing: Bool) { + guard refreshing != isRefreshing else { return } + isRefreshing = refreshing + let reduceMotion = UIAccessibility.isReduceMotionEnabled + layer.removeAllAnimations() + fill.layer.removeAllAnimations() + if refreshing { + alpha = 1 + guard !reduceMotion else { + fill.transform = .identity + return + } + fill.transform = CGAffineTransform(scaleX: 0.001, y: 1) + UIView.animate(withDuration: 0.35, delay: 0, options: [.curveEaseOut]) { + self.fill.transform = .identity + } + startPulse() + } else { + fill.transform = .identity + UIView.animate(withDuration: reduceMotion ? 0 : 0.25, delay: 0, options: [.beginFromCurrentState]) { + self.alpha = 0 + } + } + } + + private func startPulse() { + guard !UIAccessibility.isReduceMotionEnabled, + fill.layer.animation(forKey: Self.pulseKey) == nil else { return } + let pulse = CABasicAnimation(keyPath: "opacity") + pulse.fromValue = 1 + pulse.toValue = 0.3 + pulse.duration = 0.75 + pulse.autoreverses = true + pulse.repeatCount = .infinity + pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + pulse.beginTime = CACurrentMediaTime() + 0.35 + pulse.fillMode = .backwards + fill.layer.add(pulse, forKey: Self.pulseKey) + } +} + final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate { var onActivate: (() -> Void)? private let titleLabel = UILabel() @@ -137,6 +218,18 @@ final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate accessibilityLabel = [accessibilityLabel, label].compactMap { $0 }.joined(separator: ", ") } + func attachRefreshBar(_ bar: NavigationRefreshBar) { + bar.removeFromSuperview() + bar.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(bar) + NSLayoutConstraint.activate([ + bar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + bar.heightAnchor.constraint(equalToConstant: 2), + ]) + } + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { acceptsTitleTouch(in: touch.view) } @@ -256,6 +349,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { private weak var trackedAvatar: UIButton? private var reportedAvatarFrame: CGRect? private var flutterSafeTop: CGFloat = 0 + private let refreshBar = NavigationRefreshBar() init(frame: CGRect, id: Int64, args: Any?, messenger: FlutterBinaryMessenger, parent: UIViewController?) { container = NavigationClipView(frame: frame) @@ -449,8 +543,11 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { if let label = args["ephemeralLabel"] as? String { button.setEphemeralStatus(label) } + refreshBar.color = args["titleRefreshColor"] is NSNumber ? Self.color(args["titleRefreshColor"]) : color + button.attachRefreshBar(refreshBar) button.frame.size = button.intrinsicContentSize item.titleView = button + refreshBar.setRefreshing(args["titleRefreshing"] as? Bool == true) } else if largeTitle { compactTitle.text = item.title compactTitle.font = .preferredFont(forTextStyle: .headline) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 27f6b040f7..ba0745e39d 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -546,10 +546,13 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { final isConnectionInProgress = sessionStatus == SessionStatus.connecting || sessionStatus == SessionStatus.reconnecting; + // A loaded timeline stays on screen through a reconnect, as a loaded + // forum does. The session drops its socket five seconds into the + // background, so nearly every return to the app reconnects. Only a forum + // with no posts yet waits out this debounce for its skeleton. final showConnectionSkeleton = useState(false); final shouldDebounceConnectionSkeleton = - isConnectionInProgress && - (resolvedChannel.isForum || messagesNotifier.hasLoadedMessages); + isConnectionInProgress && resolvedChannel.isForum; useEffect(() { if (!shouldDebounceConnectionSkeleton) { showConnectionSkeleton.value = false; @@ -682,7 +685,6 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { !resolvedChannel.isForum && timelineEntries.isNotEmpty && !showInitialConnectionSkeleton && - !showConnectionSkeleton.value && !messagesState.isLoading ? Stack( children: [ @@ -732,6 +734,13 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { 'offline' => context.colors.outline, _ => null, }, + // Loaded content stays readable while it reconnects and catches up; + // the title capsule shows that it is not current yet. + nativeTitleRefreshing: resolvedChannel.isForum + ? isConnectionInProgress && + ref.watch(forumPostsProvider(channel.id)).value != null + : (isConnectionInProgress && messagesNotifier.hasLoadedMessages) || + messagesNotifier.isCatchingUp, onNativeTitlePressed: openChannelDetails, nativeActions: [ if (personalChat) @@ -801,7 +810,6 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { key: ValueKey('message-loading:${channel.id}'), loading: showInitialConnectionSkeleton || - showConnectionSkeleton.value || messagesState.isLoading, shimmerEnabled: sessionStatus != SessionStatus.disconnected, diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index a7cc6521bc..12f0c033ac 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -112,6 +112,10 @@ class ChannelMessagesNotifier extends Notifier>> { /// empty value returned while the relay is not yet connected. bool get hasLoadedMessages => _lastKnownMessages != null; + /// Whether a loaded timeline is resubscribing and fetching its newest + /// history, as after a reconnect, to become current again. + bool get isCatchingUp => _initInFlight && hasLoadedMessages; + Map get threadSummaries => { for (final entry in _baseThreadSummaries.entries) entry.key: _deletionSummaryUncertainty.containsKey(entry.key) @@ -258,6 +262,9 @@ class ChannelMessagesNotifier extends Notifier>> { _lastKnownMessages = merged; _reconcileRetainedSummaryPayloads(merged); _reconcileRetainedAggregates(historyVersion); + // Clear before publishing so the rebuild it triggers is no longer + // catching up. + _initInFlight = false; state = AsyncData(merged); _summaryRefreshes.resume(); unawaited(_fillNotices()); @@ -268,9 +275,14 @@ class ChannelMessagesNotifier extends Notifier>> { debugPrint( '[ChannelMessagesNotifier] history sync failed for $channelId: $e', ); + _initInFlight = false; state = AsyncData(fallbackMessages); + // The fallback is usually the list already on screen, which Riverpod + // treats as unchanged. Notify anyway so the page sees catch-up end. + ref.notifyListeners(); return; } + _initInFlight = false; state = AsyncError(e, st); } finally { if (_isCurrentInit(initVersion)) { diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 3b7ba126de..a410f6e238 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -173,6 +173,9 @@ class FrostedAppBar extends StatelessWidget { /// Reactive counterpart avatar and presence badge for a native DM title. final IosNavigationAction? nativeTitleAvatar; final Color? nativeTitlePresenceColor; + + /// Shows a refresh bar in the native iOS title capsule. + final bool nativeTitleRefreshing; final VoidCallback? onNativeTitlePressed; /// Whether UIKit should expand the title at the top of the page. @@ -203,6 +206,7 @@ class FrostedAppBar extends StatelessWidget { this.nativeEphemeralLabel, this.nativeTitleAvatar, this.nativeTitlePresenceColor, + this.nativeTitleRefreshing = false, this.onNativeTitlePressed, this.nativeLargeTitle = false, this.useNativeNavigation = true, @@ -290,6 +294,7 @@ class FrostedAppBar extends StatelessWidget { ephemeralLabel: nativeEphemeralLabel, titleAvatar: nativeTitleAvatar, titlePresenceColor: nativeTitlePresenceColor, + titleRefreshing: nativeTitleRefreshing, onTitlePressed: onNativeTitlePressed, largeTitle: nativeLargeTitle, alwaysFrosted: alwaysFrosted, diff --git a/mobile/lib/shared/widgets/ios_navigation_bar.dart b/mobile/lib/shared/widgets/ios_navigation_bar.dart index 22092a1ade..ef438468e9 100644 --- a/mobile/lib/shared/widgets/ios_navigation_bar.dart +++ b/mobile/lib/shared/widgets/ios_navigation_bar.dart @@ -9,6 +9,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; +import '../theme/mobile_appearance.dart'; import '../theme/theme_extensions.dart'; import '../animated_avatar.dart'; import 'avatar_image.dart'; @@ -128,6 +129,7 @@ class IosNavigationBar extends HookConsumerWidget { this.ephemeralLabel, this.titleAvatar, this.titlePresenceColor, + this.titleRefreshing = false, this.onTitlePressed, this.largeTitle = false, this.alwaysFrosted = false, @@ -145,6 +147,9 @@ class IosNavigationBar extends HookConsumerWidget { final String? ephemeralLabel; final IosNavigationAction? titleAvatar; final Color? titlePresenceColor; + + /// Shows a refresh bar along the bottom of the subtitled title capsule. + final bool titleRefreshing; final VoidCallback? onTitlePressed; final bool largeTitle; @@ -309,6 +314,8 @@ class IosNavigationBar extends HookConsumerWidget { ? null : encodeAction(titleAvatar!, 'titleAvatar'), 'titlePresenceColor': titlePresenceColor?.toARGB32(), + 'titleRefreshing': titleRefreshing, + 'titleRefreshColor': ChatColors.of(context).outgoing.toARGB32(), 'titleEnabled': onTitlePressed != null, 'largeTitle': largeTitle, 'alwaysFrosted': alwaysFrosted, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7a014c7fbe..49f11430f0 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2146,7 +2146,7 @@ void main() { }, ); - testWidgets('uses known media shapes in the reconnect shimmer', ( + testWidgets('keeps the loaded timeline visible during reconnect', ( tester, ) async { final relaySession = _ReconnectingRelaySession(); @@ -2175,21 +2175,21 @@ void main() { await tester.pump(const Duration(seconds: 3)); expect( tester.widget(find.byType(SkeletonReveal)).loading, - isTrue, + isFalse, ); expect( tester .widget(find.byKey(const Key('skeleton-reveal-content'))) .opacity, - 0, + 1, ); expect( - find.byKey( - const ValueKey( - 'message-skeleton-image:https://example.com/loading.jpg', - ), - ), - findsOneWidget, + tester + .widget( + find.byKey(const Key('skeleton-reveal-placeholder')), + ) + .opacity, + 0, ); relaySession.connect(); await tester.pumpAndSettle(); @@ -2238,6 +2238,65 @@ void main() { ); }); + testWidgets( + 'shows the title refresh bar while a loaded timeline reconnects', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final relaySession = _ReconnectingRelaySession( + initialStatus: SessionStatus.connected, + ); + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'msg1', + pubkey: 'alice', + content: 'Existing message', + ), + ], + relaySessionNotifier: relaySession, + ), + ); + await tester.pumpAndSettle(); + final navigationBar = find.byWidgetPredicate( + (w) => w is UiKitView && w.viewType == 'buzz/ios_navigation_bar', + ); + final view = tester.widget(navigationBar); + Map payload = view.creationParams! as Map; + expect(payload['titleRefreshing'], isFalse); + const channel = MethodChannel('buzz/ios_navigation_bar/198'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (call.method == 'configure') payload = call.arguments as Map; + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + view.onPlatformViewCreated!(198); + await tester.pump(); + + relaySession.setReconnecting(); + await tester.pump(); + expect(payload['titleRefreshing'], isTrue); + expect( + payload['titleRefreshColor'], + ChatColors.of(tester.element(navigationBar)).outgoing.toARGB32(), + ); + + relaySession.connect(); + await tester.pumpAndSettle(); + expect(payload['titleRefreshing'], isFalse); + debugDefaultTargetPlatformOverride = null; + }, + ); + testWidgets('keeps loaded forum content visible during reconnect', ( tester, ) async { diff --git a/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart b/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart index 3b054824b8..1c71be9ee6 100644 --- a/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart @@ -466,9 +466,7 @@ void _loadingReviewTests() { } }); - testWidgets('reconnect snapshot puts newest cached media at the bottom', ( - tester, - ) async { + testWidgets('reconnect keeps cached media in place', (tester) async { final relay = _ReconnectingRelaySession( initialStatus: SessionStatus.connected, ); @@ -505,26 +503,24 @@ void _loadingReviewTests() { tester.getCenter(olderPreview).dy, lessThan(tester.getCenter(newerPreview).dy), ); + final newerCenter = tester.getCenter(newerPreview); relay.setReconnecting(); await tester.pump(); await tester.pump(const Duration(seconds: 3)); - final olderShape = find.byKey( - const ValueKey('message-skeleton-image:https://example.com/0.jpg'), - ); - final newerShape = find.byKey( - const ValueKey('message-skeleton-image:https://example.com/1.jpg'), + expect( + tester.widget(find.byType(SkeletonReveal)).loading, + isFalse, ); expect( - tester.getCenter(olderShape).dy, - lessThan(tester.getCenter(newerShape).dy), + tester + .widget(find.byKey(const Key('skeleton-reveal-placeholder'))) + .opacity, + 0, ); - final list = find - .ancestor(of: newerShape, matching: find.byType(ListView)) - .first; - expect(tester.widget(list).reverse, isTrue); + expect(tester.getCenter(newerPreview), newerCenter); expect( - tester.getCenter(newerShape).dy, - greaterThan(tester.getSize(find.byType(Scaffold).first).height / 2), + tester.getCenter(olderPreview).dy, + lessThan(tester.getCenter(newerPreview).dy), ); relay.connect(); await tester.pumpAndSettle(); diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 2a97f3f6ff..bf7ffbbd5d 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -2146,6 +2146,94 @@ void main() { }, ); + test('a loaded timeline catches up after reconnecting', () async { + final reconnectPage = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'first', createdAt: 10), _bounds()], + reconnectPage.future, + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + expect(notifier.isCatchingUp, isFalse); + + session.setConnected(false); + await _pumpEventQueue(); + expect(notifier.isCatchingUp, isFalse); + session.setConnected(true); + await _pumpEventQueue(); + expect(notifier.isCatchingUp, isTrue); + + bool? catchingUpWhenPublished; + container.listen(channelMessagesProvider(_channelId), (_, _) { + catchingUpWhenPublished = notifier.isCatchingUp; + }); + reconnectPage.complete([ + _event(id: 'missed', createdAt: 20), + _event(id: 'first', createdAt: 10), + _bounds(), + ]); + await _pumpEventQueue(); + expect(notifier.isCatchingUp, isFalse); + expect(catchingUpWhenPublished, isFalse); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .map((event) => event.id), + contains('missed'), + ); + }); + + test( + 'a failed catch-up after reconnecting tells listeners it ended', + () async { + final reconnectPage = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'first', createdAt: 10), _bounds()], + reconnectPage.future, + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + expect(notifier.isCatchingUp, isTrue); + + final catchingUpWhenNotified = []; + container.listen(channelMessagesProvider(_channelId), (_, _) { + catchingUpWhenNotified.add(notifier.isCatchingUp); + }); + reconnectPage.completeError( + RelayException(503, '{"error":"query timed out"}'), + ); + await _pumpEventQueue(); + expect(notifier.isCatchingUp, isFalse); + expect(catchingUpWhenNotified, [false]); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .map((event) => event.id), + ['first'], + ); + }, + ); + test('reconnect evicts reply evidence outside the newest window', () async { final session = _RecordingRelaySessionNotifier( queryResults: [