fix(mobile): keep loaded timeline visible while reconnecting (#8864)

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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-01 07:30:05 -08:00
committed by GitHub
parent 2e4aacc9e0
commit e71fbe23df
8 changed files with 301 additions and 29 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -112,6 +112,10 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
/// 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<String, ChannelWindowThreadSummary> get threadSummaries => {
for (final entry in _baseThreadSummaries.entries)
entry.key: _deletionSummaryUncertainty.containsKey(entry.key)
@@ -258,6 +262,9 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
_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<AsyncValue<List<NostrEvent>>> {
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)) {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
isTrue,
isFalse,
);
expect(
tester
.widget<Opacity>(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<Opacity>(
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<UiKitView>(navigationBar);
Map<Object?, Object?> 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 {

View File

@@ -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<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
isFalse,
);
expect(
tester.getCenter(olderShape).dy,
lessThan(tester.getCenter(newerShape).dy),
tester
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-placeholder')))
.opacity,
0,
);
final list = find
.ancestor(of: newerShape, matching: find.byType(ListView))
.first;
expect(tester.widget<ListView>(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();

View File

@@ -2146,6 +2146,94 @@ void main() {
},
);
test('a loaded timeline catches up after reconnecting', () async {
final reconnectPage = Completer<List<NostrEvent>>();
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<List<NostrEvent>>();
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 = <bool>[];
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: [