From d5d422d9eeebe6fadd21b60037bd98480200f6cc Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 14 Aug 2026 16:22:58 +0100 Subject: [PATCH 1/7] Polish mobile timeline navigation Signed-off-by: kenny lopez --- mobile/ios/Runner.xcodeproj/project.pbxproj | 8 + mobile/ios/Runner/AppDelegate.swift | 18 + .../ios/Runner/JumpToLatestGlassButton.swift | 96 ++++ mobile/ios/Runner/StickyDateGlassHeader.swift | 117 +++++ .../channels/channel_detail_page.dart | 5 +- .../channel_detail_page/message_list.dart | 415 +++++++++++++----- mobile/lib/features/channels/day_divider.dart | 81 ++-- .../channels/jump_to_latest_button.dart | 99 +++++ .../features/channels/sticky_date_header.dart | 204 +++++++++ .../channels/channel_detail_page_test.dart | 171 +++++++- .../features/channels/day_divider_test.dart | 44 ++ .../channels/jump_to_latest_button_test.dart | 28 ++ .../channels/sticky_date_header_test.dart | 56 +++ 13 files changed, 1197 insertions(+), 145 deletions(-) create mode 100644 mobile/ios/Runner/JumpToLatestGlassButton.swift create mode 100644 mobile/ios/Runner/StickyDateGlassHeader.swift create mode 100644 mobile/lib/features/channels/jump_to_latest_button.dart create mode 100644 mobile/lib/features/channels/sticky_date_header.dart create mode 100644 mobile/test/features/channels/day_divider_test.dart create mode 100644 mobile/test/features/channels/jump_to_latest_button_test.dart create mode 100644 mobile/test/features/channels/sticky_date_header_test.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 7ccf13bae4d..45cf085cb79 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; }; 4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; }; 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; }; + 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; }; + 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; }; 331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; }; 331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; }; 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; @@ -59,6 +61,8 @@ 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = ""; }; 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = ""; }; 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; }; + 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; }; + 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -182,6 +186,8 @@ 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */, 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */, + 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */, + 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -417,6 +423,8 @@ 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, 4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */, 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */, + 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */, + 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 6ab55c359c7..fa68e539dd0 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -88,6 +88,24 @@ import UserNotifications } } + if let jumpToLatestGlassRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzJumpToLatestGlassButton" + ) { + jumpToLatestGlassRegistrar.register( + JumpToLatestGlassButtonFactory(messenger: messenger), + withId: "buzz/jump_to_latest_glass" + ) + } + + if let stickyDateGlassRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzStickyDateGlassHeader" + ) { + stickyDateGlassRegistrar.register( + StickyDateGlassHeaderFactory(messenger: messenger), + withId: "buzz/sticky_date_glass" + ) + } + let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeAttachmentPopover" ) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift new file mode 100644 index 00000000000..790c154e751 --- /dev/null +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -0,0 +1,96 @@ +import Flutter +import UIKit + +final class JumpToLatestGlassButtonFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + JumpToLatestGlassButtonPlatformView( + frame: frame, + viewIdentifier: viewId, + messenger: messenger + ) + } +} + +private final class JumpToLatestGlassButton: UIButton { + private static let hitTargetExpansion: CGFloat = 4 + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds + .insetBy( + dx: -Self.hitTargetExpansion, + dy: -Self.hitTargetExpansion + ) + .contains(point) + } +} + +final class JumpToLatestGlassButtonPlatformView: NSObject, FlutterPlatformView { + private let containerView: UIView + private let channel: FlutterMethodChannel + private let button = JumpToLatestGlassButton(type: .system) + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + messenger: FlutterBinaryMessenger + ) { + containerView = UIView(frame: frame) + channel = FlutterMethodChannel( + name: "buzz/jump_to_latest_glass/\(viewId)", + binaryMessenger: messenger + ) + super.init() + + containerView.backgroundColor = .clear + containerView.isOpaque = false + + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + } else { + configuration = .gray() + configuration.baseBackgroundColor = UIColor.secondarySystemBackground + } + configuration.cornerStyle = .capsule + configuration.baseForegroundColor = .label + configuration.image = UIImage( + systemName: "arrow.down", + withConfiguration: UIImage.SymbolConfiguration( + pointSize: 16, + weight: .semibold + ) + ) + button.configuration = configuration + button.accessibilityLabel = "Jump to latest message" + button.translatesAutoresizingMaskIntoConstraints = false + button.addAction( + UIAction { [weak self] _ in + self?.channel.invokeMethod("pressed", arguments: nil) + }, + for: .touchUpInside + ) + + containerView.addSubview(button) + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + button.widthAnchor.constraint(equalToConstant: 40), + button.heightAnchor.constraint(equalToConstant: 40), + ]) + } + + func view() -> UIView { + containerView + } +} diff --git a/mobile/ios/Runner/StickyDateGlassHeader.swift b/mobile/ios/Runner/StickyDateGlassHeader.swift new file mode 100644 index 00000000000..f95ec0555da --- /dev/null +++ b/mobile/ios/Runner/StickyDateGlassHeader.swift @@ -0,0 +1,117 @@ +import Flutter +import UIKit + +private final class StickyDateGlassView: UIVisualEffectView { + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } +} + +final class StickyDateGlassHeaderFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + StickyDateGlassHeaderPlatformView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + messenger: messenger + ) + } +} + +final class StickyDateGlassHeaderPlatformView: NSObject, FlutterPlatformView { + private let glassView: StickyDateGlassView + private let channel: FlutterMethodChannel + private let dateLabel = UILabel() + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + messenger: FlutterBinaryMessenger + ) { + let arguments = args as? [String: Any] + let text = arguments?["label"] as? String ?? "" + channel = FlutterMethodChannel( + name: "buzz/sticky_date_glass/\(viewId)", + binaryMessenger: messenger + ) + + if #available(iOS 26.0, *) { + let glassEffect = UIGlassEffect(style: .regular) + glassEffect.isInteractive = false + glassView = StickyDateGlassView(effect: glassEffect) + } else { + glassView = StickyDateGlassView( + effect: UIBlurEffect(style: .systemMaterial) + ) + } + + super.init() + + glassView.frame = frame + glassView.isOpaque = false + glassView.isUserInteractionEnabled = false + glassView.clipsToBounds = true + glassView.layer.cornerCurve = .continuous + + dateLabel.translatesAutoresizingMaskIntoConstraints = false + dateLabel.text = text + dateLabel.textAlignment = .center + dateLabel.textColor = .secondaryLabel + dateLabel.font = UIFontMetrics(forTextStyle: .caption1).scaledFont( + for: UIFont.systemFont(ofSize: 14, weight: .medium) + ) + dateLabel.adjustsFontForContentSizeCategory = true + dateLabel.numberOfLines = 1 + dateLabel.lineBreakMode = .byTruncatingTail + dateLabel.isAccessibilityElement = false + + glassView.contentView.addSubview(dateLabel) + NSLayoutConstraint.activate([ + dateLabel.leadingAnchor.constraint( + equalTo: glassView.contentView.leadingAnchor, + constant: 12 + ), + dateLabel.trailingAnchor.constraint( + equalTo: glassView.contentView.trailingAnchor, + constant: -12 + ), + dateLabel.centerYAnchor.constraint( + equalTo: glassView.contentView.centerYAnchor + ), + ]) + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "setLabel", let text = call.arguments as? String else { + result(FlutterMethodNotImplemented) + return + } + self?.dateLabel.text = text + result(nil) + } + } + + func view() -> UIView { + glassView + } + + deinit { + channel.setMethodCallHandler(nil) + } +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 7cf3532ac4e..d3e2c2e6ad5 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'dart:math' show min; -import 'dart:ui'; +import 'dart:math' show max, min; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; @@ -44,6 +43,7 @@ import 'date_formatters.dart'; import 'day_divider.dart'; import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; +import 'jump_to_latest_button.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; @@ -56,6 +56,7 @@ import 'reaction_row.dart'; import 'send_message_provider.dart'; import '../profile/user_profile_sheet.dart'; import 'small_avatar.dart'; +import 'sticky_date_header.dart'; import 'thread_detail_page.dart'; import 'timeline_message.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index b05126f314a..a2bff4917b1 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -38,9 +38,16 @@ class _MessageList extends HookConsumerWidget { final displayEntries = groupMembershipTimelineEntries(entries); final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); + final stickyDateHeaderState = useValueNotifier( + StickyDateHeaderState.hidden, + ); + final stickyDayTimestamp = useValueNotifier(null); + final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height); final isLoadingOlder = useState(false); final isAtLatest = useState(true); + final isJumpToLatestVisible = useState(false); final hasUserScrolled = useState(false); + final distanceFromLatest = useRef(0.0); final followsLatest = useRef( initialMessageId == null && initialThreadRootId == null, ); @@ -58,6 +65,38 @@ class _MessageList extends HookConsumerWidget { final hasUnreadDeepLink = initialMessageId != null || initialThreadRootId != null; final notifier = ref.read(channelMessagesProvider(channelId).notifier); + final dayTimestampByReversedIndex = {}; + final dayStartByReversedIndex = {}; + final dayHeaderTimestampByReversedIndex = {}; + var currentDayTimestamp = + displayEntries.firstOrNull?.first.message.createdAt; + var currentDayStartIndex = displayEntries.isEmpty + ? -1 + : displayEntries.length - 1; + for ( + var chronologicalIndex = 0; + chronologicalIndex < displayEntries.length; + chronologicalIndex += 1 + ) { + final message = displayEntries[chronologicalIndex].first.message; + final previousMessage = chronologicalIndex > 0 + ? displayEntries[chronologicalIndex - 1].last.message + : null; + final startsDay = + previousMessage == null || + !isSameDay(previousMessage.createdAt, message.createdAt); + final reversedIndex = displayEntries.length - 1 - chronologicalIndex; + if (startsDay) { + currentDayTimestamp = message.createdAt; + currentDayStartIndex = reversedIndex; + dayHeaderTimestampByReversedIndex[reversedIndex] = message.createdAt; + } + final dayTimestamp = currentDayTimestamp; + if (dayTimestamp != null) { + dayTimestampByReversedIndex[reversedIndex] = dayTimestamp; + dayStartByReversedIndex[reversedIndex] = currentDayStartIndex; + } + } useEffect( () { @@ -156,12 +195,120 @@ class _MessageList extends HookConsumerWidget { } double latestAlignment() { - final viewportHeight = context.size?.height ?? 0; + final viewportHeight = timelineViewportHeight.value; return viewportHeight > 0 ? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble() : 0.0; } + void updateStickyDateHeader(Iterable rawPositions) { + void setStickyDateHeader( + StickyDateHeaderState state, { + int? activeDayTimestamp, + }) { + stickyDateHeaderState.value = state; + stickyDayTimestamp.value = activeDayTimestamp; + } + + final viewportHeight = timelineViewportHeight.value; + if (viewportHeight <= 0 || displayEntries.isEmpty) { + setStickyDateHeader(StickyDateHeaderState.hidden); + return; + } + + final positions = rawPositions + .where( + (position) => + position.index < displayEntries.length && + position.itemLeadingEdge < 1 && + position.itemTrailingEdge > 0, + ) + .toList(); + if (positions.isEmpty) { + if (!isLoadingOlder.value) { + setStickyDateHeader(StickyDateHeaderState.hidden); + } + return; + } + + final stickyTop = + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) + + Grid.twelve; + double physicalTop(ItemPosition position) => + viewportHeight * (1 - position.itemTrailingEdge); + double physicalBottom(ItemPosition position) => + viewportHeight * (1 - position.itemLeadingEdge); + + final positionAtStickyTop = positions + .where( + (position) => + physicalTop(position) <= stickyTop && + physicalBottom(position) > stickyTop, + ) + .firstOrNull; + if (positionAtStickyTop == null) { + if (!isLoadingOlder.value) { + setStickyDateHeader(StickyDateHeaderState.hidden); + } + return; + } + + final activeDayTimestamp = + dayTimestampByReversedIndex[positionAtStickyTop.index]; + final activeDayStartIndex = + dayStartByReversedIndex[positionAtStickyTop.index]; + if (activeDayTimestamp == null || activeDayStartIndex == null) { + setStickyDateHeader(StickyDateHeaderState.hidden); + return; + } + + final activeHeaderPosition = positions + .where((position) => position.index == activeDayStartIndex) + .firstOrNull; + final oldestVisibleIndex = positions + .map((position) => position.index) + .reduce((a, b) => a > b ? a : b); + final activeHeaderHasCrossed = activeHeaderPosition != null + ? physicalTop(activeHeaderPosition) <= stickyTop + : activeDayStartIndex > oldestVisibleIndex; + if (!activeHeaderHasCrossed) { + setStickyDateHeader(StickyDateHeaderState.hidden); + return; + } + + double? nextHeaderTop; + for (final position in positions) { + if (!dayHeaderTimestampByReversedIndex.containsKey(position.index) || + position.index >= activeDayStartIndex) { + continue; + } + final top = physicalTop(position); + if (top <= stickyTop || + (nextHeaderTop != null && top >= nextHeaderTop)) { + continue; + } + nextHeaderTop = top; + } + + final stickyHeaderHeight = StickyDateHeader.heightOf(context); + final rawTranslateY = nextHeaderTop == null + ? 0.0 + : min(0.0, nextHeaderTop - stickyTop - stickyHeaderHeight - 5); + final translateY = rawTranslateY + .clamp(-(stickyHeaderHeight + 5), 0.0) + .toDouble(); + setStickyDateHeader( + StickyDateHeaderState( + label: formatDayHeading(activeDayTimestamp), + translateY: (translateY * 2).round() / 2, + ), + activeDayTimestamp: activeDayTimestamp, + ); + } + Future scrollToLatest() async { if (!itemScrollController.isAttached || isAutoScrolling.value) return; followsLatest.value = true; @@ -176,6 +323,7 @@ class _MessageList extends HookConsumerWidget { ); if (context.mounted && !hasUserScrolled.value) { isAtLatest.value = true; + isJumpToLatestVisible.value = false; } } finally { isAutoScrolling.value = false; @@ -219,6 +367,34 @@ class _MessageList extends HookConsumerWidget { ); } + void updateJumpToLatestVisibility( + Iterable positions, { + double? viewportDimension, + }) { + final latestIsVisible = positions.any( + (position) => + position.index == 0 && + position.itemLeadingEdge < 1 && + position.itemTrailingEdge > latestAlignment(), + ); + final viewportHeight = viewportDimension ?? timelineViewportHeight.value; + final visiblePageHeight = max( + 0.0, + viewportHeight - + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) - + composerBottomInset, + ); + final shouldShow = + !latestIsAtBoundary() && + (!latestIsVisible || distanceFromLatest.value > visiblePageHeight); + if (isJumpToLatestVisible.value != shouldShow) { + isJumpToLatestVisible.value = shouldShow; + } + } + void realignLatestAfterLayoutChange() { if (latestRealignmentQueued.value || !followsLatest.value || @@ -242,41 +418,71 @@ class _MessageList extends HookConsumerWidget { }); } - useEffect(() { - void onPositionsChanged() { - final positions = itemPositionsListener.itemPositions.value; - if (positions.isEmpty) return; - final nextIsAtLatest = latestIsAtBoundary(); - if (showUnreadNavigation && - nextIsAtLatest && - detachedWhileUnreadShown.value) { - isUnreadNavigationDismissed.value = true; - } - if (nextIsAtLatest) { - if (!isAtLatest.value) isAtLatest.value = true; - } else if (!followsLatest.value && isAtLatest.value) { - isAtLatest.value = false; + useEffect( + () { + void onPositionsChanged() { + final positions = itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + updateStickyDateHeader(positions); + updateJumpToLatestVisibility(positions); + final nextIsAtLatest = latestIsAtBoundary(); + if (showUnreadNavigation && + nextIsAtLatest && + detachedWhileUnreadShown.value) { + isUnreadNavigationDismissed.value = true; + } + if (nextIsAtLatest) { + if (!isAtLatest.value) isAtLatest.value = true; + if (isJumpToLatestVisible.value) { + isJumpToLatestVisible.value = false; + } + } else if (!followsLatest.value && isAtLatest.value) { + isAtLatest.value = false; + } + + final oldestVisible = positions + .map((position) => position.index) + .reduce((a, b) => a > b ? a : b); + if (!hasUserScrolled.value || + oldestVisible < displayEntries.length - 3 || + isLoadingOlder.value) { + return; + } + final notifier = ref.read( + channelMessagesProvider(channelId).notifier, + ); + if (notifier.reachedOldest) return; + isLoadingOlder.value = true; + notifier.fetchOlder().whenComplete( + () => isLoadingOlder.value = false, + ); } - final oldestVisible = positions - .map((position) => position.index) - .reduce((a, b) => a > b ? a : b); - if (!hasUserScrolled.value || - oldestVisible < displayEntries.length - 3 || - isLoadingOlder.value) { - return; - } - final notifier = ref.read(channelMessagesProvider(channelId).notifier); - if (notifier.reachedOldest) return; - isLoadingOlder.value = true; - notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false); - } + var disposed = false; + itemPositionsListener.itemPositions.addListener(onPositionsChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!disposed && context.mounted) onPositionsChanged(); + }); + return () { + disposed = true; + itemPositionsListener.itemPositions.removeListener( + onPositionsChanged, + ); + }; + }, + [ + channelId, + entries.length, + itemPositionsListener, + appBarTitleContentHeight, + ], + ); - itemPositionsListener.itemPositions.addListener(onPositionsChanged); - return () => itemPositionsListener.itemPositions.removeListener( - onPositionsChanged, - ); - }, [channelId, entries.length, itemPositionsListener]); + useEffect(() { + stickyDateHeaderState.value = StickyDateHeaderState.hidden; + stickyDayTimestamp.value = null; + return null; + }, [channelId]); // Composer size changes and keyboard metrics changes arrive in separate // layout passes. Preserve the latest-message anchor for both, but only @@ -395,8 +601,25 @@ class _MessageList extends HookConsumerWidget { return Stack( children: [ - NotificationListener( + NotificationListener( onNotification: (notification) { + if (notification is ScrollMetricsNotification) { + timelineViewportHeight.value = + notification.metrics.viewportDimension; + return false; + } + if (notification is! ScrollNotification) return false; + timelineViewportHeight.value = + notification.metrics.viewportDimension; + distanceFromLatest.value = max( + 0.0, + notification.metrics.pixels - + notification.metrics.minScrollExtent, + ); + updateJumpToLatestVisibility( + itemPositionsListener.itemPositions.value, + viewportDimension: notification.metrics.viewportDimension, + ); if (notification is UserScrollNotification && notification.direction != ScrollDirection.idle) { hasUserScrolled.value = true; @@ -477,7 +700,11 @@ class _MessageList extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showDayDivider) - DayDivider(label: formatDayHeading(message.createdAt)), + DayDivider( + label: formatDayHeading(message.createdAt), + dayTimestamp: message.createdAt, + stickyDayTimestamp: stickyDayTimestamp, + ), if (message.isSystem) _SystemMessageRow( message: message, @@ -521,6 +748,21 @@ class _MessageList extends HookConsumerWidget { ), ), ), + if (!showUnreadNavigation) + Positioned( + left: 0, + right: 0, + top: + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) + + Grid.twelve, + child: StickyDateHeader( + key: const ValueKey('channel-sticky-date-header'), + state: stickyDateHeaderState, + ), + ), if (showUnreadNavigation) Positioned( left: 0, @@ -544,80 +786,42 @@ class _MessageList extends HookConsumerWidget { ), ), ) - else if (!isAtLatest.value) + else Positioned( left: 0, right: 0, bottom: composerBottomInset + Grid.xs, child: Center( - child: _JumpToLatestButton( - key: const ValueKey('channel-jump-to-latest'), - onPressed: scrollToLatest, - ), - ), - ), - ], - ); - } -} - -class _JumpToLatestButton extends StatelessWidget { - final VoidCallback onPressed; - - const _JumpToLatestButton({required this.onPressed, super.key}); - - @override - Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(Radii.full); - return Semantics( - button: true, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - key: const ValueKey('channel-jump-to-latest-surface'), - decoration: BoxDecoration( - color: context.colors.surface.withValues(alpha: 0.5), - borderRadius: borderRadius, - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: onPressed, - borderRadius: borderRadius, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.arrowDown, - size: 16, - color: context.colors.onSurface, - ), - const SizedBox(width: Grid.half), - Text( - 'Latest', - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.onSurface, - ), - ), - ], + child: AnimatedSwitcher( + key: const ValueKey('channel-jump-to-latest-switcher'), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + reverseDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 160), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: _JumpToLatestScaleAnimation(animation), + alignment: Alignment.bottomCenter, + child: child, ), ), + child: !isJumpToLatestVisible.value + ? const SizedBox.shrink( + key: ValueKey('channel-jump-to-latest-hidden'), + ) + : JumpToLatestButton( + key: const ValueKey('channel-jump-to-latest'), + onPressed: scrollToLatest, + ), ), ), ), - ), - ), + ], ); } } @@ -630,3 +834,16 @@ class _ChannelLatestMetricsObserver with WidgetsBindingObserver { @override void didChangeMetrics() => onMetricsChanged(); } + +class _JumpToLatestScaleAnimation extends Animation + with AnimationWithParentMixin { + @override + final Animation parent; + + _JumpToLatestScaleAnimation(this.parent); + + @override + double get value => parent.status == AnimationStatus.reverse + ? parent.value + : 0.92 + (0.08 * parent.value); +} diff --git a/mobile/lib/features/channels/day_divider.dart b/mobile/lib/features/channels/day_divider.dart index 7d65ad58fe6..24f5bb798e3 100644 --- a/mobile/lib/features/channels/day_divider.dart +++ b/mobile/lib/features/channels/day_divider.dart @@ -1,53 +1,60 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../shared/theme/theme.dart'; -/// Desktop-parity day separator with a centered label over a horizontal rule. +/// In-flow date label. The active date gains a glass capsule when it sticks. class DayDivider extends StatelessWidget { final String label; + final int? dayTimestamp; + final ValueListenable? stickyDayTimestamp; - const DayDivider({super.key, required this.label}); + const DayDivider({ + super.key, + required this.label, + this.dayTimestamp, + this.stickyDayTimestamp, + }); + + Widget _buildOpacity(BuildContext context, {required bool isSticky}) { + return ExcludeSemantics( + excluding: isSticky, + child: AnimatedOpacity( + key: dayTimestamp == null + ? null + : ValueKey('channel-day-divider-opacity-$dayTimestamp'), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + opacity: isSticky ? 0 : 1, + child: Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant.withValues(alpha: 0.72), + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } @override Widget build(BuildContext context) { + final activeTimestamp = stickyDayTimestamp; + final timestamp = dayTimestamp; return Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: SizedBox( - width: double.infinity, - child: Stack( - alignment: Alignment.center, - children: [ - Positioned( - left: 0, - right: 0, - child: Divider( - height: 1, - thickness: 1, - color: context.colors.outlineVariant.withValues(alpha: 0.35), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xxs + Grid.quarter, - vertical: Grid.half, - ), - decoration: BoxDecoration( - color: context.colors.surface, - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: context.colors.outlineVariant.withValues(alpha: 0.7), + padding: const EdgeInsets.symmetric(vertical: Grid.xxs + Grid.quarter), + child: Center( + child: activeTimestamp == null || timestamp == null + ? _buildOpacity(context, isSticky: false) + : ValueListenableBuilder( + valueListenable: activeTimestamp, + builder: (context, activeDayTimestamp, _) => _buildOpacity( + context, + isSticky: activeDayTimestamp == timestamp, ), ), - child: Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant.withValues(alpha: 0.7), - letterSpacing: 0.22, - ), - ), - ), - ], - ), ), ); } diff --git a/mobile/lib/features/channels/jump_to_latest_button.dart b/mobile/lib/features/channels/jump_to_latest_button.dart new file mode 100644 index 00000000000..47dd6a1b87a --- /dev/null +++ b/mobile/lib/features/channels/jump_to_latest_button.dart @@ -0,0 +1,99 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; + +/// Compact conversation control that returns a detached timeline to its tail. +class JumpToLatestButton extends HookWidget { + final VoidCallback onPressed; + + const JumpToLatestButton({required this.onPressed, super.key}); + + static const _iosViewType = 'buzz/jump_to_latest_glass'; + + @override + Widget build(BuildContext context) { + final nativeChannel = useState(null); + final onPressedRef = useRef(onPressed)..value = onPressed; + + useEffect(() { + final channel = nativeChannel.value; + if (channel == null) return null; + channel.setMethodCallHandler((call) async { + if (call.method == 'pressed') onPressedRef.value(); + }); + return () => channel.setMethodCallHandler(null); + }, [nativeChannel.value]); + + final borderColor = context.colors.onSurface.withValues(alpha: 0.08); + final usesNativeIosGlass = defaultTargetPlatform == TargetPlatform.iOS; + + return Semantics( + button: true, + label: 'Jump to latest message', + child: Tooltip( + excludeFromSemantics: true, + message: 'Jump to latest message', + child: SizedBox.square( + dimension: Grid.xl, + child: usesNativeIosGlass + ? UiKitView( + key: const ValueKey('channel-jump-to-latest-ios-glass'), + viewType: _iosViewType, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel( + '$_iosViewType/$viewId', + ); + }, + ) + : Material( + color: Colors.transparent, + child: InkResponse( + containedInkWell: true, + customBorder: const CircleBorder(), + onTap: onPressed, + radius: Grid.sm, + child: Align( + key: const ValueKey( + 'channel-jump-to-latest-visual-anchor', + ), + alignment: Alignment.bottomCenter, + child: ClipOval( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + key: const ValueKey( + 'channel-jump-to-latest-surface', + ), + width: Grid.lg, + height: Grid.lg, + decoration: BoxDecoration( + color: context.colors.surface.withValues( + alpha: 0.72, + ), + shape: BoxShape.circle, + border: Border.all(color: borderColor), + ), + child: Icon( + LucideIcons.arrowDown, + size: Grid.gutter, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/sticky_date_header.dart b/mobile/lib/features/channels/sticky_date_header.dart new file mode 100644 index 00000000000..3b725004562 --- /dev/null +++ b/mobile/lib/features/channels/sticky_date_header.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../../shared/theme/theme.dart'; + +/// The active date and vertical push-off applied to a sticky date header. +@immutable +class StickyDateHeaderState { + final String? label; + final double translateY; + + const StickyDateHeaderState({this.label, this.translateY = 0}); + + static const hidden = StickyDateHeaderState(); + + bool get isVisible => label != null; + + @override + bool operator ==(Object other) { + return other is StickyDateHeaderState && + other.label == label && + other.translateY == translateY; + } + + @override + int get hashCode => Object.hash(label, translateY); +} + +/// A glass date capsule that remains below the app bar as its day scrolls. +class StickyDateHeader extends StatelessWidget { + final ValueListenable state; + + const StickyDateHeader({required this.state, super.key}); + + static const _iosViewType = 'buzz/sticky_date_glass'; + static const _minimumIosGlassHeight = 28.0; + + /// Height used by the surface and the next-day push-off calculation. + static double heightOf(BuildContext context) { + final labelStyle = context.textTheme.labelMedium; + final unscaledLineHeight = + (labelStyle?.fontSize ?? 14) * (labelStyle?.height ?? 1.25); + final contentHeight = + MediaQuery.textScalerOf(context).scale(unscaledLineHeight) + Grid.xxs; + return defaultTargetPlatform == TargetPlatform.iOS + ? max(_minimumIosGlassHeight, contentHeight) + : contentHeight; + } + + Widget _buildIosGlass(BuildContext context, String label) { + final textStyle = context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ); + final textPainter = TextPainter( + text: TextSpan(text: label, style: textStyle), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + + return _IosStickyDateGlass( + label: label, + width: textPainter.width + Grid.sm, + height: heightOf(context), + ); + } + + Widget _buildFlutterSurface(BuildContext context, String label) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.full), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + key: const ValueKey('channel-sticky-date-header-surface'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.half, + ), + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(Radii.full), + border: Border.all( + color: context.colors.onSurface.withValues(alpha: 0.08), + ), + ), + child: Semantics( + header: true, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + + return ValueListenableBuilder( + valueListenable: state, + builder: (context, value, _) { + return IgnorePointer( + child: ExcludeSemantics( + excluding: !value.isVisible, + child: AnimatedOpacity( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + opacity: value.isVisible ? 1 : 0, + child: Transform.translate( + offset: Offset(0, value.translateY), + child: Center( + child: RepaintBoundary( + child: defaultTargetPlatform == TargetPlatform.iOS + ? _buildIosGlass(context, value.label ?? '') + : _buildFlutterSurface(context, value.label ?? ''), + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _IosStickyDateGlass extends HookWidget { + final String label; + final double width; + final double height; + + const _IosStickyDateGlass({ + required this.label, + required this.width, + required this.height, + }); + + @override + Widget build(BuildContext context) { + final nativeChannel = useState(null); + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited(channel.invokeMethod('setLabel', label)); + } + return null; + }, [nativeChannel.value, label]); + + return Semantics( + header: true, + label: label, + child: ExcludeSemantics( + child: SizedBox( + key: const ValueKey('channel-sticky-date-header-surface'), + width: width, + height: height, + child: UiKitView( + key: const ValueKey('channel-sticky-date-header-ios-glass'), + viewType: StickyDateHeader._iosViewType, + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + creationParams: {'label': label}, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel( + '${StickyDateHeader._iosViewType}/$viewId', + ); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 365befcff07..afd83c84882 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1697,8 +1697,9 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, ); - expect(find.text('Latest'), findsOneWidget); + expect(find.text('Latest'), findsNothing); expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget); + expect(find.byTooltip('Jump to latest message'), findsOneWidget); }); testWidgets('loads history through the oldest unread boundary', ( @@ -2440,19 +2441,83 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, ); - final latestSurface = tester.widget( - find.byKey(const ValueKey('channel-jump-to-latest-surface')), + final latestSurfaceFinder = find.byKey( + const ValueKey('channel-jump-to-latest-surface'), ); + final latestSurface = tester.widget(latestSurfaceFinder); final latestDecoration = latestSurface.decoration! as BoxDecoration; - expect(latestDecoration.borderRadius, BorderRadius.circular(Radii.full)); + expect(latestDecoration.shape, BoxShape.circle); expect( latestDecoration.color, - AppTheme.light().colorScheme.surface.withValues(alpha: 0.5), + AppTheme.light().colorScheme.surface.withValues(alpha: 0.72), ); expect( (latestDecoration.border! as Border).top.color, - Colors.black.withValues(alpha: 0.04), + AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.08), + ); + expect( + tester.getSize(find.byKey(const ValueKey('channel-jump-to-latest'))), + const Size.square(Grid.xl), + ); + expect( + tester + .getCenter(find.byKey(const ValueKey('channel-jump-to-latest'))) + .dx, + closeTo(tester.getCenter(messageList).dx, 0.1), ); + expect( + tester + .getTopLeft(find.byKey(const ValueKey('channel-composer-dock'))) + .dy - + tester + .getBottomRight( + find.byKey(const ValueKey('channel-jump-to-latest')), + ) + .dy, + closeTo(Grid.xs, 0.1), + ); + final latestSwitcher = tester.widget( + find.byKey(const ValueKey('channel-jump-to-latest-switcher')), + ); + expect(latestSwitcher.duration, const Duration(milliseconds: 180)); + expect(latestSwitcher.reverseDuration, const Duration(milliseconds: 160)); + expect(latestSwitcher.switchInCurve, Curves.easeOutCubic); + expect(latestSwitcher.switchOutCurve, Curves.easeInCubic); + final latestScaleTransition = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('channel-jump-to-latest-switcher')), + matching: find.byType(ScaleTransition), + ), + ); + expect(latestScaleTransition.alignment, Alignment.bottomCenter); + final visualAnchor = tester.widget( + find.byKey(const ValueKey('channel-jump-to-latest-visual-anchor')), + ); + expect(visualAnchor.alignment, Alignment.bottomCenter); + expect(tester.getSize(latestSurfaceFinder), const Size.square(Grid.lg)); + expect( + tester.getBottomRight(latestSurfaceFinder).dy, + closeTo( + tester + .getBottomRight( + find.byKey(const ValueKey('channel-jump-to-latest')), + ) + .dy, + 0.1, + ), + ); + expect(find.text('Latest'), findsNothing); + expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget); + for (final container in tester.widgetList( + find.descendant( + of: find.byKey(const ValueKey('channel-jump-to-latest')), + matching: find.byType(Container), + ), + )) { + if (container.decoration case final BoxDecoration decoration) { + expect(decoration.boxShadow, anyOf(isNull, isEmpty)); + } + } expect( find.descendant( of: find.byKey(const ValueKey('channel-jump-to-latest')), @@ -2474,6 +2539,30 @@ void main() { expect(findRichText('Newest live update'), findsNothing); await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest'))); + await tester.pump(); + + ScaleTransition exitingScaleTransition() { + return tester.widget( + find.ancestor( + of: find.byKey(const ValueKey('channel-jump-to-latest')), + matching: find.byType(ScaleTransition), + ), + ); + } + + for (var frame = 0; frame < 60; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + if (exitingScaleTransition().scale.status == AnimationStatus.reverse) { + break; + } + } + expect(exitingScaleTransition().scale.status, AnimationStatus.reverse); + await tester.pump(const Duration(milliseconds: 120)); + + final collapsedScaleTransition = exitingScaleTransition(); + expect(collapsedScaleTransition.alignment, Alignment.bottomCenter); + expect(collapsedScaleTransition.scale.value, lessThan(0.1)); + await tester.pumpAndSettle(); expect(findRichText('Newest live update'), findsOneWidget); @@ -2491,6 +2580,74 @@ void main() { ); }); + testWidgets( + 'pins the current day below the app bar after its divider scrolls away', + (tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final firstDay = + DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000; + final messages = [ + for (var day = 0; day < 3; day += 1) + for (var index = 0; index < 10; index += 1) + _textMsg( + id: 'day-$day-message-$index', + pubkey: 'alice', + content: 'Day $day message $index', + createdAt: firstDay + day * 86400 + index, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final messageList = find.byKey(const ValueKey('channel-message-list')); + final list = tester.widget(messageList); + list.itemScrollController!.jumpTo(index: 14, alignment: 0.8); + await tester.pumpAndSettle(); + + final stickyHeader = find.byKey( + const ValueKey('channel-sticky-date-header'), + ); + final stickySurface = find.byKey( + const ValueKey('channel-sticky-date-header-surface'), + ); + expect(stickyHeader, findsOneWidget); + expect(stickySurface, findsOneWidget); + expect( + find.descendant( + of: stickyHeader, + matching: find.text(formatDayHeading(firstDay + 86400)), + ), + findsOneWidget, + ); + expect( + tester.getTopLeft(stickySurface).dy, + closeTo( + frostedAppBarHeight(tester.element(stickyHeader)) + Grid.twelve, + 1, + ), + ); + expect( + find.descendant( + of: stickyHeader, + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); + }, + ); + testWidgets( 'keeps follow mode off while a tall newest message stays visible', (tester) async { @@ -2539,7 +2696,7 @@ void main() { expect(findRichText('Newest message line 0'), findsOneWidget); expect( find.byKey(const ValueKey('channel-jump-to-latest')), - findsOneWidget, + findsNothing, ); messagesNotifier.setMessages([ diff --git a/mobile/test/features/channels/day_divider_test.dart b/mobile/test/features/channels/day_divider_test.dart new file mode 100644 index 00000000000..23b39052681 --- /dev/null +++ b/mobile/test/features/channels/day_divider_test.dart @@ -0,0 +1,44 @@ +import 'package:buzz/features/channels/day_divider.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('fades the in-flow date while that day is sticky', ( + tester, + ) async { + final stickyDayTimestamp = ValueNotifier(null); + addTearDown(stickyDayTimestamp.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: DayDivider( + label: 'Today', + dayTimestamp: 1000, + stickyDayTimestamp: stickyDayTimestamp, + ), + ), + ), + ); + + AnimatedOpacity opacity() => tester.widget( + find.byKey(const ValueKey('channel-day-divider-opacity-1000')), + ); + + expect(opacity().opacity, 1); + + stickyDayTimestamp.value = 1000; + await tester.pump(); + + expect(opacity().opacity, 0); + expect(opacity().duration, const Duration(milliseconds: 120)); + expect(opacity().curve, Curves.easeOutCubic); + + stickyDayTimestamp.value = null; + await tester.pump(); + + expect(opacity().opacity, 1); + }); +} diff --git a/mobile/test/features/channels/jump_to_latest_button_test.dart b/mobile/test/features/channels/jump_to_latest_button_test.dart new file mode 100644 index 00000000000..a8880a0dfd0 --- /dev/null +++ b/mobile/test/features/channels/jump_to_latest_button_test.dart @@ -0,0 +1,28 @@ +import 'package:buzz/features/channels/jump_to_latest_button.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('uses the native glass control on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + ), + ); + + final nativeView = tester.widget(find.byType(UiKitView)); + expect(nativeView.viewType, 'buzz/jump_to_latest_glass'); + expect( + find.byKey(const ValueKey('channel-jump-to-latest-ios-glass')), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/mobile/test/features/channels/sticky_date_header_test.dart b/mobile/test/features/channels/sticky_date_header_test.dart new file mode 100644 index 00000000000..53b08dd3353 --- /dev/null +++ b/mobile/test/features/channels/sticky_date_header_test.dart @@ -0,0 +1,56 @@ +import 'package:buzz/features/channels/sticky_date_header.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('uses native glass and updates its date on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final state = ValueNotifier( + const StickyDateHeaderState(label: 'Yesterday'), + ); + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), + ); + + var nativeView = tester.widget(find.byType(UiKitView)); + expect(nativeView.viewType, 'buzz/sticky_date_glass'); + expect(nativeView.creationParams, {'label': 'Yesterday'}); + expect(find.byType(BackdropFilter), findsNothing); + + state.value = const StickyDateHeaderState(label: 'Today'); + await tester.pump(); + + nativeView = tester.widget(find.byType(UiKitView)); + expect(nativeView.creationParams, {'label': 'Today'}); + } finally { + state.dispose(); + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('keeps the Flutter date surface on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final state = ValueNotifier(const StickyDateHeaderState(label: 'Today')); + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.text('Today'), findsOneWidget); + } finally { + state.dispose(); + debugDefaultTargetPlatformOverride = null; + } + }); +} From d7e6578ba9ac52e2b74ac39633b3d270b50d9f4c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 14 Aug 2026 16:35:22 +0100 Subject: [PATCH 2/7] Ignore nested timeline scroll metrics Signed-off-by: kenny lopez --- .../channel_detail_page/message_list.dart | 6 +- .../channels/channel_detail_page_test.dart | 58 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index a2bff4917b1..9f556349408 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -604,11 +604,15 @@ class _MessageList extends HookConsumerWidget { NotificationListener( onNotification: (notification) { if (notification is ScrollMetricsNotification) { + if (notification.depth != 0) return false; timelineViewportHeight.value = notification.metrics.viewportDimension; return false; } - if (notification is! ScrollNotification) return false; + if (notification is! ScrollNotification || + notification.depth != 0) { + return false; + } timelineViewportHeight.value = notification.metrics.viewportDimension; distanceFromLatest.value = max( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index afd83c84882..88c3672fb72 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2395,6 +2395,64 @@ void main() { }, ); + testWidgets('ignores scroll notifications from nested message content', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final codeLine = List.filled(80, 'nestedScrollMarker').join('_'); + final initialMessage = _textMsg( + id: 'code-message', + pubkey: 'alice', + content: ['```dart', codeLine, '```'].join('\n'), + ); + final messagesNotifier = _FakeMessagesNotifier([initialMessage]); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: messagesNotifier, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final codeScroller = find.ancestor( + of: findRichText('nestedScrollMarker'), + matching: find.byType(SingleChildScrollView), + ); + expect(codeScroller, findsOneWidget); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + + await tester.drag(codeScroller, const Offset(-800, 0)); + await tester.pumpAndSettle(); + + messagesNotifier.setMessages([ + initialMessage, + _textMsg( + id: 'new-message', + pubkey: 'alice', + content: 'Newest message', + createdAt: 2000, + ), + ]); + await tester.pumpAndSettle(); + + expect(findRichText('Newest message'), findsOneWidget); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + }); + testWidgets('can jump back to latest after a non-drag user scroll', ( tester, ) async { From 091f8e96deb706e5d33bad548b8db9c150f11f3d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 14 Aug 2026 17:08:09 +0100 Subject: [PATCH 3/7] Sync iOS glass controls with app theme Signed-off-by: kenny lopez --- .../ios/Runner/JumpToLatestGlassButton.swift | 29 +++++++++ mobile/ios/Runner/StickyDateGlassHeader.swift | 22 +++++-- .../channels/jump_to_latest_button.dart | 12 ++++ .../features/channels/sticky_date_header.dart | 9 ++- .../channels/jump_to_latest_button_test.dart | 45 +++++++++++++- .../channels/sticky_date_header_test.dart | 60 ++++++++++++++++++- 6 files changed, 167 insertions(+), 10 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 790c154e751..0e96a78ec0c 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -9,6 +9,10 @@ final class JumpToLatestGlassButtonFactory: NSObject, FlutterPlatformViewFactory super.init() } + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + func create( withFrame frame: CGRect, viewIdentifier viewId: Int64, @@ -17,6 +21,7 @@ final class JumpToLatestGlassButtonFactory: NSObject, FlutterPlatformViewFactory JumpToLatestGlassButtonPlatformView( frame: frame, viewIdentifier: viewId, + arguments: args, messenger: messenger ) } @@ -43,6 +48,7 @@ final class JumpToLatestGlassButtonPlatformView: NSObject, FlutterPlatformView { init( frame: CGRect, viewIdentifier viewId: Int64, + arguments args: Any?, messenger: FlutterBinaryMessenger ) { containerView = UIView(frame: frame) @@ -54,6 +60,7 @@ final class JumpToLatestGlassButtonPlatformView: NSObject, FlutterPlatformView { containerView.backgroundColor = .clear containerView.isOpaque = false + applyBrightness(from: args) var configuration: UIButton.Configuration if #available(iOS 26.0, *) { @@ -81,6 +88,15 @@ final class JumpToLatestGlassButtonPlatformView: NSObject, FlutterPlatformView { for: .touchUpInside ) + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "setBrightness" else { + result(FlutterMethodNotImplemented) + return + } + self?.applyBrightness(from: call.arguments) + result(nil) + } + containerView.addSubview(button) NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), @@ -93,4 +109,17 @@ final class JumpToLatestGlassButtonPlatformView: NSObject, FlutterPlatformView { func view() -> UIView { containerView } + + private func applyBrightness(from value: Any?) { + let brightness = (value as? [String: Any])?["brightness"] as? String + ?? value as? String + let interfaceStyle: UIUserInterfaceStyle = brightness == "dark" ? .dark : .light + containerView.overrideUserInterfaceStyle = interfaceStyle + button.overrideUserInterfaceStyle = interfaceStyle + button.setNeedsUpdateConfiguration() + } + + deinit { + channel.setMethodCallHandler(nil) + } } diff --git a/mobile/ios/Runner/StickyDateGlassHeader.swift b/mobile/ios/Runner/StickyDateGlassHeader.swift index f95ec0555da..6a502048a20 100644 --- a/mobile/ios/Runner/StickyDateGlassHeader.swift +++ b/mobile/ios/Runner/StickyDateGlassHeader.swift @@ -69,6 +69,7 @@ final class StickyDateGlassHeaderPlatformView: NSObject, FlutterPlatformView { glassView.isUserInteractionEnabled = false glassView.clipsToBounds = true glassView.layer.cornerCurve = .continuous + applyBrightness(from: arguments?["brightness"]) dateLabel.translatesAutoresizingMaskIntoConstraints = false dateLabel.text = text @@ -98,12 +99,20 @@ final class StickyDateGlassHeaderPlatformView: NSObject, FlutterPlatformView { ]) channel.setMethodCallHandler { [weak self] call, result in - guard call.method == "setLabel", let text = call.arguments as? String else { + switch call.method { + case "setLabel": + guard let text = call.arguments as? String else { + result(FlutterMethodNotImplemented) + return + } + self?.dateLabel.text = text + result(nil) + case "setBrightness": + self?.applyBrightness(from: call.arguments) + result(nil) + default: result(FlutterMethodNotImplemented) - return } - self?.dateLabel.text = text - result(nil) } } @@ -111,6 +120,11 @@ final class StickyDateGlassHeaderPlatformView: NSObject, FlutterPlatformView { glassView } + private func applyBrightness(from value: Any?) { + let interfaceStyle: UIUserInterfaceStyle = value as? String == "dark" ? .dark : .light + glassView.overrideUserInterfaceStyle = interfaceStyle + } + deinit { channel.setMethodCallHandler(nil) } diff --git a/mobile/lib/features/channels/jump_to_latest_button.dart b/mobile/lib/features/channels/jump_to_latest_button.dart index 47dd6a1b87a..314fb624ef9 100644 --- a/mobile/lib/features/channels/jump_to_latest_button.dart +++ b/mobile/lib/features/channels/jump_to_latest_button.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -21,6 +22,7 @@ class JumpToLatestButton extends HookWidget { Widget build(BuildContext context) { final nativeChannel = useState(null); final onPressedRef = useRef(onPressed)..value = onPressed; + final brightness = context.theme.brightness.name; useEffect(() { final channel = nativeChannel.value; @@ -31,6 +33,14 @@ class JumpToLatestButton extends HookWidget { return () => channel.setMethodCallHandler(null); }, [nativeChannel.value]); + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited(channel.invokeMethod('setBrightness', brightness)); + } + return null; + }, [nativeChannel.value, brightness]); + final borderColor = context.colors.onSurface.withValues(alpha: 0.08); final usesNativeIosGlass = defaultTargetPlatform == TargetPlatform.iOS; @@ -47,6 +57,8 @@ class JumpToLatestButton extends HookWidget { key: const ValueKey('channel-jump-to-latest-ios-glass'), viewType: _iosViewType, hitTestBehavior: PlatformViewHitTestBehavior.opaque, + creationParams: {'brightness': brightness}, + creationParamsCodec: const StandardMessageCodec(), onPlatformViewCreated: (viewId) { nativeChannel.value = MethodChannel( '$_iosViewType/$viewId', diff --git a/mobile/lib/features/channels/sticky_date_header.dart b/mobile/lib/features/channels/sticky_date_header.dart index 3b725004562..cad7f452b97 100644 --- a/mobile/lib/features/channels/sticky_date_header.dart +++ b/mobile/lib/features/channels/sticky_date_header.dart @@ -168,14 +168,16 @@ class _IosStickyDateGlass extends HookWidget { @override Widget build(BuildContext context) { final nativeChannel = useState(null); + final brightness = context.theme.brightness.name; useEffect(() { final channel = nativeChannel.value; if (channel != null) { unawaited(channel.invokeMethod('setLabel', label)); + unawaited(channel.invokeMethod('setBrightness', brightness)); } return null; - }, [nativeChannel.value, label]); + }, [nativeChannel.value, label, brightness]); return Semantics( header: true, @@ -189,7 +191,10 @@ class _IosStickyDateGlass extends HookWidget { key: const ValueKey('channel-sticky-date-header-ios-glass'), viewType: StickyDateHeader._iosViewType, hitTestBehavior: PlatformViewHitTestBehavior.transparent, - creationParams: {'label': label}, + creationParams: { + 'label': label, + 'brightness': brightness, + }, creationParamsCodec: const StandardMessageCodec(), onPlatformViewCreated: (viewId) { nativeChannel.value = MethodChannel( diff --git a/mobile/test/features/channels/jump_to_latest_button_test.dart b/mobile/test/features/channels/jump_to_latest_button_test.dart index a8880a0dfd0..b934f83aa2b 100644 --- a/mobile/test/features/channels/jump_to_latest_button_test.dart +++ b/mobile/test/features/channels/jump_to_latest_button_test.dart @@ -2,11 +2,22 @@ import 'package:buzz/features/channels/jump_to_latest_button.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('uses the native glass control on iOS', (tester) async { + testWidgets('keeps the native iOS glass in sync with the app theme', ( + tester, + ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('buzz/jump_to_latest_glass/41'); + final methodCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + methodCalls.add(call); + return null; + }); try { await tester.pumpWidget( MaterialApp( @@ -17,11 +28,43 @@ void main() { final nativeView = tester.widget(find.byType(UiKitView)); expect(nativeView.viewType, 'buzz/jump_to_latest_glass'); + expect(nativeView.creationParams, { + 'brightness': 'light', + }); expect( find.byKey(const ValueKey('channel-jump-to-latest-ios-glass')), findsOneWidget, ); + + nativeView.onPlatformViewCreated!(41); + await tester.pump(); + expect( + methodCalls + .lastWhere((call) => call.method == 'setBrightness') + .arguments, + 'light', + ); + + methodCalls.clear(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + ), + ); + await tester.pumpAndSettle(); + + expect( + methodCalls + .lastWhere((call) => call.method == 'setBrightness') + .arguments, + 'dark', + ); } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); debugDefaultTargetPlatformOverride = null; } }); diff --git a/mobile/test/features/channels/sticky_date_header_test.dart b/mobile/test/features/channels/sticky_date_header_test.dart index 53b08dd3353..c6c012ba0c6 100644 --- a/mobile/test/features/channels/sticky_date_header_test.dart +++ b/mobile/test/features/channels/sticky_date_header_test.dart @@ -2,14 +2,25 @@ import 'package:buzz/features/channels/sticky_date_header.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('uses native glass and updates its date on iOS', (tester) async { + testWidgets('updates the native iOS glass date and app theme', ( + tester, + ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; final state = ValueNotifier( const StickyDateHeaderState(label: 'Yesterday'), ); + const channel = MethodChannel('buzz/sticky_date_glass/42'); + final methodCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + methodCalls.add(call); + return null; + }); try { await tester.pumpWidget( MaterialApp( @@ -20,15 +31,58 @@ void main() { var nativeView = tester.widget(find.byType(UiKitView)); expect(nativeView.viewType, 'buzz/sticky_date_glass'); - expect(nativeView.creationParams, {'label': 'Yesterday'}); + expect(nativeView.creationParams, { + 'label': 'Yesterday', + 'brightness': 'light', + }); expect(find.byType(BackdropFilter), findsNothing); + nativeView.onPlatformViewCreated!(42); + await tester.pump(); + expect( + methodCalls.lastWhere((call) => call.method == 'setLabel').arguments, + 'Yesterday', + ); + expect( + methodCalls + .lastWhere((call) => call.method == 'setBrightness') + .arguments, + 'light', + ); + state.value = const StickyDateHeaderState(label: 'Today'); await tester.pump(); nativeView = tester.widget(find.byType(UiKitView)); - expect(nativeView.creationParams, {'label': 'Today'}); + expect(nativeView.creationParams, { + 'label': 'Today', + 'brightness': 'light', + }); + expect( + methodCalls.lastWhere((call) => call.method == 'setLabel').arguments, + 'Today', + ); + + methodCalls.clear(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), + ); + await tester.pumpAndSettle(); + + expect( + methodCalls + .lastWhere((call) => call.method == 'setBrightness') + .arguments, + 'dark', + ); } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); state.dispose(); debugDefaultTargetPlatformOverride = null; } From 5d622c23d3fc62824d06a630b3f88586d60dd4bf Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 14 Aug 2026 17:19:47 +0100 Subject: [PATCH 4/7] Slow latest button exit animation Signed-off-by: kenny lopez --- .../features/channels/channel_detail_page/message_list.dart | 2 +- mobile/test/features/channels/channel_detail_page_test.dart | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 9f556349408..40dd4a8c169 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -803,7 +803,7 @@ class _MessageList extends HookConsumerWidget { : const Duration(milliseconds: 180), reverseDuration: MediaQuery.disableAnimationsOf(context) ? Duration.zero - : const Duration(milliseconds: 160), + : const Duration(milliseconds: 320), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) => FadeTransition( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 88c3672fb72..579c6adb263 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2538,7 +2538,7 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest-switcher')), ); expect(latestSwitcher.duration, const Duration(milliseconds: 180)); - expect(latestSwitcher.reverseDuration, const Duration(milliseconds: 160)); + expect(latestSwitcher.reverseDuration, const Duration(milliseconds: 320)); expect(latestSwitcher.switchInCurve, Curves.easeOutCubic); expect(latestSwitcher.switchOutCurve, Curves.easeInCubic); final latestScaleTransition = tester.widget( @@ -2617,6 +2617,9 @@ void main() { expect(exitingScaleTransition().scale.status, AnimationStatus.reverse); await tester.pump(const Duration(milliseconds: 120)); + expect(exitingScaleTransition().scale.value, greaterThan(0.1)); + await tester.pump(const Duration(milliseconds: 120)); + final collapsedScaleTransition = exitingScaleTransition(); expect(collapsedScaleTransition.alignment, Alignment.bottomCenter); expect(collapsedScaleTransition.scale.value, lessThan(0.1)); From afb01e4d480672b4e720978a8c894723178fe88e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 14 Aug 2026 18:36:55 +0100 Subject: [PATCH 5/7] Refresh latest navigation on composer resize Signed-off-by: kenny lopez --- .../channel_detail_page/message_list.dart | 1 + .../channels/channel_detail_page_test.dart | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 40dd4a8c169..b1084fb49d0 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -475,6 +475,7 @@ class _MessageList extends HookConsumerWidget { entries.length, itemPositionsListener, appBarTitleContentHeight, + composerBottomInset, ], ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 579c6adb263..d16c22ee288 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2395,6 +2395,78 @@ void main() { }, ); + testWidgets( + 'recomputes latest navigation when a detached composer resizes', + (tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final tallMessage = List.generate( + 40, + (index) => 'Newest message line $index', + ).join('\n'); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'tall-newest', + pubkey: 'alice', + content: tallMessage, + createdAt: 1000, + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(TextField), + List.generate(5, (index) => 'Draft line $index').join('\n'), + ); + await tester.pumpAndSettle(); + + final messageList = find.byKey(const ValueKey('channel-message-list')); + final composerDock = find.byKey( + const ValueKey('channel-composer-dock'), + ); + final expandedDockHeight = tester.getSize(composerDock).height; + final viewportHeight = tester.getSize(messageList).height; + final appBarHeight = tester.getSize(find.byType(FrostedAppBar)).height; + final detachedDistance = + viewportHeight - appBarHeight - expandedDockHeight + Grid.xl; + + await tester.drag(messageList, Offset(0, detachedDistance)); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + + await tester.enterText(find.byType(TextField), 'Short draft'); + await tester.pumpAndSettle(); + + final compactDockHeight = tester.getSize(composerDock).height; + expect(compactDockHeight, lessThan(expandedDockHeight)); + expect( + detachedDistance, + lessThan(viewportHeight - appBarHeight - compactDockHeight), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + }, + ); + testWidgets('ignores scroll notifications from nested message content', ( tester, ) async { From 6fe81cfbb935991f5c97c4671f3655622dd80c0a Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 14 Aug 2026 19:08:45 +0100 Subject: [PATCH 6/7] Fix detached latest navigation Signed-off-by: kenny lopez --- .../channel_detail_page/message_list.dart | 32 +++++++++++++++---- .../channels/channel_detail_page_test.dart | 15 +++++++-- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index b1084fb49d0..2cf7c51c1bc 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -48,6 +48,7 @@ class _MessageList extends HookConsumerWidget { final isJumpToLatestVisible = useState(false); final hasUserScrolled = useState(false); final distanceFromLatest = useRef(0.0); + final hasUnseenLatestEntry = useRef(false); final followsLatest = useRef( initialMessageId == null && initialThreadRootId == null, ); @@ -313,6 +314,7 @@ class _MessageList extends HookConsumerWidget { if (!itemScrollController.isAttached || isAutoScrolling.value) return; followsLatest.value = true; hasUserScrolled.value = false; + hasUnseenLatestEntry.value = false; isAutoScrolling.value = true; try { await itemScrollController.scrollTo( @@ -354,13 +356,13 @@ class _MessageList extends HookConsumerWidget { } } - bool latestIsAtBoundary() { + bool latestIsAtBoundary([Iterable? positions]) { // In this reversed list, item 0's leading edge is the visible bottom // boundary above the composer. Being merely visible is not enough: a // user who has pulled a tall newest row away from that boundary must not // snap back on live updates. final boundary = latestAlignment(); - return itemPositionsListener.itemPositions.value.any( + return (positions ?? itemPositionsListener.itemPositions.value).any( (position) => position.index == 0 && (position.itemLeadingEdge - boundary).abs() < 0.01, @@ -388,8 +390,10 @@ class _MessageList extends HookConsumerWidget { composerBottomInset, ); final shouldShow = - !latestIsAtBoundary() && - (!latestIsVisible || distanceFromLatest.value > visiblePageHeight); + !latestIsAtBoundary(positions) && + (hasUnseenLatestEntry.value || + !latestIsVisible || + distanceFromLatest.value > visiblePageHeight); if (isJumpToLatestVisible.value != shouldShow) { isJumpToLatestVisible.value = shouldShow; } @@ -432,6 +436,7 @@ class _MessageList extends HookConsumerWidget { isUnreadNavigationDismissed.value = true; } if (nextIsAtLatest) { + hasUnseenLatestEntry.value = false; if (!isAtLatest.value) isAtLatest.value = true; if (isJumpToLatestVisible.value) { isJumpToLatestVisible.value = false; @@ -552,12 +557,25 @@ class _MessageList extends HookConsumerWidget { previousLatestEntryId.value = latestEntryId; if (previous == null || latestEntryId == null || - previous == latestEntryId || - !isAtLatest.value) { + previous == latestEntryId) { return null; } + if (!followsLatest.value || hasUserScrolled.value) { + hasUnseenLatestEntry.value = true; + } WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) scrollToLatest(); + if (!context.mounted) return; + if (followsLatest.value && !hasUserScrolled.value) { + scrollToLatest(); + return; + } + final positions = itemPositionsListener.itemPositions.value; + if (positions.isNotEmpty) { + if (latestIsAtBoundary(positions)) { + hasUnseenLatestEntry.value = false; + } + updateJumpToLatestVisibility(positions); + } }); return null; }, [latestEntryId]); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index d16c22ee288..127b75badbb 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2823,7 +2823,7 @@ void main() { await tester.pumpAndSettle(); final messageList = find.byKey(const ValueKey('channel-message-list')); - await tester.drag(messageList, const Offset(0, 120)); + await tester.drag(messageList, const Offset(0, 60)); await tester.pumpAndSettle(); expect(findRichText('Newest message line 0'), findsOneWidget); @@ -2843,13 +2843,22 @@ void main() { ]); await tester.pumpAndSettle(); - // Cache-extent mounting varies by platform, so assert the reversed - // list's semantic boundary rather than whether item 0 is mounted. + // Keep the inserted row visible so this exercises the settled + // reversed-list boundary instead of relying on offscreen detection. final positions = tester .widget(messageList) .itemPositionsNotifier! .itemPositions .value; + expect( + positions.any( + (position) => + position.index == 0 && + position.itemLeadingEdge < 1 && + position.itemTrailingEdge > 0, + ), + isTrue, + ); expect( positions.any( (position) => From e0f5e5dd0ddddccd3edef3e758c7df20e04e343f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 16 Aug 2026 14:14:47 +0100 Subject: [PATCH 7/7] fix(mobile): address timeline review feedback Signed-off-by: kenny lopez --- .../channel_detail_page/message_list.dart | 1 + .../channels/jump_to_latest_button.dart | 5 ++-- .../features/channels/sticky_date_header.dart | 5 ++-- .../channels/jump_to_latest_button_test.dart | 17 ++++++++----- .../channels/sticky_date_header_test.dart | 25 ++++++++++++------- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index c411f4760b5..59e4c746fb8 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -516,6 +516,7 @@ class _MessageList extends HookConsumerWidget { entries.length, itemPositionsListener, appBarTitleContentHeight, + composerBottomInset, ], ); diff --git a/mobile/lib/features/channels/jump_to_latest_button.dart b/mobile/lib/features/channels/jump_to_latest_button.dart index 314fb624ef9..cb6b703a353 100644 --- a/mobile/lib/features/channels/jump_to_latest_button.dart +++ b/mobile/lib/features/channels/jump_to_latest_button.dart @@ -6,12 +6,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; /// Compact conversation control that returns a detached timeline to its tail. -class JumpToLatestButton extends HookWidget { +class JumpToLatestButton extends HookConsumerWidget { final VoidCallback onPressed; const JumpToLatestButton({required this.onPressed, super.key}); @@ -19,7 +20,7 @@ class JumpToLatestButton extends HookWidget { static const _iosViewType = 'buzz/jump_to_latest_glass'; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final nativeChannel = useState(null); final onPressedRef = useRef(onPressed)..value = onPressed; final brightness = context.theme.brightness.name; diff --git a/mobile/lib/features/channels/sticky_date_header.dart b/mobile/lib/features/channels/sticky_date_header.dart index cad7f452b97..f6d17d2b9b2 100644 --- a/mobile/lib/features/channels/sticky_date_header.dart +++ b/mobile/lib/features/channels/sticky_date_header.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; @@ -154,7 +155,7 @@ class StickyDateHeader extends StatelessWidget { } } -class _IosStickyDateGlass extends HookWidget { +class _IosStickyDateGlass extends HookConsumerWidget { final String label; final double width; final double height; @@ -166,7 +167,7 @@ class _IosStickyDateGlass extends HookWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final nativeChannel = useState(null); final brightness = context.theme.brightness.name; diff --git a/mobile/test/features/channels/jump_to_latest_button_test.dart b/mobile/test/features/channels/jump_to_latest_button_test.dart index b934f83aa2b..26b50455859 100644 --- a/mobile/test/features/channels/jump_to_latest_button_test.dart +++ b/mobile/test/features/channels/jump_to_latest_button_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { testWidgets('keeps the native iOS glass in sync with the app theme', ( @@ -20,9 +21,11 @@ void main() { }); try { await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + ), ), ); @@ -47,9 +50,11 @@ void main() { methodCalls.clear(); await tester.pumpWidget( - MaterialApp( - theme: AppTheme.dark(), - home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + ProviderScope( + child: MaterialApp( + theme: AppTheme.dark(), + home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + ), ), ); await tester.pumpAndSettle(); diff --git a/mobile/test/features/channels/sticky_date_header_test.dart b/mobile/test/features/channels/sticky_date_header_test.dart index c6c012ba0c6..5f89ebb8106 100644 --- a/mobile/test/features/channels/sticky_date_header_test.dart +++ b/mobile/test/features/channels/sticky_date_header_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { testWidgets('updates the native iOS glass date and app theme', ( @@ -23,9 +24,11 @@ void main() { }); try { await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - home: Scaffold(body: StickyDateHeader(state: state)), + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), ), ); @@ -65,9 +68,11 @@ void main() { methodCalls.clear(); await tester.pumpWidget( - MaterialApp( - theme: AppTheme.dark(), - home: Scaffold(body: StickyDateHeader(state: state)), + ProviderScope( + child: MaterialApp( + theme: AppTheme.dark(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), ), ); await tester.pumpAndSettle(); @@ -93,9 +98,11 @@ void main() { final state = ValueNotifier(const StickyDateHeaderState(label: 'Today')); try { await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - home: Scaffold(body: StickyDateHeader(state: state)), + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), ), );