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: [