From d2da104d947e07dd0c203dabd92f245eeca6b672 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 4 Oct 2026 14:11:39 +0100 Subject: [PATCH] feat(mobile): add animated community switcher (#8076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replace the community switch sheet with a two-column picker. Selection enlarges the avatar, waits with the voice-note loader for destination channel content and layout to settle, then follows a curved path into the header. Cache raster icons at a maximum 512-pixel size and accept only resource-free emoji SVGs. Cancel timed-out or disposed downloads without delaying content readiness. Animate Edit badges in place, and open Add Community on the pairing welcome page. Community removal lives only in the switcher’s Edit mode. Keep the flow shared across Android/iOS, with native iOS controls and removal confirmation, and clear navigation backgrounds at rest. ### Related issue Related to #8070 (community icon refresh/caching). ### Testing - 122 focused Flutter regression tests and seven native iOS tests passed after merging the latest navigation changes from `main`. Earlier feature build installed and launched on a physical iPhone. - Full mobile pre-push checks passed; regression coverage includes gradient rendering, rejected/oversized icons, bounded persisted raster decoding, SVG resource rejection and cache migration, timeout/disposal cancellation, disconnected destination channels, degraded unread-history settlement, and switching while icons refresh. Full `just ci` stopped during Rust checks for low disk space. Hosted CI remains required. - [Grid and Edit previews](https://github.com/block/buzz/pull/8076#issuecomment-5977838957). --------- Signed-off-by: kenny lopez --- mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 8 + mobile/ios/Runner/IosNavigationBar.swift | 47 + .../ios/Runner/NativeConfirmationDialog.swift | 77 ++ mobile/ios/RunnerTests/RunnerTests.swift | 120 +- .../channel_mutes/channel_mutes_provider.dart | 5 +- .../channel_sections_provider.dart | 5 +- .../channel_sort/channel_sort_provider.dart | 5 +- .../channel_stars/channel_stars_provider.dart | 5 +- .../lib/features/channels/channels_page.dart | 130 ++- .../features/channels/channels_page/body.dart | 3 + .../channels/channels_page/community.dart | 568 ++------- .../channels_page/community_switcher.dart | 650 +++++++++++ .../community_switcher_action.dart | 166 +++ .../features/channels/channels_provider.dart | 107 +- .../channels/channels_provider_lifecycle.dart | 6 +- mobile/lib/features/pairing/pairing_page.dart | 22 +- .../theme_picker_page/theme_home_preview.dart | 2 +- .../community/community_icon_artwork.dart | 81 ++ .../community/community_icon_cache.dart | 72 ++ .../community/community_icon_provider.dart | 147 ++- .../shared/widgets/confirmation_dialog.dart | 49 + .../lib/shared/widgets/frosted_app_bar.dart | 24 +- .../shared/widgets/ios_navigation_bar.dart | 104 +- .../lib/shared/widgets/skeleton_reveal.dart | 22 + .../channel_reader_recovery_test.dart | 75 ++ .../features/channels/channels_page_test.dart | 1019 +++++++++++++++-- .../unread_readiness_tests.dart | 154 +++ .../channels_provider_readiness_cases.dart | 177 +++ .../channels/channels_provider_test.dart | 129 ++- .../channels/message_content_test.dart | 7 + .../features/pairing/pairing_page_test.dart | 11 +- .../community_icons/compressed-16000.png | Bin 0 -> 248907 bytes .../community_icons/compressed-8192.png | Bin 0 -> 65303 bytes .../community_icons/landscape-1024.png | Bin 0 -> 588 bytes .../community_icon_provider_test.dart | 592 ++++++++-- .../shared/widgets/frosted_scaffold_test.dart | 68 +- .../widgets/ios_navigation_bar_test.dart | 221 ++++ mobile/test/shared/widgets/skeleton_test.dart | 24 + 39 files changed, 4134 insertions(+), 772 deletions(-) create mode 100644 mobile/ios/Runner/NativeConfirmationDialog.swift create mode 100644 mobile/lib/features/channels/channels_page/community_switcher.dart create mode 100644 mobile/lib/features/channels/channels_page/community_switcher_action.dart create mode 100644 mobile/lib/shared/community/community_icon_artwork.dart create mode 100644 mobile/lib/shared/community/community_icon_cache.dart create mode 100644 mobile/lib/shared/widgets/confirmation_dialog.dart create mode 100644 mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart create mode 100644 mobile/test/features/channels/channels_provider_readiness_cases.dart create mode 100644 mobile/test/fixtures/community_icons/compressed-16000.png create mode 100644 mobile/test/fixtures/community_icons/compressed-8192.png create mode 100644 mobile/test/fixtures/community_icons/landscape-1024.png diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index c2ae0477aa..6079bca445 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; }; BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; }; BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; }; @@ -88,6 +89,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = ""; }; BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; @@ -277,6 +279,7 @@ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */, 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */, 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */, + C0AF00000000000000000002 /* NativeConfirmationDialog.swift */, 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */, @@ -609,6 +612,7 @@ 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */, 4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */, 4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */, + C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */, 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 43ba6a8e73..1ab8cea7b0 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -41,6 +41,7 @@ import os.log private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator? + private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator? private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator? private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? private var huddleMediaPlugin: HuddleMediaPlugin? @@ -243,6 +244,13 @@ import os.log parentViewController: nativeEmojiPickerRegistrar?.viewController ) + nativeConfirmationDialogCoordinator = NativeConfirmationDialogCoordinator( + messenger: messenger, + parentViewController: engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeConfirmationDialog" + )?.viewController + ) + let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeProfileTextEditor" ) diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 6d603b2461..068d83b77f 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -208,6 +208,8 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { private var measuredCategory: UIContentSizeCategory? private var measuring = false private var metrics: [String: Any]? + private weak var trackedAvatar: UIButton? + private var reportedAvatarFrame: CGRect? init(frame: CGRect, id: Int64, args: Any?, messenger: FlutterBinaryMessenger, parent: UIViewController?) { container = NavigationClipView(frame: frame) @@ -234,6 +236,11 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { channel.setMethodCallHandler { [weak self] call, result in switch call.method { case "configure": self?.configure(call.arguments as? [String: Any] ?? [:]) + case "prepareForReveal": + UIView.performWithoutAnimation { + self?.container.setNeedsLayout() + self?.container.layoutIfNeeded() + } case "scroll": self?.setScrollOffset((call.arguments as? NSNumber)?.doubleValue ?? 0) default: result(FlutterMethodNotImplemented); return @@ -259,6 +266,22 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { navigation.view.layoutIfNeeded() measureIfNeeded() applyScroll() + reportAvatarBounds() + } + + private func reportAvatarBounds() { + guard let avatar = trackedAvatar, avatar.window != nil, avatar.bounds.width > 0 else { return } + avatar.layoutIfNeeded() + guard let image = avatar.imageView, image.bounds.width > 0 else { return } + let frame = image.convert(image.bounds, to: container) + guard frame != reportedAvatarFrame else { return } + reportedAvatarFrame = frame + DispatchQueue.main.async { [weak self] in + self?.channel.invokeMethod("avatarBounds", arguments: [ + "id": "leading", "x": frame.minX, "y": frame.minY, + "width": frame.width, "height": frame.height + ]) + } } private func configure(_ args: [String: Any]) { @@ -326,6 +349,8 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { item.titleView = nil } item.largeTitleDisplayMode = bar.prefersLargeTitles ? .always : .never + trackedAvatar = nil + reportedAvatarFrame = nil if let leading = args["leading"] as? [String: Any] { item.leftBarButtonItems = [makeItem(leading)] } else if args["back"] as? Bool == true { @@ -378,6 +403,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { offset = max(0, value) material.alpha = min(1, offset / 12) applyScroll() + reportAvatarBounds() } private func applyScroll() { @@ -463,6 +489,27 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { }.withRenderingMode(.alwaysOriginal) item.accessibilityValue = data["activityLabel"] as? String } + if data["tracksAvatarBounds"] as? Bool == true, data["id"] as? String == "leading", + data["avatarInitial"] is String { + // An explicit native button gives Flutter a public, measured destination + // without depending on UINavigationBar's private view hierarchy. + let button = UIButton(type: .custom) + // UIKit adds the glass button's own padding around this custom view. + // Match the 36-point image on both axes so that glass stays circular. + button.frame = CGRect(x: 0, y: 0, width: 36, height: 36) + button.widthAnchor.constraint(equalToConstant: 36).isActive = true + button.heightAnchor.constraint(equalToConstant: 36).isActive = true + button.setImage(item.image?.withAlignmentRectInsets(.zero), for: .normal) + button.addAction(action, for: .touchUpInside) + button.isEnabled = item.isEnabled + button.accessibilityLabel = item.accessibilityLabel + button.accessibilityIdentifier = "community-navigation-avatar" + let hidden = data["avatarHidden"] as? Bool == true + button.alpha = hidden ? 0 : 1 + button.accessibilityElementsHidden = hidden + trackedAvatar = button + return UIBarButtonItem(customView: button) + } return item } diff --git a/mobile/ios/Runner/NativeConfirmationDialog.swift b/mobile/ios/Runner/NativeConfirmationDialog.swift new file mode 100644 index 0000000000..f8365b9013 --- /dev/null +++ b/mobile/ios/Runner/NativeConfirmationDialog.swift @@ -0,0 +1,77 @@ +import Flutter +import UIKit + +/// Owns a destructive UIKit alert until dismissal and replies exactly once. +final class NativeConfirmationDialogCoordinator: NSObject { + private let channel: FlutterMethodChannel + private weak var parent: UIViewController? + private weak var alert: UIAlertController? + private var pendingResult: FlutterResult? + private var isFinishing = false + + init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) { + channel = FlutterMethodChannel(name: "buzz/confirmation_dialog", binaryMessenger: messenger) + parent = parentViewController + super.init() + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { result(false); return } + self.handle(call, result: result) + } + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard call.method == "present" else { result(FlutterMethodNotImplemented); return } + guard let data = call.arguments as? [String: Any], + let title = data["title"] as? String, + let message = data["message"] as? String, + let confirmLabel = data["confirmLabel"] as? String, + let cancelLabel = data["cancelLabel"] as? String else { + result(FlutterError(code: "invalid_arguments", message: "Expected confirmation labels.", details: nil)) + return + } + // Repeated taps must not create stacked alerts or multiple removals. + guard pendingResult == nil else { result(false); return } + var presenter = parent ?? UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows).first(where: \.isKeyWindow)?.rootViewController + while let presented = presenter?.presentedViewController { presenter = presented } + guard let presenter, presenter.view.window != nil, + !presenter.isBeingDismissed, !(presenter is UIAlertController) else { + result(FlutterError(code: "presentation_failed", message: "Cannot present confirmation right now.", details: nil)) + return + } + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light + alert.addAction(UIAlertAction(title: cancelLabel, style: .cancel) { [weak self] _ in + self?.cancel() + }) + alert.addAction(UIAlertAction(title: confirmLabel, style: .destructive) { [weak self] _ in + self?.finish(true) + }) + pendingResult = result + self.alert = alert + presenter.present(alert, animated: !UIAccessibility.isReduceMotionEnabled) + } + + /// Dismisses an outstanding confirmation without authorizing the action. + func cancel() { finish(false) } + + private func finish(_ confirmed: Bool) { + guard pendingResult != nil, !isFinishing else { return } + isFinishing = true + guard let alert else { resolve(confirmed); return } + // Finish native dismissal before Flutter removes the underlying route. + alert.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in + self?.resolve(confirmed) + } + } + + private func resolve(_ confirmed: Bool) { + let result = pendingResult + pendingResult = nil + isFinishing = false + alert = nil + result?(confirmed) + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index bed82a686e..4c34d115ad 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -8,6 +8,106 @@ import XCTest class RunnerTests: XCTestCase { + @MainActor + func testNativeRemovalConfirmationUsesDestructiveAlertAndCancelsOnce() async throws { + let messenger = NavigationTestMessenger() + let parent = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + window.rootViewController = parent + parent.view.backgroundColor = .systemBackground + window.makeKeyAndVisible() + defer { window.isHidden = true } + let coordinator = NativeConfirmationDialogCoordinator(messenger: messenger, parentViewController: parent) + let arguments: [String: Any] = ["title": "Remove community?", + "message": "Are you sure you want to remove “Alpha”? You can pair with it again later.", + "confirmLabel": "Remove", "cancelLabel": "Cancel", "dark": false] + var replies: [Bool] = [] + let completed = expectation(description: "Native confirmation dismissed") + messenger.invoke("present", arguments: arguments) { + if let value = $0 as? Bool { replies.append(value); completed.fulfill() } + } + let alert = try XCTUnwrap(parent.presentedViewController as? UIAlertController) + if let transition = alert.transitionCoordinator { + await withCheckedContinuation { continuation in + transition.animate(alongsideTransition: nil) { _ in continuation.resume() } + } + } + XCTAssertEqual(alert.preferredStyle, .alert) + XCTAssertEqual(alert.title, "Remove community?") + XCTAssertEqual(alert.message, arguments["message"] as? String) + XCTAssertEqual(alert.actions.map(\.title), ["Cancel", "Remove"]) + XCTAssertEqual(alert.actions.map(\.style), [.cancel, .destructive]) + XCTAssertEqual(alert.overrideUserInterfaceStyle, .light) + XCTAssertTrue(replies.isEmpty) + var duplicate: Bool? + messenger.invoke("present", arguments: arguments) { duplicate = $0 as? Bool } + XCTAssertEqual(duplicate, false) + XCTAssertTrue(parent.presentedViewController === alert) + let capture = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let attachment = XCTAttachment(image: capture) + attachment.name = "Native remove community alert" + attachment.lifetime = .keepAlways + add(attachment) + coordinator.cancel() + coordinator.cancel() + await fulfillment(of: [completed], timeout: 3) + XCTAssertNil(parent.presentedViewController) + XCTAssertEqual(replies, [false]) + } + + @MainActor + func testCommunityAvatarReportsItsVisibleFrameAndHidesWithoutMoving() async throws { + let messenger = NavigationTestMessenger() + let parent = UIViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 393, height: 852)) + window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + parent.view.backgroundColor = .systemBackground + window.rootViewController = parent + window.makeKeyAndVisible() + defer { window.isHidden = true } + let factory = IosNavigationBarFactory(messenger: messenger, parent: parent) + var leading: [String: Any] = ["id": "leading", "label": "Community settings", + "avatarInitial": "A", "enabled": true, "tracksAvatarBounds": true] + let bar = factory.create(withFrame: CGRect(x: 0, y: 0, width: 393, height: 160), + viewIdentifier: 999991, arguments: ["title": "Alpha", "largeTitle": true, "leading": leading, + "actions": [["id": "profile", "label": "Profile", "avatarInitial": "P", "enabled": true]]]) + parent.view.addSubview(bar.view()) + parent.view.layoutIfNeeded() + try await Task.sleep(nanoseconds: 200_000_000) + let navigation = try XCTUnwrap(parent.children.first as? UINavigationController) + func avatar() throws -> UIButton { + try XCTUnwrap(navigation.topViewController?.navigationItem.leftBarButtonItems?.first?.customView as? UIButton) + } + // A rectangular custom view makes UIKit's surrounding glass a pill. + let buttonBounds = try avatar().bounds + XCTAssertEqual(buttonBounds.width, buttonBounds.height) + XCTAssertEqual(buttonBounds.width, 36) + let visibleFrame = try XCTUnwrap(messenger.avatarBounds) + XCTAssertEqual((visibleFrame["width"] as? NSNumber)?.doubleValue, 36) + XCTAssertEqual((visibleFrame["height"] as? NSNumber)?.doubleValue, 36) + let image = try XCTUnwrap(avatar().imageView) + let imageFrame = image.convert(image.bounds, to: bar.view()) + XCTAssertEqual((visibleFrame["x"] as? NSNumber)?.doubleValue, imageFrame.minX) + XCTAssertEqual((visibleFrame["y"] as? NSNumber)?.doubleValue, imageFrame.minY) + try avatar().sendActions(for: .touchUpInside) + XCTAssertEqual(messenger.actions, ["leading"]) + leading["avatarHidden"] = true + messenger.configure(["title": "Bravo", "largeTitle": true, "leading": leading]) + var landingPrepared = false + messenger.invoke("prepareForReveal", arguments: [:]) { _ in landingPrepared = true } + XCTAssertTrue(landingPrepared) + XCTAssertFalse(bar.view().layer.needsLayout()) + XCTAssertEqual(try avatar().alpha, 0) + XCTAssertEqual((messenger.avatarBounds?["x"] as? NSNumber)?.doubleValue, imageFrame.minX) + XCTAssertEqual((messenger.avatarBounds?["y"] as? NSNumber)?.doubleValue, imageFrame.minY) + leading["avatarHidden"] = false + messenger.configure(["title": "Bravo", "largeTitle": true, "leading": leading]) + XCTAssertEqual(try avatar().alpha, 1) + } + @MainActor func testConversationTitleHasWidthBeforePlatformViewLayout() throws { let parent = UIViewController() @@ -244,7 +344,7 @@ class RunnerTests: XCTestCase { } @MainActor - func testCompactMaterialOnlyAppearsAfterScrolling() async throws { + func testCompactNavigationMaterialFollowsScrollDepth() async throws { let messenger = NavigationTestMessenger() let parent = UIViewController() let window = UIWindow(frame: UIScreen.main.bounds) @@ -260,8 +360,9 @@ class RunnerTests: XCTestCase { parent.view.addSubview(bar.view()) parent.view.layoutIfNeeded() let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView) - // At rest and during top-edge overscroll the bar is clear; positive - // scroll gradually introduces the backdrop. + XCTAssertEqual(material.alpha, 0, "Compact titles must not frost an unscrolled page") + // Scrolling, returning to rest, and pull-to-refresh all share the same + // treatment as large titles; reconfiguration must preserve that depth. for offset in [0.0, 6.0, 52.0, 12.0, 0.0, -20.0, 0.0] { messenger.scroll(to: offset) bar.view().setNeedsLayout() @@ -273,7 +374,10 @@ class RunnerTests: XCTestCase { XCTAssertEqual(locations.first?.doubleValue, 0) XCTAssertEqual(locations.last?.doubleValue, 1) XCTAssertLessThan(locations[1].doubleValue, 1, "Compact material needs a soft lower edge") - XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.01) + XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.001) + messenger.configure(["title": "general", "subtitle": "37 members"]) + XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.001, + "Updating a compact title must not restore blur at rest") XCTAssertEqual(material.frame, bar.view().bounds) if #available(iOS 26.0, *) { let navigation = try XCTUnwrap(parent.children.first as? UINavigationController) @@ -1637,11 +1741,13 @@ private actor NativeEmojiDownloadProbe { private final class NavigationTestMessenger: NSObject, FlutterBinaryMessenger { var metrics: [String: Any]? + var avatarBounds: [String: Any]? var actions: [String] = [] func send(onChannel channel: String, message: Data?) { guard let message else { return } let call = FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message) if call.method == "metrics" { metrics = call.arguments as? [String: Any] } + if call.method == "avatarBounds" { avatarBounds = call.arguments as? [String: Any] } if call.method == "action", let action = call.arguments as? String { actions.append(action) } } func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) { @@ -1662,6 +1768,12 @@ private final class NavigationTestMessenger: NSObject, FlutterBinaryMessenger { handler = nil } + func invoke(_ method: String, arguments: [String: Any], reply: @escaping (Any?) -> Void) { + let codec = FlutterStandardMethodCodec.sharedInstance() + let message = codec.encode(FlutterMethodCall(methodName: method, arguments: arguments)) + handler?(message) { data in reply(data.flatMap { codec.decodeEnvelope($0) }) } + } + func configure(_ arguments: [String: Any]) { let message = FlutterStandardMethodCodec.sharedInstance().encode( FlutterMethodCall(methodName: "configure", arguments: arguments) diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart index f58eabcacb..6d52c95f84 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart @@ -24,11 +24,13 @@ class ChannelMutesState { class ChannelMutesNotifier extends Notifier { ChannelMutesManager? _manager; + bool _isInitialized = false; @override ChannelMutesState build() { _manager?.dispose(flushPending: false); _manager = null; + _isInitialized = false; final relayConfig = ref.watch(relayConfigProvider); final sessionState = ref.watch(relaySessionProvider); @@ -85,6 +87,7 @@ class ChannelMutesNotifier extends Notifier { Future.microtask(() async { await manager.initialize(); if (_manager != manager) return; + _isInitialized = true; _emitManagerState(manager); }); @@ -98,7 +101,7 @@ class ChannelMutesNotifier extends Notifier { void _emitManagerState(ChannelMutesManager manager) { if (_manager != manager) return; state = ChannelMutesState( - isReady: true, + isReady: _isInitialized, store: manager.store, version: state.version + 1, ); diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index 6466e6a4d4..31d424cc95 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -24,11 +24,13 @@ class ChannelSectionsState { class ChannelSectionsNotifier extends Notifier { ChannelSectionsManager? _manager; + bool _isInitialized = false; @override ChannelSectionsState build() { _manager?.dispose(flushPending: false); _manager = null; + _isInitialized = false; final relayConfig = ref.watch(relayConfigProvider); final sessionState = ref.watch(relaySessionProvider); @@ -85,6 +87,7 @@ class ChannelSectionsNotifier extends Notifier { Future.microtask(() async { await manager.initialize(); if (_manager != manager) return; + _isInitialized = true; _emitManagerState(manager); }); @@ -116,7 +119,7 @@ class ChannelSectionsNotifier extends Notifier { void _emitManagerState(ChannelSectionsManager manager) { if (_manager != manager) return; state = ChannelSectionsState( - isReady: true, + isReady: _isInitialized, store: manager.store, version: state.version + 1, ); diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart index 4fdbbe00f3..afc0fd492c 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart @@ -27,11 +27,13 @@ class ChannelSortState { class ChannelSortNotifier extends Notifier { ChannelSortManager? _manager; + bool _isInitialized = false; @override ChannelSortState build() { _manager?.dispose(); _manager = null; + _isInitialized = false; final relayConfig = ref.watch(relayConfigProvider); final sessionState = ref.watch(relaySessionProvider); @@ -93,6 +95,7 @@ class ChannelSortNotifier extends Notifier { Future.microtask(() async { await manager.initialize(); if (_manager != manager) return; + _isInitialized = true; _emitManagerState(manager); }); @@ -109,7 +112,7 @@ class ChannelSortNotifier extends Notifier { void _emitManagerState(ChannelSortManager manager) { if (_manager != manager) return; state = ChannelSortState( - isReady: true, + isReady: _isInitialized, store: manager.store, version: state.version + 1, ); diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart index a341417837..9bac36d7b3 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart @@ -24,11 +24,13 @@ class ChannelStarsState { class ChannelStarsNotifier extends Notifier { ChannelStarsManager? _manager; + bool _isInitialized = false; @override ChannelStarsState build() { _manager?.dispose(flushPending: false); _manager = null; + _isInitialized = false; final relayConfig = ref.watch(relayConfigProvider); final sessionState = ref.watch(relaySessionProvider); @@ -85,6 +87,7 @@ class ChannelStarsNotifier extends Notifier { Future.microtask(() async { await manager.initialize(); if (_manager != manager) return; + _isInitialized = true; _emitManagerState(manager); }); @@ -98,7 +101,7 @@ class ChannelStarsNotifier extends Notifier { void _emitManagerState(ChannelStarsManager manager) { if (_manager != manager) return; state = ChannelStarsState( - isReady: true, + isReady: _isInitialized, store: manager.store, version: state.version + 1, ); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c87a6663a4..10a2268175 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -23,8 +23,10 @@ import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/ios_navigation_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/confirmation_dialog.dart'; import '../../shared/widgets/skeleton.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; @@ -63,6 +65,8 @@ part 'channels_page/sheets.dart'; part 'channels_page/badges.dart'; part 'channels_page/skeleton.dart'; part 'channels_page/community.dart'; +part 'channels_page/community_switcher.dart'; +part 'channels_page/community_switcher_action.dart'; part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions_launcher.dart'; @@ -188,6 +192,10 @@ class ChannelsPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Resolve permissions before the community menu is opened. + if (communityInvitePageBuilder != null) { + ref.watch(currentCommunityRoleProvider); + } final channelsAsync = ref.watch(channelsProvider); final sessionState = ref.watch(relaySessionProvider); final currentPubkey = ref @@ -205,6 +213,12 @@ class ChannelsPage extends HookConsumerWidget { bottomHeight: _kTopSectionBottomPadding, nativeLargeTitle: true, ); + final communityAvatarKey = useMemoized(GlobalKey.new); + final nativeCommunityAvatarBounds = useRef(null); + final headerKey = useMemoized(GlobalKey.new); + final nativeHeaderReady = useRef(false); + final bodyReady = useRef(false); + final communityFlightActive = useState(false); final channelsScrollController = useScrollController(); final reducedMotion = MediaQuery.disableAnimationsOf(context); final headerFrostProgress = useState(0.0); @@ -314,14 +328,98 @@ class ChannelsPage extends HookConsumerWidget { return timer.cancel; }, [isReconnectingWithContent]); - void openCommunitySwitcher() { - unawaited(HapticFeedback.selectionClick()); + Rect? measureCommunityAvatar() { + if (defaultTargetPlatform == TargetPlatform.iOS) { + final header = headerKey.currentContext?.findRenderObject(); + final bounds = nativeCommunityAvatarBounds.value; + return header is RenderBox && bounds != null + ? MatrixUtils.transformRect(header.getTransformTo(null), bounds) + : null; + } + final avatar = communityAvatarKey.currentContext?.findRenderObject(); + return avatar is RenderBox + ? MatrixUtils.transformRect( + avatar.getTransformTo(null), + Offset.zero & avatar.size, + ) + : null; + } + + Future prepareCommunityLanding() async { + // The list snapshot precedes unread history and DM profile hydration. + // Both can still change weight, labels, and sorting behind the picker. + final channels = ref.read(channelsProvider).requireValue; + final dmPubkeys = { + for (final channel in channels) + if (channel.isMember && !channel.isArchived && channel.isDm) + ...channel.participantPubkeys, + }; + await Future.wait([ + ref.read(channelsProvider.notifier).waitForUnreadCatchUp(), + if (dmPubkeys.isNotEmpty) + ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()), + ]); + if (!context.mounted || !communityFlightActive.value) return null; + // Settle Home's scale and scroll while the loading backdrop is opaque. + onSettingsTransitionProgress(0); + if (channelsScrollController.hasClients) { + channelsScrollController.jumpTo( + channelsScrollController.position.minScrollExtent, + ); + } + Rect? previous; + var stableFrames = 0; + while (context.mounted && communityFlightActive.value) { + await WidgetsBinding.instance.endOfFrame; + if (!context.mounted) return null; + final ready = + ref.read(_communityContentReadyProvider).value == true && + bodyReady.value && + (defaultTargetPlatform != TargetPlatform.iOS || + nativeHeaderReady.value); + final bounds = ready ? measureCommunityAvatar() : null; + stableFrames = ready && bounds == previous ? stableFrames + 1 : 0; + previous = bounds; + if (ready && stableFrames >= 2) return bounds; + } + return null; + } + + void openCommunityGrid() { + if (!context.mounted) return; ref.invalidate(communityIconProvider); + final destination = measureCommunityAvatar(); + late final _CommunitySwitcherRoute route; + route = _CommunitySwitcherRoute( + onTransitionProgress: onSettingsTransitionProgress, + builder: (_) => _CommunitySwitcherPage( + destination: destination, + prepareLanding: prepareCommunityLanding, + onFlightChanged: (flying) { + route.flying = flying; + communityFlightActive.value = flying; + }, + onTransitionProgress: onSettingsTransitionProgress, + ), + ); + Navigator.of(context).push(route).whenComplete(() { + if (context.mounted) communityFlightActive.value = false; + }); + } + + void openCommunitySwitcher() { + // Freeze the menu shape for this presentation. If a cold permission + // lookup is still pending, the next opening uses its resolved result. + final role = ref.read(currentCommunityRoleProvider).unwrapPrevious(); + final canInvite = role.hasError || canManageCommunityInvites(role.value); + unawaited(HapticFeedback.selectionClick()); showBuzzModalBottomSheet( context: context, showCloseButton: false, showDragHandle: true, - builder: (_) => _CommunitySwitcherSheet( + builder: (_) => _CommunityMenuSheet( + canInvite: canInvite, + onSwitchCommunity: openCommunityGrid, invitePageBuilder: communityInvitePageBuilder, appearancePageBuilder: communityAppearancePageBuilder, ), @@ -335,10 +433,7 @@ class ChannelsPage extends HookConsumerWidget { final communityRelay = activeCommunity?.relayUrl; final communityAvatar = communityRelay == null ? null - : ref - .watch(communityIconProvider(communityRelay)) - .unwrapPrevious() - .value; + : ref.watch(communityIconPresentationProvider(communityRelay)); final profile = ref.watch(profileProvider).unwrapPrevious().value; final communityName = activeCommunity?.name.trim() ?? ''; final topSectionGradient = context.appColors.topSectionGradient; @@ -350,10 +445,22 @@ class ChannelsPage extends HookConsumerWidget { : context.colors.surface, backgroundGradient: topSectionGradient, appBar: FrostedAppBar( + key: headerKey, + onNativeReadyChanged: (ready) => nativeHeaderReady.value = ready, nativeTitle: communityName.isEmpty ? 'Community' : communityName, nativeLargeTitle: true, nativeLeading: IosNavigationAction( - label: 'Switch community', + label: 'Community settings', + onAvatarBoundsChanged: (bounds) { + final header = headerKey.currentContext?.findRenderObject(); + if (header is RenderBox) { + // Store native coordinates, then apply the current Home transform + // when measuring the destination immediately before departure. + nativeCommunityAvatarBounds.value = + header.globalToLocal(bounds.topLeft) & bounds.size; + } + }, + avatarHidden: communityFlightActive.value, avatarIdentity: activeCommunity?.id, symbol: 'building.2.crop.circle', imageUrl: communityAvatar, @@ -389,7 +496,11 @@ class ChannelsPage extends HookConsumerWidget { ? _kHeaderFrostMaxBlurSigma * headerFrostProgress.value : 20, showBottomDivider: false, - leading: _CommunityIndicator(onTap: openCommunitySwitcher), + leading: _CommunityIndicator( + onTap: openCommunitySwitcher, + avatarKey: communityAvatarKey, + hidden: communityFlightActive.value, + ), centerTitle: false, titleStyle: headerTitleStyle, title: _CommunityHeaderTitle( @@ -420,6 +531,7 @@ class ChannelsPage extends HookConsumerWidget { bottom: const SizedBox.expand(), ), body: _ChannelsBody( + onReadyChanged: (ready) => bodyReady.value = ready, channels: channels, channelsAsync: channelsAsync, showError: showError.value, diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 33364e2b21..835b4da20f 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -6,6 +6,7 @@ class _ChannelsBody extends StatelessWidget { final bool showError; final SessionStatus sessionStatus; final bool showConnectionSkeleton; + final ValueChanged onReadyChanged; final String? currentPubkey; final double topSectionHeight; final bool usesPinnedGradient; @@ -19,6 +20,7 @@ class _ChannelsBody extends StatelessWidget { required this.showError, required this.sessionStatus, required this.showConnectionSkeleton, + required this.onReadyChanged, required this.currentPubkey, required this.topSectionHeight, required this.usesPinnedGradient, @@ -76,6 +78,7 @@ class _ChannelsBody extends StatelessWidget { ); return SkeletonReveal( + onReadyChanged: onReadyChanged, loading: loading, shimmerEnabled: sessionStatus != SessionStatus.disconnected, skeleton: _ChannelsSkeleton( diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index 85c708ee3d..3e966a62d4 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -1,465 +1,72 @@ part of '../channels_page.dart'; -class _CommunitySwitcherSheet extends HookConsumerWidget { - const _CommunitySwitcherSheet({ +class _CommunityMenuSheet extends StatelessWidget { + const _CommunityMenuSheet({ + required this.canInvite, + required this.onSwitchCommunity, this.invitePageBuilder, this.appearancePageBuilder, }); + final bool canInvite; + final VoidCallback onSwitchCommunity; final WidgetBuilder? invitePageBuilder; final WidgetBuilder? appearancePageBuilder; @override - Widget build(BuildContext context, WidgetRef ref) { - final communitiesAsync = ref.watch(communityListProvider); - final activeAsync = ref.watch(activeCommunityProvider); - final isEditing = useState(false); - final role = ref.watch(currentCommunityRoleProvider); - final canInvite = role.hasError || canManageCommunityInvites(role.value); - + Widget build(BuildContext context) { void openPage(WidgetBuilder builder) { final navigator = Navigator.of(context, rootNavigator: true); Navigator.of(context).pop(); navigator.push(MaterialPageRoute(builder: builder)); } + Future openSwitcher() async { + final route = ModalRoute.of(context); + Navigator.of(context).pop(); + // Let the menu finish dismissing before the grid starts its entrance. + if (route != null) await route.completed; + onSwitchCommunity(); + } + return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, + child: SingleChildScrollView( key: const Key('community-switcher-sheet'), - children: [ - const SizedBox(height: Grid.xs), - Flexible( - child: communitiesAsync.when( - loading: () => const SizedBox( - height: 120, - child: Center( - child: BuzzLoadingIndicator( - size: 40, - semanticLabel: 'Loading communities', - ), - ), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(Grid.xs), - child: Text('Error loading communities: $e'), - ), - data: (communities) { - final activeId = activeAsync.value?.id; - final activeCommunity = communities - .where((community) => community.id == activeId) - .firstOrNull; - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: Grid.xs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (activeCommunity != null || - appearancePageBuilder != null || - (canInvite && invitePageBuilder != null)) - AppListCard( - label: 'Community settings', - children: [ - if (canInvite && invitePageBuilder != null) - AppListRow( - icon: LucideIcons.userPlus, - title: 'Invite', - trailing: const Icon( - LucideIcons.chevronRight, - size: 18, - ), - onTap: () => openPage(invitePageBuilder!), - ), - if (appearancePageBuilder != null) - AppListRow( - icon: LucideIcons.sunMoon, - title: 'Appearance', - trailing: const Icon( - LucideIcons.chevronRight, - size: 18, - ), - onTap: () => openPage(appearancePageBuilder!), - ), - if (activeCommunity != null) - AppListRow( - icon: LucideIcons.circleMinus, - title: 'Remove community', - titleColor: context.colors.error, - onTap: () => _confirmRemoveCommunity( - context, - ref, - activeCommunity, - closeSheetAfterRemoval: true, - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Switch communities', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - SizedBox( - key: const Key('community-switcher-edit'), - width: 56, - height: 44, - child: TextButton( - onPressed: () => - isEditing.value = !isEditing.value, - style: TextButton.styleFrom( - minimumSize: const Size(56, 44), - padding: EdgeInsets.zero, - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - child: Text(isEditing.value ? 'Done' : 'Edit'), - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - ), - child: Material( - key: const Key('community-switcher-options'), - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.card), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - for ( - var index = 0; - index < communities.length; - index++ - ) ...[ - if (index > 0) - const _CommunitySwitcherDivider(), - _CommunitySwitcherTile( - community: communities[index], - isActive: communities[index].id == activeId, - isEditing: isEditing.value, - onTap: isEditing.value - ? null - : () async { - final community = communities[index]; - if (community.id != activeId) { - await ref - .read( - communityListProvider - .notifier, - ) - .switchCommunity(community.id); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - onRemove: () => _confirmRemoveCommunity( - context, - ref, - communities[index], - closeSheetAfterRemoval: - communities[index].id == activeId, - ), - ), - ], - if (communities.isNotEmpty) - const _CommunitySwitcherDivider(), - _AddCommunityTile( - onTap: () { - final nav = Navigator.of( - context, - rootNavigator: true, - ); - ref.read(pairingProvider.notifier).reset(); - Navigator.of(context).pop(); - nav.push( - MaterialPageRoute( - builder: (_) => const PairingPage( - addingCommunity: true, - ), - ), - ); - }, - ), - ], - ), - ), - ), - ], - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -const double _communitySwitcherAvatarSize = 36; - -class _CommunitySwitcherDivider extends StatelessWidget { - const _CommunitySwitcherDivider(); - - @override - Widget build(BuildContext context) { - return Divider( - height: 1, - thickness: 1, - indent: Grid.xs + _communitySwitcherAvatarSize + Grid.xs, - endIndent: 0, - color: context.colors.onSurface.withValues(alpha: 0.12), - ); - } -} - -class _CommunitySwitcherTile extends StatelessWidget { - final Community community; - final bool isActive; - final bool isEditing; - final VoidCallback? onTap; - final VoidCallback onRemove; - - const _CommunitySwitcherTile({ - required this.community, - required this.isActive, - required this.isEditing, - required this.onTap, - required this.onRemove, - }); - - @override - Widget build(BuildContext context) { - final host = Uri.tryParse(community.relayUrl)?.host ?? community.relayUrl; - - return InkWell( - key: Key('community-switcher-row-${community.id}'), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.xs, - Grid.twelve, - Grid.xxs, - Grid.twelve, - ), - child: Row( + padding: const EdgeInsets.symmetric(vertical: Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - _CommunityAvatar( - key: Key('community-switcher-avatar-${community.id}'), - name: community.name, - relayUrl: community.relayUrl, - size: _communitySwitcherAvatarSize, - ), - const SizedBox(width: Grid.xs), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + if (appearancePageBuilder != null || + (canInvite && invitePageBuilder != null)) + AppListCard( + label: 'Community settings', children: [ - Text( - community.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: isActive - ? FontWeight.w600 - : FontWeight.normal, + if (canInvite && invitePageBuilder != null) + AppListRow( + icon: LucideIcons.userPlus, + title: 'Invite', + trailing: const Icon(LucideIcons.chevronRight, size: 18), + onTap: () => openPage(invitePageBuilder!), ), - ), - const SizedBox(height: Grid.quarter), - Text( - host, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, + if (appearancePageBuilder != null) + AppListRow( + icon: LucideIcons.sunMoon, + title: 'Appearance', + trailing: const Icon(LucideIcons.chevronRight, size: 18), + onTap: () => openPage(appearancePageBuilder!), ), - ), ], ), - ), - const SizedBox(width: Grid.xxs), - _CommunityActionSwap( - key: Key('community-switcher-action-${community.id}'), - communityId: community.id, - communityName: community.name, - isActive: isActive, - isEditing: isEditing, - onRemove: onRemove, - ), - ], - ), - ), - ); - } -} - -const _communityActionSwapDuration = Duration(milliseconds: 250); -const double _communityActionSwapBlur = 2; -const double _communityActionSwapStartScale = 0.25; - -class _CommunityActionSwap extends StatelessWidget { - const _CommunityActionSwap({ - super.key, - required this.communityId, - required this.communityName, - required this.isActive, - required this.isEditing, - required this.onRemove, - }); - - final String communityId; - final String communityName; - final bool isActive; - final bool isEditing; - final VoidCallback onRemove; - - @override - Widget build(BuildContext context) { - final reduceMotion = - MediaQuery.maybeOf(context)?.disableAnimations ?? false; - - return SizedBox.square( - dimension: 40, - child: AnimatedSwitcher( - duration: reduceMotion ? Duration.zero : _communityActionSwapDuration, - reverseDuration: reduceMotion - ? Duration.zero - : _communityActionSwapDuration, - transitionBuilder: (child, animation) { - final eased = CurvedAnimation( - parent: animation, - curve: Curves.easeInOut, - ); - return AnimatedBuilder( - animation: eased, - child: child, - builder: (context, child) { - final progress = eased.value; - final blur = _communityActionSwapBlur * (1 - progress); - final scale = - _communityActionSwapStartScale + - ((1 - _communityActionSwapStartScale) * progress); - return Opacity( - opacity: progress, - child: ImageFiltered( - enabled: blur > 0.01, - imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), - child: Transform.scale(scale: scale, child: child), + AppListCard( + children: [ + AppListRow( + key: const Key('community-menu-switch'), + icon: LucideIcons.arrowLeftRight, + title: 'Switch Community', + trailing: const Icon(LucideIcons.chevronRight, size: 18), + onTap: openSwitcher, ), - ); - }, - ); - }, - child: isEditing - ? IconButton( - key: ValueKey('community-switcher-remove-$communityId'), - tooltip: 'Remove $communityName', - visualDensity: VisualDensity.compact, - onPressed: onRemove, - icon: Icon( - LucideIcons.trash2, - size: 18, - color: context.colors.error, - ), - ) - : Center( - key: ValueKey('community-switcher-selection-$communityId'), - child: _CommunitySelectionIndicator( - key: ValueKey('community-switcher-circle-$communityId'), - communityName: communityName, - isActive: isActive, - ), - ), - ), - ); - } -} - -class _CommunitySelectionIndicator extends StatelessWidget { - const _CommunitySelectionIndicator({ - super.key, - required this.communityName, - required this.isActive, - }); - - final String communityName; - final bool isActive; - - @override - Widget build(BuildContext context) { - return Semantics( - label: isActive - ? '$communityName is the current community' - : 'Switch to $communityName', - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isActive ? context.appColors.success : Colors.transparent, - border: isActive - ? null - : Border.all(color: context.colors.outlineVariant, width: 2), - shape: BoxShape.circle, - ), - child: isActive - ? const Icon(LucideIcons.check, color: Colors.white, size: 16) - : null, - ), - ); - } -} - -class _AddCommunityTile extends StatelessWidget { - const _AddCommunityTile({required this.onTap}); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - key: const Key('community-switcher-add'), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.twelve, - ), - child: Row( - children: [ - Container( - width: _communitySwitcherAvatarSize, - height: _communitySwitcherAvatarSize, - decoration: BoxDecoration( - color: context.colors.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - LucideIcons.plus, - size: 18, - color: context.colors.onPrimaryContainer, - ), - ), - const SizedBox(width: Grid.xs), - Text( - 'Add Community', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), + ], ), ], ), @@ -474,32 +81,17 @@ Future _confirmRemoveCommunity( Community community, { required bool closeSheetAfterRemoval, }) async { - final confirmed = await showBuzzDialog( - context: context, - builder: (dialogContext) => AlertDialog.adaptive( - title: const Text('Remove community?'), - content: Text( - 'Are you sure you want to remove “${community.name}”? ' - 'You can pair with it again later.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - style: FilledButton.styleFrom(backgroundColor: context.colors.error), - child: const Text('Remove'), - ), - ], - ), - ); - - if (confirmed != true || !context.mounted) return; - final messenger = ScaffoldMessenger.of(context); try { + final confirmed = await showDestructiveConfirmation( + context: context, + title: 'Remove community?', + message: + 'Are you sure you want to remove “${community.name}”? ' + 'You can pair with it again later.', + confirmLabel: 'Remove', + ); + if (!confirmed || !context.mounted) return; await ref .read(communityListProvider.notifier) .removeCommunity(community.id); @@ -516,7 +108,14 @@ Future _confirmRemoveCommunity( class _CommunityIndicator extends ConsumerWidget { final VoidCallback onTap; - const _CommunityIndicator({required this.onTap}); + const _CommunityIndicator({ + required this.onTap, + required this.avatarKey, + required this.hidden, + }); + + final GlobalKey avatarKey; + final bool hidden; @override Widget build(BuildContext context, WidgetRef ref) { @@ -527,9 +126,17 @@ class _CommunityIndicator extends ConsumerWidget { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, - child: _CommunityAvatar( - name: activeCommunity?.name, - relayUrl: activeCommunity?.relayUrl, + child: Semantics( + button: true, + label: 'Community settings', + child: Opacity( + opacity: hidden ? 0 : 1, + child: _CommunityAvatar( + key: avatarKey, + name: activeCommunity?.name, + relayUrl: activeCommunity?.relayUrl, + ), + ), ), ); } @@ -582,22 +189,31 @@ class _CommunityAvatar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final trimmedName = name?.trim(); final initial = trimmedName != null && trimmedName.isNotEmpty - ? trimmedName.substring(0, 1).toUpperCase() + ? trimmedName.characters.first.toUpperCase() : '?'; final relay = relayUrl; final iconUrl = relay == null ? null - : ref.watch(communityIconProvider(relay)).value; + : ref.watch(communityIconPresentationProvider(relay)); - return AvatarImage( - imageUrl: iconUrl, - radius: size / 2, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - initial, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, + return ClipRSuperellipse( + borderRadius: BorderRadius.circular(Radii.card * size / 40), + child: ColoredBox( + color: context.colors.primaryContainer, + child: SizedBox.square( + dimension: size, + child: AvatarImageContent( + imageUrl: iconUrl, + fallback: Text( + initial, + textScaler: TextScaler.noScaling, + style: context.textTheme.labelMedium?.copyWith( + fontSize: size * 0.38, + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), ), ), ); diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart new file mode 100644 index 0000000000..44fd2d3894 --- /dev/null +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -0,0 +1,650 @@ +part of '../channels_page.dart'; + +const _communityGridAvatarSize = 88.0; +const _communityCenterDuration = Duration(milliseconds: 420); +const _communityCenterCurve = Cubic(0.22, 1, 0.36, 1); +const _communityDepartureDuration = Duration(milliseconds: 520); +const _communityDepartureCurve = Cubic(0.2, 0, 0.2, 1); +const _communityRevealDuration = Duration(milliseconds: 160); +const _communityCenteredAvatarSize = _communityGridAvatarSize * 1.5; + +// Readiness follows the data that determines the landing page's layout. Live +// messages continue to sync normally after this first usable snapshot. +final _communityContentReadyProvider = Provider.autoDispose>(( + ref, +) { + final channels = ref.watch(channelsProvider); + final profile = ref.watch(profileProvider); + final community = ref.watch(activeCommunityProvider).unwrapPrevious().value; + final sections = ref.watch(channelSectionsProvider).isReady; + final stars = ref.watch(channelStarsProvider).isReady; + final sort = ref.watch(channelSortProvider).isReady; + final mutes = ref.watch(channelMutesProvider).isReady; + final readState = ref.watch(readStateProvider).isReady; + final hasIdentity = ref.watch(myPubkeyProvider) != null; + if (!channels.isLoading && channels.hasError) { + return AsyncError(channels.error!, channels.stackTrace!); + } + return AsyncData( + community != null && + channels.hasValue && + !channels.isLoading && + !profile.isLoading && + (!hasIdentity || (sections && stars && sort && mutes && readState)), + ); +}); + +/// Reuses Settings' entrance/dismissal, while letting a selected avatar leave +/// independently of the surface. The flight already performs the exit fade. +class _CommunitySwitcherRoute extends _SettingsPageRoute { + _CommunitySwitcherRoute({ + required super.builder, + required super.onTransitionProgress, + }); + + bool flying = false; + + @override + bool didPop(void result) { + if (flying) controller?.reverseDuration = Duration.zero; + return super.didPop(result); + } + + // Keep the entrance wrappers mounted during selection. Replacing them with + // the bare page would reparent the native header as the center motion starts. +} + +class _CommunityFlight { + const _CommunityFlight(this.community, this.origin, this.previousCommunityId); + + final Community community; + final Rect origin; + final String? previousCommunityId; +} + +class _CommunitySwitcherPage extends HookConsumerWidget { + const _CommunitySwitcherPage({ + required this.destination, + required this.prepareLanding, + required this.onFlightChanged, + required this.onTransitionProgress, + }); + + final Rect? destination; + final Future Function() prepareLanding; + final ValueChanged onFlightChanged; + final ValueChanged onTransitionProgress; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final communitiesAsync = ref.watch(communityListProvider); + final activeId = ref.watch(activeCommunityProvider).value?.id; + final isEditing = useState(false); + final flight = useState<_CommunityFlight?>(null); + final landingBounds = useState(destination); + if (flight.value != null) ref.watch(_communityContentReadyProvider); + final error = useState(null); + final failedCommunityId = useState(null); + final centering = useAnimationController( + duration: _communityCenterDuration, + ); + final controller = useAnimationController( + duration: _communityDepartureDuration, + ); + final reveal = useAnimationController(duration: _communityRevealDuration); + final flightAvatar = useMemoized(() { + final selected = flight.value; + return selected == null + ? const SizedBox.shrink() + : RepaintBoundary( + // Keep avatar decoding/painting out of the per-frame motion build. + child: Material( + type: MaterialType.transparency, + child: _CommunityAvatar( + name: selected.community.name, + relayUrl: selected.community.relayUrl, + size: _communityGridAvatarSize, + ), + ), + ); + }, [flight.value]); + final reducedMotion = + MediaQuery.disableAnimationsOf(context) || + FocusManager.instance.highlightMode == FocusHighlightMode.traditional; + + Future waitForContent() async { + final ready = Completer(); + final subscription = ref.listenManual(_communityContentReadyProvider, ( + _, + next, + ) { + if (ready.isCompleted) return; + if (next.hasError) { + ready.completeError(next.error!, next.stackTrace); + } else if (next.value == true) { + ready.complete(); + } + }, fireImmediately: true); + try { + // A stalled relay must not trap someone behind an endless spinner. + await ready.future.timeout(const Duration(seconds: 20)); + } finally { + subscription.close(); + } + } + + Future selectCommunity(Community community, Rect origin) async { + if (flight.value != null) return; + if (community.id == activeId && community.id != failedCommunityId.value) { + Navigator.of(context).pop(); + return; + } + error.value = null; + flight.value = _CommunityFlight(community, origin, activeId); + onFlightChanged(true); + unawaited(HapticFeedback.selectionClick()); + final animate = !reducedMotion && destination != null; + try { + // Relay teardown, credential changes and provider hydration must not + // compete with the grid-to-center motion on the UI isolate. + if (animate) { + await centering.forward(from: 0).orCancel; + } else { + centering.value = 1; + } + if (!context.mounted) return; + await WidgetsBinding.instance.endOfFrame; + if (!context.mounted) return; + await ref + .read(communityListProvider.notifier) + .switchCommunity(community.id); + if (!context.mounted) return; + await ref.read(activeCommunityProvider.future); + if (!context.mounted) return; + // Refresh this scope before awaiting its content. ChannelsNotifier + // fences cached snapshots to the destination relay and identity. + ref.invalidate(channelsProvider); + await (() async { + await waitForContent(); + if (!context.mounted) return; + final bounds = await prepareLanding(); + if (context.mounted) landingBounds.value = bounds; + })().timeout(const Duration(seconds: 20)); + if (!context.mounted) return; + if (animate) { + await controller.forward(from: 0).orCancel; + if (!context.mounted) return; + // Reveal the settled page only after the avatar stops moving. + await reveal.forward(from: 0).orCancel; + } else { + controller.value = 1; + reveal.value = 1; + } + if (!context.mounted) return; + Navigator.of(context).pop(); + } catch (e) { + if (!context.mounted) return; + failedCommunityId.value = community.id; + flight.value = null; + centering.reset(); + controller.reset(); + reveal.reset(); + onFlightChanged(false); + onTransitionProgress(1); + error.value = e is TimeoutException + ? 'This community is taking longer to load. Select it to try again, or choose another.' + : 'Could not switch communities. Please try again.'; + } + } + + Future addCommunity() async { + final navigator = Navigator.of(context, rootNavigator: true); + final route = ModalRoute.of(context); + final pairing = ref.read(pairingProvider.notifier); + Navigator.of(context).pop(); + // Finish the picker exit before showing the pairing page. + if (route != null) await route.completed; + if (!navigator.mounted) return; + pairing.reset(); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => const PairingPage(addingCommunity: true), + ), + ), + ); + } + + final surface = Material( + key: const Key('community-switcher-page'), + color: context.colors.surface, + child: SafeArea( + top: defaultTargetPlatform != TargetPlatform.iOS, + child: Column( + children: [ + if (defaultTargetPlatform == TargetPlatform.iOS) + SizedBox( + height: frostedAppBarHeight(context), + child: IosNavigationBar( + title: '', + leading: IosNavigationAction( + label: 'Close community switcher', + symbol: 'xmark', + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + IosNavigationAction( + label: isEditing.value ? 'Done' : 'Edit', + onPressed: () => isEditing.value = !isEditing.value, + ), + ], + ), + ) + else + Padding( + padding: const EdgeInsets.all(Grid.xxs), + child: Row( + children: [ + IconButton( + tooltip: 'Close community switcher', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(LucideIcons.x), + ), + const Spacer(), + TextButton( + key: const Key('community-switcher-edit'), + onPressed: () => isEditing.value = !isEditing.value, + child: Text(isEditing.value ? 'Done' : 'Edit'), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xs, + Grid.gutter, + Grid.md, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + children: [ + Text( + 'Switch Community', + key: const Key('community-switcher-title'), + textAlign: TextAlign.center, + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.md), + if (error.value != null) + Padding( + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Semantics( + liveRegion: true, + child: Text( + error.value!, + style: TextStyle(color: context.colors.error), + ), + ), + ), + communitiesAsync.when( + loading: () => const Center( + child: BuzzLoadingIndicator( + size: 40, + semanticLabel: 'Loading communities', + ), + ), + error: (e, _) => + Text('Error loading communities: $e'), + data: (communities) => _CommunityGrid( + children: [ + for (final community in communities) + _CommunityGridTile( + community: community, + // Keep the current-community marker stable + // while the selected avatar leaves the grid. + isActive: + community.id == + (flight.value == null + ? activeId + : flight.value!.previousCommunityId), + isEditing: isEditing.value, + hidden: + flight.value?.community.id == + community.id, + onSelect: selectCommunity, + onRemove: () => _confirmRemoveCommunity( + context, + ref, + community, + closeSheetAfterRemoval: + community.id == activeId, + ), + ), + _CommunityGridAdd(onTap: addCommunity), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + + return PopScope( + canPop: flight.value == null, + child: IgnorePointer( + ignoring: flight.value != null, + child: AnimatedBuilder( + animation: Listenable.merge([centering, controller, reveal]), + child: RepaintBoundary(child: surface), + builder: (context, child) { + final selected = flight.value; + final departure = _communityDepartureCurve.transform( + controller.value, + ); + final surfaceExit = Curves.easeOutCubic.transform(centering.value); + final centerProgress = _communityCenterCurve.transform( + centering.value, + ); + final screen = MediaQuery.sizeOf(context); + final center = Offset(screen.width / 2, screen.height / 2); + final destinationRect = landingBounds.value; + return Stack( + fit: StackFit.expand, + children: [ + if (selected != null) + Opacity( + key: const Key('community-loading-backdrop'), + opacity: 1 - Curves.easeOutCubic.transform(reveal.value), + child: ColoredBox(color: context.colors.surface), + ), + Opacity( + key: const Key('community-picker-opacity'), + opacity: 1 - surfaceExit, + child: Transform.scale( + scale: 1 + 0.04 * surfaceExit, + child: child, + ), + ), + if (selected != null && controller.value == 0) + Positioned( + top: center.dy + _communityCenteredAvatarSize / 2 + Grid.md, + left: 0, + right: 0, + child: Center( + child: RepaintBoundary( + child: BuzzLoadingIndicator( + key: const Key('community-switch-loading'), + size: 18, + color: context.colors.onSecondaryContainer, + semanticLabel: 'Loading ${selected.community.name}', + ), + ), + ), + ), + if (selected != null) + _CommunityFlyingAvatar( + avatar: flightAvatar, + origin: selected.origin.center, + destination: + destinationRect ?? + Rect.fromCenter( + center: center, + width: _communityCenteredAvatarSize, + height: _communityCenteredAvatarSize, + ), + centerProgress: centerProgress, + departure: departure, + ), + ], + ); + }, + ), + ), + ); + } +} + +class _CommunityFlyingAvatar extends StatelessWidget { + const _CommunityFlyingAvatar({ + required this.avatar, + required this.origin, + required this.destination, + required this.departure, + required this.centerProgress, + }); + + final Widget avatar; + final Offset origin; + final Rect destination; + final double departure; + final double centerProgress; + + @override + Widget build(BuildContext context) { + final screen = MediaQuery.sizeOf(context); + final center = screen.center(Offset.zero); + final toCenter = center - origin; + final centerControl = + (origin + center) / 2 + Offset(-toCenter.dy, toCenter.dx) * 0.18; + final c = centerProgress; + final start = + origin * ((1 - c) * (1 - c)) + + centerControl * (2 * (1 - c) * c) + + center * (c * c); + final end = destination.center; + final delta = end - start; + // A quadratic Bezier bends perpendicular to travel, like Motion's arc. + // Bow outward first, then settle into the header. Clamp the control point + // so the larger avatar stays inside narrow screens throughout the swoop. + final bend = (start + end) / 2 + Offset(-delta.dy, delta.dx) * 0.75; + final margin = _communityCenteredAvatarSize / 2 + Grid.xs; + final width = screen.width; + final control = Offset( + bend.dx.clamp(margin, max(margin, width - margin)), + bend.dy, + ); + final t = departure; + final position = + start * ((1 - t) * (1 - t)) + + control * (2 * (1 - t) * t) + + end * (t * t); + final centeredSize = lerpDouble( + _communityGridAvatarSize, + _communityCenteredAvatarSize, + centerProgress, + )!; + final size = lerpDouble(centeredSize, destination.width, t)!; + return Positioned( + left: 0, + top: 0, + width: _communityGridAvatarSize, + height: _communityGridAvatarSize, + child: Transform.translate( + offset: + position - + const Offset( + _communityGridAvatarSize / 2, + _communityGridAvatarSize / 2, + ), + child: ExcludeSemantics( + child: Transform.scale( + key: const Key('community-flying-avatar'), + scale: size / _communityGridAvatarSize, + child: avatar, + ), + ), + ), + ); + } +} + +/// Intrinsic-height rows keep two columns without clipping scaled labels. +class _CommunityGrid extends StatelessWidget { + const _CommunityGrid({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) => Column( + key: const Key('community-switcher-options'), + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < children.length; index += 2) + Padding( + padding: const EdgeInsets.only(bottom: Grid.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: children[index]), + const SizedBox(width: Grid.xs), + Expanded( + child: index + 1 < children.length + ? children[index + 1] + : const SizedBox.shrink(), + ), + ], + ), + ), + ], + ); +} + +class _CommunityGridTile extends HookWidget { + const _CommunityGridTile({ + required this.community, + required this.isActive, + required this.isEditing, + required this.hidden, + required this.onSelect, + required this.onRemove, + }); + + final Community community; + final bool isActive; + final bool isEditing; + final bool hidden; + final Future Function(Community, Rect) onSelect; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final avatarKey = useMemoized(GlobalKey.new); + return Semantics( + selected: isActive, + child: InkWell( + key: Key('community-switcher-row-${community.id}'), + borderRadius: BorderRadius.circular(Radii.card), + onTap: isEditing + ? null + : () { + final box = avatarKey.currentContext?.findRenderObject(); + if (box is RenderBox) { + unawaited( + onSelect( + community, + box.localToGlobal(Offset.zero) & box.size, + ), + ); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Opacity( + opacity: hidden ? 0 : 1, + child: SizedBox( + key: avatarKey, + child: _CommunityAvatar( + key: Key('community-switcher-avatar-${community.id}'), + name: community.name, + relayUrl: community.relayUrl, + size: _communityGridAvatarSize, + ), + ), + ), + Positioned( + right: -Grid.twelve, + bottom: -Grid.twelve, + child: _CommunityGridAction( + community: community, + isActive: isActive, + isEditing: isEditing, + onRemove: onRemove, + ), + ), + ], + ), + const SizedBox(height: Grid.xs), + Text( + community.name, + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _CommunityGridAdd extends StatelessWidget { + const _CommunityGridAdd({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => InkWell( + key: const Key('community-switcher-add'), + borderRadius: BorderRadius.circular(Radii.card), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + children: [ + ClipRSuperellipse( + borderRadius: BorderRadius.circular( + Radii.card * _communityGridAvatarSize / 40, + ), + child: Container( + width: _communityGridAvatarSize, + height: _communityGridAvatarSize, + color: context.colors.primaryContainer.withValues(alpha: 0.5), + child: Icon( + LucideIcons.plus, + size: 32, + color: context.colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: Grid.xs), + Text( + 'Add Community', + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); +} diff --git a/mobile/lib/features/channels/channels_page/community_switcher_action.dart b/mobile/lib/features/channels/channels_page/community_switcher_action.dart new file mode 100644 index 0000000000..856f0e928b --- /dev/null +++ b/mobile/lib/features/channels/channels_page/community_switcher_action.dart @@ -0,0 +1,166 @@ +part of '../channels_page.dart'; + +class _CommunityGridAction extends HookWidget { + const _CommunityGridAction({ + required this.community, + required this.isActive, + required this.isEditing, + required this.onRemove, + }); + + final Community community; + final bool isActive; + final bool isEditing; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final controller = useAnimationController( + duration: const Duration(milliseconds: 220), + initialValue: isEditing ? 1 : 0, + ); + useEffect(() { + if (reducedMotion) { + controller.value = isEditing ? 1 : 0; + } else if (isEditing) { + unawaited(controller.forward()); + } else { + unawaited(controller.reverse()); + } + return null; + }, [isEditing, reducedMotion]); + final progress = useAnimation(controller); + final t = Curves.easeInOutCubic.transform(progress); + final size = isActive ? lerpDouble(24, 36, t)! : 36.0; + final foreground = isActive + ? Color.lerp(Colors.white, context.colors.onErrorContainer, t)! + : context.colors.onErrorContainer; + + // A fixed touch target and center keep Edit from nudging the avatar/check. + return SizedBox.square( + key: Key('community-switcher-action-${community.id}'), + dimension: Grid.xl, + child: IconButton( + key: Key( + 'community-switcher-${isEditing ? 'remove' : 'selection'}-${community.id}', + ), + tooltip: isEditing ? 'Remove ${community.name}' : null, + padding: EdgeInsets.zero, + onPressed: isEditing ? onRemove : null, + icon: Opacity( + key: Key('community-switcher-action-opacity-${community.id}'), + opacity: isActive ? 1 : t, + child: Transform.scale( + key: Key('community-switcher-action-scale-${community.id}'), + scale: isActive ? 1 : lerpDouble(.9, 1, t)!, + child: Container( + key: Key('community-switcher-badge-${community.id}'), + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? Color.lerp( + context.appColors.success, + context.colors.errorContainer, + t, + ) + : context.colors.errorContainer, + border: Border.all(color: context.colors.surface, width: 2), + ), + child: Center( + child: isActive && t > 0 && t < 1 + ? CustomPaint( + size: Size.square(lerpDouble(14, 18, t)!), + painter: _CommunityActionGlyphPainter(t, foreground), + ) + : isActive && t == 0 + ? Icon(LucideIcons.check, color: foreground, size: 14) + : t > 0 + ? Icon(LucideIcons.trash2, color: foreground, size: 18) + : const SizedBox.shrink(), + ), + ), + ), + ), + ), + ); + } +} + +/// Interpolates the check stroke into the bin outline; its lid and ribs appear +/// as the outline opens. Reversing Edit follows the same geometry backwards. +class _CommunityActionGlyphPainter extends CustomPainter { + const _CommunityActionGlyphPainter(this.progress, this.color); + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + canvas.scale(size.width / 24, size.height / 24); + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + Offset point(Offset check, Offset trash) => + Offset.lerp(check, trash, progress)!; + final start = point(const Offset(4, 12), const Offset(5, 6)); + final path = Path()..moveTo(start.dx, start.dy); + void line(Offset check, Offset trash) { + final p = point(check, trash); + path.lineTo(p.dx, p.dy); + } + + void curve( + Offset checkControl, + Offset checkEnd, + Offset trashControl, + Offset trashEnd, + ) { + final c = point(checkControl, trashControl); + final p = point(checkEnd, trashEnd); + path.quadraticBezierTo(c.dx, c.dy, p.dx, p.dy); + } + + line(const Offset(7, 15), const Offset(5, 20)); + curve( + const Offset(8, 16), + const Offset(9, 17), + const Offset(5, 22), + const Offset(7, 22), + ); + line(const Offset(14.5, 11.5), const Offset(17, 22)); + curve( + const Offset(17.25, 8.75), + const Offset(20, 6), + const Offset(19, 22), + const Offset(19, 20), + ); + line(const Offset(20, 6), const Offset(19, 6)); + canvas.drawPath(path, paint); + paint.color = color.withValues(alpha: progress); + canvas.drawPath( + Path() + ..moveTo(3, 6) + ..lineTo(21, 6) + ..moveTo(9, 6) + ..lineTo(9, 4) + ..quadraticBezierTo(9, 2, 11, 2) + ..lineTo(13, 2) + ..quadraticBezierTo(15, 2, 15, 4) + ..lineTo(15, 6) + ..moveTo(10, 10) + ..lineTo(10, 16) + ..moveTo(14, 10) + ..lineTo(14, 16), + paint, + ); + } + + @override + bool shouldRepaint(_CommunityActionGlyphPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index f63c271804..0d72eba0b7 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -52,6 +52,9 @@ class ChannelsNotifier extends AsyncNotifier> { final Map _liveSubscriptionsByChunk = {}; Future _liveSubscriptionQueue = Future.value(); + Future _unreadCatchUp = Future.value(); + int? _settledUnreadGeneration; + Completer _unreadReadinessChanged = Completer(); Set _desiredLiveChannelIds = const {}; int _subscriptionVersion = 0; int _nextLiveChunkGeneration = 0; @@ -93,6 +96,36 @@ class ChannelsNotifier extends AsyncNotifier> { bool get hasLoaded => _hasLoaded; + /// Waits for the current list's initial unread history to settle. + /// + /// Presentation transitions can await this without blocking ordinary cached + /// channel rendering. Failure keeps observed unread state and permits landing; + /// follow a newer refresh if it replaces work we awaited. + Future waitForUnreadCatchUp() async { + while (ref.mounted) { + final changed = _unreadReadinessChanged.future; + final subscriptions = _liveSubscriptionQueue; + await subscriptions; + if (!ref.mounted) return; + final unread = _unreadCatchUp; + await unread; + if (identical(subscriptions, _liveSubscriptionQueue) && + identical(unread, _unreadCatchUp) && + _settledUnreadGeneration == _subscriptionVersion) { + return; + } + // A disconnected generation has no settled destination history. + // Wake on reconciliation rather than accepting an older completed future. + await changed; + } + } + + void _notifyUnreadReadinessChanged() { + final previous = _unreadReadinessChanged; + _unreadReadinessChanged = Completer(); + previous.complete(); + } + Map> get observedUnreadEventsByChannel => Map>.unmodifiable({ @@ -106,6 +139,9 @@ class ChannelsNotifier extends AsyncNotifier> { final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); if (_memberSnapshotRelayBaseUrl != relayBaseUrl || _memberSnapshotPubkey != pubkey) { + // A cached snapshot is usable only within its relay and identity. + // A new scope must await its own connection and first channel fetch. + _hasLoaded = false; _memberSnapshotRelayBaseUrl = relayBaseUrl; _memberSnapshotPubkey = pubkey; _memberSnapshotsByChannelId = const {}; @@ -150,7 +186,7 @@ class ChannelsNotifier extends AsyncNotifier> { }); if (sessionState.status != SessionStatus.connected) { - // Keep the prior community's cache visible until the new relay connects. + // Preserve cached channels only for a reconnect within the same scope. if (_hasLoaded) return state.value ?? const []; await connected.future; } @@ -574,42 +610,36 @@ class ChannelsNotifier extends AsyncNotifier> { int subscriptionGeneration, ) async { if (!ref.mounted) return; - final myPk = ref.read(myPubkeyProvider); - if (myPk == null) return; - - final session = ref.read(relaySessionProvider.notifier); - final mutedChannelIds = _mutedChannelIds(); - final ReadStateState readState; try { - readState = ref.read(readStateProvider); - } catch (error) { - debugPrint('[ChannelsNotifier] unread catch-up skipped: $error'); - return; - } - final activeChannels = [ - for (final channel in channels) - if (channel.isMember && !channel.isArchived) channel, - ]; - final channelById = { - for (final channel in activeChannels) channel.id: channel, - }; - final readAtByChannel = { - for (final channel in activeChannels) - channel.id: readState.effectiveTimestamp(channel.id), - }; - final filters = [ - for (final channel in activeChannels) - NostrFilter( - kinds: EventKind.channelMessageEventKinds, - tags: { - '#h': [channel.id], - }, - since: (readAtByChannel[channel.id] ?? -1) + 1, - limit: _unreadCatchUpLimit, - ), - ]; + final myPk = ref.read(myPubkeyProvider); + if (myPk == null) return; + + final session = ref.read(relaySessionProvider.notifier); + final mutedChannelIds = _mutedChannelIds(); + final readState = ref.read(readStateProvider); + final activeChannels = [ + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel, + ]; + final channelById = { + for (final channel in activeChannels) channel.id: channel, + }; + final readAtByChannel = { + for (final channel in activeChannels) + channel.id: readState.effectiveTimestamp(channel.id), + }; + final filters = [ + for (final channel in activeChannels) + NostrFilter( + kinds: EventKind.channelMessageEventKinds, + tags: { + '#h': [channel.id], + }, + since: (readAtByChannel[channel.id] ?? -1) + 1, + limit: _unreadCatchUpLimit, + ), + ]; - try { final events = await _fetchChannelHistoryBatch( session, filters, @@ -674,6 +704,13 @@ class ChannelsNotifier extends AsyncNotifier> { } catch (error) { if (!ref.mounted) return; debugPrint('[ChannelsNotifier] unread catch-up failed: $error'); + } finally { + // Optional history can terminate without data. Release this destination + // with its retained unread state, but never settle a retired request. + if (ref.mounted && !_isCatchUpRetired(fence, subscriptionGeneration)) { + _settledUnreadGeneration = subscriptionGeneration; + _notifyUnreadReadinessChanged(); + } } } diff --git a/mobile/lib/features/channels/channels_provider_lifecycle.dart b/mobile/lib/features/channels/channels_provider_lifecycle.dart index 8c88377aa9..9078d08dad 100644 --- a/mobile/lib/features/channels/channels_provider_lifecycle.dart +++ b/mobile/lib/features/channels/channels_provider_lifecycle.dart @@ -53,6 +53,7 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier { ); } }); + _notifyUnreadReadinessChanged(); return _liveSubscriptionQueue; } @@ -160,7 +161,9 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier { } fence.ensureCurrent(); - unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion)); + _unreadCatchUp = _catchUpUnreadEvents(channels, fence, subscriptionVersion); + _notifyUnreadReadinessChanged(); + unawaited(_unreadCatchUp); _backstopTimer?.cancel(); _backstopTimer = Timer.periodic( @@ -237,6 +240,7 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier { void _clearLiveSubscriptions() { _subscriptionVersion++; + _notifyUnreadReadinessChanged(); _desiredLiveChannelIds = const {}; _terminallyClosedLiveChunks.clear(); _clearRetainedLiveChunks(); diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 836de72705..b88d86a89e 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -101,6 +101,10 @@ class PairingPage extends HookConsumerWidget { nativeTitle: identityRecoveryOnly ? 'Send to Desktop' : '', + frosted: false, + showBottomDivider: false, + iconColor: context._onboardingInk, + title: identityRecoveryOnly ? const Text('Send to Desktop') : null, @@ -109,6 +113,10 @@ class PairingPage extends HookConsumerWidget { ), ) : AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, foregroundColor: context._onboardingInk, systemOverlayStyle: onboardingSystemOverlayStyle, leadingWidth: Theme.of(context).platform == TargetPlatform.iOS @@ -132,12 +140,14 @@ class PairingPage extends HookConsumerWidget { tooltip: 'Back', onPressed: () => Navigator.of(context).pop(), ), - title: Text( - identityRecoveryOnly ? 'Send to Desktop' : 'Add Community', - style: context.textTheme.titleMedium?.copyWith( - color: context._onboardingInk, - ), - ), + title: identityRecoveryOnly + ? Text( + 'Send to Desktop', + style: context.textTheme.titleMedium?.copyWith( + color: context._onboardingInk, + ), + ) + : null, ) : null; diff --git a/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart b/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart index 795d3a5f89..4d475814ec 100644 --- a/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart +++ b/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart @@ -142,7 +142,7 @@ class _FigmaHomeScreen extends ConsumerWidget { final relayUrl = community?.relayUrl; final communityIcon = relayUrl == null ? null - : ref.watch(communityIconProvider(relayUrl)).value; + : ref.watch(communityIconPresentationProvider(relayUrl)); final suppliedGradient = context.appColors.topSectionGradient; final backgroundGradient = suppliedGradient ?? diff --git a/mobile/lib/shared/community/community_icon_artwork.dart b/mobile/lib/shared/community/community_icon_artwork.dart new file mode 100644 index 0000000000..643518bd4a --- /dev/null +++ b/mobile/lib/shared/community/community_icon_artwork.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import '../emoji/emoji_avatar.dart'; + +const _maximumSourceEdge = 4096; +const _maximumSourcePixels = 4 * 1024 * 1024; +const _maximumCachedEdge = 512; +const _maximumEncodedBytes = 256 * 1024; + +// Only the product's fixed emoji template is safe to retain as SVG. Arbitrary +// SVG can reference external or embedded rasters that bypass the limits below. +// Accept current desktop artwork and its two legacy rounded backgrounds. +final _emojiSvg = RegExp( + r'^[^<>]{1,64}$', +); + +/// Rejects pathological raster dimensions before decoding and persists only a +/// small first-frame PNG or a regenerated, resource-free emoji SVG. +Future prepareCommunityIconArtwork(String artwork) async { + try { + final data = UriData.parse(artwork); + final bytes = data.contentAsBytes(); + if (bytes.length > _maximumEncodedBytes) return null; + if (data.mimeType == 'image/svg+xml') { + final svg = utf8.decode(bytes); + if (!_emojiSvg.hasMatch(svg)) return null; + final emoji = parseEmojiAvatarSvg(svg); + if (emoji == null) return null; + return emojiAvatarDataUrl(emoji.emoji, emoji.colorValue); + } + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + try { + final descriptor = await ui.ImageDescriptor.encoded(buffer); + try { + final width = descriptor.width; + final height = descriptor.height; + if (width <= 0 || + height <= 0 || + width > _maximumSourceEdge || + height > _maximumSourceEdge || + width * height > _maximumSourcePixels) { + return null; + } + final codec = await descriptor.instantiateCodec( + targetWidth: width >= height + ? width.clamp(1, _maximumCachedEdge) + : null, + targetHeight: height > width + ? height.clamp(1, _maximumCachedEdge) + : null, + ); + try { + final frame = await codec.getNextFrame(); + try { + final png = await frame.image.toByteData( + format: ui.ImageByteFormat.png, + ); + if (png == null || png.lengthInBytes > _maximumEncodedBytes) { + return null; + } + return Uri.dataFromBytes( + png.buffer.asUint8List(), + mimeType: 'image/png', + ).toString(); + } finally { + frame.image.dispose(); + } + } finally { + codec.dispose(); + } + } finally { + descriptor.dispose(); + } + } finally { + buffer.dispose(); + } + } catch (_) { + return null; + } +} diff --git a/mobile/lib/shared/community/community_icon_cache.dart b/mobile/lib/shared/community/community_icon_cache.dart new file mode 100644 index 0000000000..1e04d5d6ee --- /dev/null +++ b/mobile/lib/shared/community/community_icon_cache.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../theme/theme_provider.dart'; + +// v1 allowed unbounded rasters and v2 allowed arbitrary SVG resources. +// Rebuild this derived cache through the validated artwork pipeline. +const _storageKey = 'buzz.community-icons.v3'; +const _maximumEntries = 32; +const _maximumStoredCharacters = 3 * 1024 * 1024; + +/// Last downloaded public icon artwork, keyed by the relay information URL. +/// It intentionally survives active-community changes and app restarts. +final communityIconCacheProvider = + NotifierProvider>( + CommunityIconCache.new, + ); + +/// A bounded, best-effort local cache; it never stores community credentials. +class CommunityIconCache extends Notifier> { + SharedPreferences? _prefs; + Future _writes = Future.value(); + + @override + Map build() { + try { + _prefs = ref.read(savedPrefsProvider); + final raw = _prefs?.getString(_storageKey); + if (raw == null || raw.length > _maximumStoredCharacters) return {}; + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return _bounded({ + for (final entry in decoded.entries) + if (entry.value is String && + (entry.value as String).startsWith('data:image/')) + entry.key: entry.value as String, + }); + } catch (_) { + // Artwork caching is optional when preferences are unavailable/corrupt. + return {}; + } + } + + /// Stores complete artwork, or clears an explicitly removed relay icon. + Future remember(String relay, String? artwork) { + if (state[relay] == artwork) return _writes; + final next = {...state}..remove(relay); + if (artwork != null) next[relay] = artwork; + state = _bounded(next); + final encoded = jsonEncode(state); + _writes = _writes.then((_) async { + try { + await _prefs?.setString(_storageKey, encoded); + } catch (_) { + // Retain the in-memory copy even if persistence is unavailable. + } + }); + return _writes; + } + + Map _bounded(Map entries) { + var size = jsonEncode(entries).length; + while (entries.length > _maximumEntries || + size > _maximumStoredCharacters) { + entries.remove(entries.keys.first); + size = jsonEncode(entries).length; + } + return Map.unmodifiable(entries); + } +} diff --git a/mobile/lib/shared/community/community_icon_provider.dart b/mobile/lib/shared/community/community_icon_provider.dart index 9e5ed8433a..9bcf3ecad2 100644 --- a/mobile/lib/shared/community/community_icon_provider.dart +++ b/mobile/lib/shared/community/community_icon_provider.dart @@ -1,48 +1,143 @@ +import 'dart:async'; import 'dart:convert'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; -/// Supplies the HTTP client used for NIP-11 community icon lookups. -/// -/// Tests can override this provider to return deterministic relay responses. +import 'community_icon_cache.dart'; +import 'community_icon_artwork.dart'; + +const _maximumIconBytes = 256 * 1024; +const _lookupTimeout = Duration(seconds: 5); + +/// Supplies the unauthenticated client for public NIP-11 documents and icons. final communityIconHttpClientProvider = Provider((ref) { final client = http.Client(); ref.onDispose(client.close); return client; }); -/// Reads a community's icon from its public NIP-11 relay information document. -/// -/// The lookup does not depend on the active relay session, so icons can render -/// for every paired community in the switcher. Callers explicitly invalidate -/// this family when opening the switcher so transient failures and relay -/// metadata updates can be retried without background polling. +/// Immediately presents saved artwork while a public metadata refresh runs. +final communityIconPresentationProvider = Provider.autoDispose + .family((ref, relayUrl) { + final key = _relayInfoUri(relayUrl)?.toString(); + final saved = ref.watch(communityIconCacheProvider)[key]; + final refreshed = ref.watch(communityIconProvider(relayUrl)); + return saved ?? refreshed.value; + }); + +/// Refreshes public relay artwork without discarding its last usable copy. +/// Downloaded bytes (including remote images) are persisted, so reopening the +/// picker and offline launches do not depend on the active relay's image cache. final communityIconProvider = FutureProvider.autoDispose .family((ref, relayUrl) async { final uri = _relayInfoUri(relayUrl); if (uri == null) return null; - + final key = uri.toString(); + final cache = ref.read(communityIconCacheProvider.notifier); + final saved = ref.read(communityIconCacheProvider)[key]; + final client = ref.read(communityIconHttpClientProvider); + var disposed = false; + final cancelled = Completer(); + ref.onDispose(() { + disposed = true; + cancelled.complete(); + }); try { - final response = await ref - .read(communityIconHttpClientProvider) - .get(uri, headers: const {'Accept': 'application/nostr+json'}) - .timeout(const Duration(seconds: 5)); - if (response.statusCode < 200 || response.statusCode >= 300) { + final response = await _download( + client, + uri, + cancelled.future, + metadata: true, + ); + if (response == null || disposed) return saved; + final document = jsonDecode(utf8.decode(response.bytes)); + if (document is! Map) return saved; + final value = document['icon']; + if (value == null || (value is String && value.trim().isEmpty)) { + await cache.remember(key, null); return null; } - - final document = jsonDecode(response.body); - if (document is! Map) return null; - - final icon = document['icon']; - if (icon is! String || icon.trim().isEmpty) return null; - return icon.trim(); + if (value is! String) return saved; + final icon = value.trim(); + String? artwork; + if (icon.startsWith('data:image/')) { + final data = UriData.parse(icon); + if (data.contentAsBytes().length <= _maximumIconBytes) artwork = icon; + } else { + final imageUri = Uri.tryParse(icon); + if (imageUri != null && + (imageUri.scheme == 'https' || imageUri.scheme == 'http')) { + final image = await _download(client, imageUri, cancelled.future); + if (image != null && image.mimeType.startsWith('image/')) { + artwork = Uri.dataFromBytes( + image.bytes, + mimeType: image.mimeType, + ).toString(); + } + } + } + if (disposed) return saved; + // Never expose rejected remote URLs to the avatar renderer: doing so + // would bypass the bounded download above. + if (artwork == null) return saved; + final prepared = await prepareCommunityIconArtwork(artwork); + if (prepared == null || disposed) return saved; + await cache.remember(key, prepared); + return prepared; } catch (_) { - return null; + return saved; } }); +Future<({List bytes, String mimeType})?> _download( + http.Client client, + Uri uri, + Future cancelled, { + bool metadata = false, +}) async { + final abort = Completer(); + void cancel() { + if (!abort.isCompleted) abort.complete(); + } + + unawaited(cancelled.then((_) => cancel())); + final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future); + if (metadata) request.headers['Accept'] = 'application/nostr+json'; + StreamIterator>? iterator; + try { + final response = await client.send(request).timeout(_lookupTimeout); + iterator = StreamIterator(response.stream); + final limit = metadata ? _maximumIconBytes * 2 : _maximumIconBytes; + if (response.statusCode < 200 || + response.statusCode >= 300 || + (response.contentLength ?? 0) > limit) { + return null; + } + final deadline = DateTime.now().add(_lookupTimeout); + final bytes = []; + while (await iterator.moveNext().timeout( + deadline.difference(DateTime.now()), + )) { + if (bytes.length + iterator.current.length > limit) return null; + bytes.addAll(iterator.current); + } + return ( + bytes: bytes, + mimeType: (response.headers['content-type'] ?? '') + .split(';') + .first + .trim() + .toLowerCase(), + ); + } finally { + // Future.timeout does not stop the underlying socket, including while + // waiting for response headers. Abort before releasing the stream. + cancel(); + await iterator?.cancel(); + } +} + Uri? _relayInfoUri(String relayUrl) { try { final uri = Uri.parse(relayUrl.trim()); @@ -52,7 +147,11 @@ Uri? _relayInfoUri(String relayUrl) { 'https' || 'http' => uri.scheme, _ => null, }; - return scheme == null ? null : uri.replace(scheme: scheme); + return scheme == null || uri.host.isEmpty + ? null + : uri + .replace(scheme: scheme, path: uri.path.isEmpty ? '/' : uri.path) + .removeFragment(); } on FormatException { return null; } diff --git a/mobile/lib/shared/widgets/confirmation_dialog.dart b/mobile/lib/shared/widgets/confirmation_dialog.dart new file mode 100644 index 0000000000..c41b883c5a --- /dev/null +++ b/mobile/lib/shared/widgets/confirmation_dialog.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../theme/theme.dart'; +import 'modal_presentation.dart'; + +/// Confirms a destructive action using a UIKit alert on iOS. +/// Returns true only when the person explicitly chooses the destructive action. +Future showDestructiveConfirmation({ + required BuildContext context, + required String title, + required String message, + required String confirmLabel, +}) async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { + return await const MethodChannel( + 'buzz/confirmation_dialog', + ).invokeMethod('present', { + 'title': title, + 'message': message, + 'confirmLabel': confirmLabel, + 'cancelLabel': 'Cancel', + 'dark': context.theme.brightness == Brightness.dark, + }) == + true; + } + return await showBuzzDialog( + context: context, + builder: (dialogContext) => AlertDialog.adaptive( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: FilledButton.styleFrom( + backgroundColor: context.colors.error, + ), + child: Text(confirmLabel), + ), + ], + ), + ) == + true; +} diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 3bb2dcceaf..674ca6abb5 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -140,8 +140,8 @@ class FrostedAppBar extends StatelessWidget { /// Whether to apply the translucent blur treatment behind the app bar. /// - /// A page can leave its painted backdrop exposed at rest, then turn this on - /// when scrolling moves content beneath the controls. + /// The backdrop stays clear at rest. This enables the treatment when the + /// surrounding scroll scope reports content beneath the controls. final bool frosted; /// Opacity of the frosted surface above the blurred backdrop. @@ -182,6 +182,9 @@ class FrostedAppBar extends StatelessWidget { /// Native replacements for composite Flutter actions, including menus. final List? nativeActions; + /// Reports when native header images and layout are ready to reveal. + final ValueChanged? onNativeReadyChanged; + /// Uses a composable Flutter header while a backdrop covers the native view. final ValueListenable? nativeViewSuppressed; @@ -197,6 +200,7 @@ class FrostedAppBar extends StatelessWidget { this.nativeLayoutDuration = Duration.zero, this.nativeLeading, this.nativeActions, + this.onNativeReadyChanged, this.nativeViewSuppressed, this.leading, this.automaticallyImplyLeading = true, @@ -266,6 +270,7 @@ class FrostedAppBar extends StatelessWidget { right: 0, height: barHeight, child: IosNavigationBar( + onReadyChanged: onNativeReadyChanged, title: nativeTitle ?? (title is Text ? (title as Text).data ?? '' : ''), @@ -320,8 +325,9 @@ class FrostedAppBar extends StatelessWidget { } final topPadding = MediaQuery.paddingOf(context).top; final scrollUnder = FrostedScrollUnderScope.maybeOf(context); - final paintsBottomDivider = - showBottomDivider && (scrollUnder?.isScrolledUnder ?? true); + final isScrolledUnder = scrollUnder?.isScrolledUnder ?? false; + final paintsFrost = frosted && isScrolledUnder; + final paintsBottomDivider = showBottomDivider && isScrolledUnder; final canPop = Navigator.canPop(context); final effectiveTitleStyle = _effectiveTitleStyle(context, titleStyle); final barContentHeight = _barContentHeight( @@ -440,11 +446,11 @@ class FrostedAppBar extends StatelessWidget { key: const ValueKey('frosted-app-bar-background'), padding: EdgeInsets.only(top: topPadding), decoration: BoxDecoration( - color: !frosted - ? Colors.transparent - : gradient == null + color: gradient != null + ? null + : paintsFrost ? context.colors.surface.withValues(alpha: frostedSurfaceOpacity) - : null, + : Colors.transparent, gradient: gradient, border: showBottomDivider ? Border( @@ -461,7 +467,7 @@ class FrostedAppBar extends StatelessWidget { ); final child = ClipRect( - child: frosted + child: paintsFrost ? BackdropFilter( filter: ImageFilter.blur( sigmaX: frostedBlurSigma, diff --git a/mobile/lib/shared/widgets/ios_navigation_bar.dart b/mobile/lib/shared/widgets/ios_navigation_bar.dart index 2b3da7fdfc..d737498d33 100644 --- a/mobile/lib/shared/widgets/ios_navigation_bar.dart +++ b/mobile/lib/shared/widgets/ios_navigation_bar.dart @@ -26,6 +26,9 @@ class IosNavigationAction { this.onPressed, this.children = const [], this.selected = false, + this.onAvatarBoundsChanged, + this.avatarHidden = false, + this.plain = false, }); @@ -41,6 +44,12 @@ class IosNavigationAction { final List children; final bool selected; + /// Reports the avatar's global bounds for a transition into this bar item. + final ValueChanged? onAvatarBoundsChanged; + + /// Hides a transition's destination avatar while retaining its layout slot. + final bool avatarHidden; + /// Omits the shared Liquid Glass background for a text-only action. final bool plain; @@ -55,6 +64,9 @@ class IosNavigationAction { 'imageUrl': imageUrl, 'enabled': onPressed != null || children.isNotEmpty, 'selected': selected, + 'tracksAvatarBounds': onAvatarBoundsChanged != null, + 'avatarHidden': avatarHidden, + 'plain': plain, 'children': [ for (var i = 0; i < children.length; i++) children[i]._encode('$id.$i'), @@ -108,6 +120,7 @@ class IosNavigationBar extends HookConsumerWidget { this.actions = const [], this.onBack, this.foregroundColor, + this.onReadyChanged, }); static const viewType = 'buzz/ios_navigation_bar'; @@ -124,9 +137,14 @@ class IosNavigationBar extends HookConsumerWidget { final VoidCallback? onBack; final Color? foregroundColor; + /// Reports when the latest native configuration and layout are applied. + /// Cosmetic avatar downloads do not delay readiness. + final ValueChanged? onReadyChanged; + @override Widget build(BuildContext context, WidgetRef ref) { final channel = useState(null); + final viewKey = useMemoized(GlobalKey.new); final latest = useRef(this)..value = this; final offset = IosNavigationScrollScope.maybeOf(context); final collapseRange = IosNavigationMetrics.of(context).largeTitleHeight; @@ -145,17 +163,17 @@ class IosNavigationBar extends HookConsumerWidget { action.avatarInitial, action.avatarIsAgent, ]); - final retainedImages = useRef({}); + final avatarImages = useState({}); final avatarKey = jsonEncode([ for (final entry in avatarActions.entries) [entry.key, imageKey(entry.value)], ]); - final avatarFuture = useMemoized( - () async { - final images = {}; - await Future.wait( - avatarActions.entries.map((entry) async { - final bytes = await nativeAvatarImage( + useEffect( + () { + var active = true; + for (final entry in avatarActions.entries) { + unawaited( + nativeAvatarImage( url: entry.value.imageUrl, initial: entry.value.avatarInitial!, isAgent: entry.value.avatarIsAgent, @@ -163,11 +181,24 @@ class IosNavigationBar extends HookConsumerWidget { foreground: colors.onPrimaryContainer, networkImage: (url) => MediaImageProvider(url: url, auth: auth, client: client), - ); - if (bytes != null) images[entry.key] = base64Encode(bytes); - }), - ); - return images; + ).then( + (bytes) { + if (!active || !context.mounted || bytes == null) return; + avatarImages.value = { + ...avatarImages.value, + entry.key: ( + key: imageKey(entry.value), + data: base64Encode(bytes), + ), + }; + }, + onError: (Object _, StackTrace _) { + // Keep UIKit's synchronous initial fallback on image failure. + }, + ), + ); + } + return () => active = false; }, [ avatarKey, @@ -177,12 +208,9 @@ class IosNavigationBar extends HookConsumerWidget { colors.onPrimaryContainer, ], ); - final avatarImages = useFuture(avatarFuture, preserveState: false).data; Map encodeAction(IosNavigationAction action, String id) { final key = imageKey(action); - final image = avatarImages?[id]; - if (image != null) retainedImages.value[id] = (key: key, data: image); - final retained = retainedImages.value[id]; + final retained = avatarImages.value[id]; return { ...action._encode(id), 'avatarBackground': colors.primaryContainer.toARGB32(), @@ -217,6 +245,20 @@ class IosNavigationBar extends HookConsumerWidget { final current = channel.value; if (current == null) return null; current.setMethodCallHandler((call) async { + if (call.method == 'avatarBounds') { + final values = call.arguments as Map; + final box = viewKey.currentContext?.findRenderObject(); + if (box is RenderBox && values['id'] == 'leading') { + final x = (values['x'] as num).toDouble(); + final y = (values['y'] as num).toDouble(); + final width = (values['width'] as num).toDouble(); + final height = (values['height'] as num).toDouble(); + latest.value.leading?.onAvatarBoundsChanged?.call( + box.localToGlobal(Offset(x, y)) & Size(width, height), + ); + } + return; + } if (call.method == 'metrics') { if (context.mounted) { IosNavigationMetrics.update( @@ -250,9 +292,32 @@ class IosNavigationBar extends HookConsumerWidget { useEffect(() { final current = channel.value; - if (current == null) return null; - unawaited(current.invokeMethod('configure', payload)); - return null; + var active = true; + void report(bool ready) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (active && context.mounted) { + latest.value.onReadyChanged?.call(ready); + } + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + report(false); + if (current != null) { + unawaited(() async { + try { + await current.invokeMethod('configure', payload); + if (!active) return; + if (latest.value.onReadyChanged != null) { + await current.invokeMethod('prepareForReveal'); + } + report(true); + } on PlatformException catch (error) { + debugPrint('Native navigation configuration failed: $error'); + } + }()); + } + return () => active = false; }, [channel.value, signature]); useEffect(() { @@ -274,6 +339,7 @@ class IosNavigationBar extends HookConsumerWidget { }, [channel.value, offset, collapseRange]); return UiKitView( + key: viewKey, viewType: viewType, creationParams: payload, creationParamsCodec: const StandardMessageCodec(), diff --git a/mobile/lib/shared/widgets/skeleton_reveal.dart b/mobile/lib/shared/widgets/skeleton_reveal.dart index cb0d523dbe..2352c7aaf5 100644 --- a/mobile/lib/shared/widgets/skeleton_reveal.dart +++ b/mobile/lib/shared/widgets/skeleton_reveal.dart @@ -11,11 +11,15 @@ class SkeletonReveal extends HookWidget { final Widget content; final bool shimmerEnabled; + /// Reports when loaded content has finished its reveal and layout frame. + final ValueChanged? onReadyChanged; + const SkeletonReveal({ required this.loading, required this.skeleton, required this.content, this.shimmerEnabled = true, + this.onReadyChanged, super.key, }); @@ -44,6 +48,24 @@ class SkeletonReveal extends HookWidget { return null; }, [loading, reducedMotion]); + useEffect(() { + var active = true; + void report([AnimationStatus? _]) { + final ready = !loading && reveal.isCompleted; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (active) onReadyChanged?.call(ready); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + reveal.addStatusListener(report); + report(); + return () { + active = false; + reveal.removeStatusListener(report); + }; + }, [loading, reveal, onReadyChanged]); + return AnimatedBuilder( animation: reveal, builder: (context, _) { diff --git a/mobile/test/features/channels/channel_reader_recovery_test.dart b/mobile/test/features/channels/channel_reader_recovery_test.dart index 6db4be1b59..0c48d97f9f 100644 --- a/mobile/test/features/channels/channel_reader_recovery_test.dart +++ b/mobile/test/features/channels/channel_reader_recovery_test.dart @@ -104,6 +104,33 @@ class _Config extends RelayConfigNotifier { RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); } +class _InitializingSortSession extends ConnectedRelaySession { + _InitializingSortSession(this.firstSnapshot); + final NostrEvent firstSnapshot; + final secondSnapshot = Completer>(); + void Function(NostrEvent)? onEvent; + var fetchCount = 0; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + fetchCount++; + return fetchCount == 1 ? [firstSnapshot] : secondSnapshot.future; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) callback, { + void Function(String)? onClosed, + }) async { + onEvent = callback; + return () => onEvent = null; + } +} + /// Missed heads, one per lane, each marked with `zz` so the persisted /// cache shows whether that lane's provider re-read on resume. const _missedHeads = { @@ -161,6 +188,54 @@ void main() { }); } + fakeAsyncTest('sort readiness waits for the post-subscription snapshot', ( + clock, + ) { + final relay = SidebarRelay(); + final first = relay.event('channel-sort', { + 'version': 1, + 'groups': {'channels': 'alpha'}, + }, nowSeconds()); + final session = _InitializingSortSession(first); + final c = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(() => _Config(relay.keys.nsec)), + relaySessionProvider.overrideWith(() => session), + activeCommunityProvider.overrideWith( + (ref) async => Community( + id: 'c', + name: 'c', + relayUrl: 'wss://relay.example', + addedAt: DateTime(2026), + ), + ), + appLifecycleProvider.overrideWith(_Lifecycle.new), + ], + ); + c.read(activeCommunityProvider); + clock.flushMicrotasks(); + c.listen(channelSortProvider, (_, _) {}); + clock.flushMicrotasks(); + expect(session.fetchCount, 2); + // A live preference arrives while the second history snapshot is pending. + final latest = relay.event('channel-sort', { + 'version': 1, + 'groups': {'channels': 'recent'}, + }, nowSeconds() + 1); + session.onEvent!(latest); + expect( + c.read(channelSortProvider).sortModeFor('channels'), + ChannelSortMode.recent, + ); + expect(c.read(channelSortProvider).isReady, isFalse); + + session.secondSnapshot.complete([latest]); + clock.flushMicrotasks(); + expect(c.read(channelSortProvider).isReady, isTrue); + c.dispose(); + }); + for (final lane in _lanes) { Map blob(Map entries) => { 'version': 1, diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index a9a627c838..cb0749df92 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -15,6 +15,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; +import 'package:buzz/features/channels/channel_stars/channel_stars_provider.dart'; +import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart'; +import 'package:buzz/features/channels/channel_sort/channel_sort_provider.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; @@ -25,21 +28,27 @@ import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/profile/profile_avatar.dart'; +import 'package:buzz/features/pairing/pairing_page.dart'; +import 'package:buzz/features/pairing/pairing_qr_scanner.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/presence_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/utils/string_utils.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/community/community_icon_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:buzz/shared/emoji/emoji_avatar.dart'; import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/ios_navigation_bar.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; part 'channels_page_test/presence_tests.dart'; +part 'channels_page_test/unread_readiness_tests.dart'; void main() { setUpAll(() async { @@ -64,6 +73,7 @@ void main() { double bottomPadding = 0, Map communityIcons = const {}, ValueChanged? onCommunityIconLoad, + Future Function(String)? loadCommunityIcon, TextScaler textScaler = TextScaler.noScaling, Gradient? topSectionGradient, ValueChanged? onSettingsTransitionProgress, @@ -79,6 +89,7 @@ void main() { presenceProvider.overrideWith(() => _FakePresenceNotifier()), communityIconProvider.overrideWith((ref, relayUrl) async { onCommunityIconLoad?.call(relayUrl); + if (loadCommunityIcon != null) return loadCommunityIcon(relayUrl); return communityIcons[relayUrl]; }), dmDirectoryPreviewEnabledProvider.overrideWith( @@ -128,6 +139,8 @@ void main() { ); } + _communityUnreadLandingTests(buildTestable); + final testChannels = [ Channel( id: '1', @@ -862,7 +875,7 @@ void main() { final appBar = find.byType(FrostedAppBar).last; final communityAvatar = find.descendant( of: appBar, - matching: find.byType(AvatarImage), + matching: find.byType(ClipRSuperellipse), ); final profileAvatar = find.descendant( of: appBar, @@ -1141,7 +1154,7 @@ void main() { await tester.pumpAndSettle(); final communityAvatar = find.descendant( of: find.byType(FrostedAppBar).last, - matching: find.byType(AvatarImage), + matching: find.byType(ClipRSuperellipse), ); await tester.tap(communityAvatar); await tester.pump(); @@ -1178,31 +1191,27 @@ void main() { ), ); await tester.pumpAndSettle(); + final semantics = tester.ensureSemantics(); + try { + await tester.pump(); + expect( + find.bySemanticsLabel(RegExp('Community settings')), + findsOneWidget, + ); + } finally { + semantics.dispose(); + } await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); expect(find.byType(BuzzSheetHeader), findsNothing); expect(find.text('Community settings'), findsOneWidget); expect(find.text('Invite'), findsOneWidget); expect(find.text('Appearance'), findsOneWidget); - expect(find.text('Remove community'), findsOneWidget); - expect( - tester.getTopLeft(find.text('Remove community')).dy, - greaterThan(tester.getTopLeft(find.text('Appearance')).dy), - ); - expect( - tester.getTopLeft(find.text('Remove community')).dy, - lessThan(tester.getTopLeft(find.text('Switch communities')).dy), - ); - await tester.tap(find.text('Remove community')); - await tester.pumpAndSettle(); - expect(find.text('Remove community?'), findsOneWidget); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - expect(find.text('Community settings'), findsOneWidget); + expect(find.text('Remove community'), findsNothing); expect( tester.getTopLeft(find.text('Appearance')).dy, - lessThan(tester.getTopLeft(find.text('Switch communities')).dy), + lessThan(tester.getTopLeft(find.text('Switch Community')).dy), ); if (Platform.environment['COMMUNITY_SCREENSHOTS'] case final directory?) { final boundary = tester @@ -1233,6 +1242,148 @@ void main() { }); } + for (final role in [CommunityMemberRole.admin, CommunityMemberRole.member]) { + testWidgets('prefetches $role permissions and keeps an open menu stable', ( + tester, + ) async { + final ready = Completer(); + var lookups = 0; + final permission = FutureProvider((ref) { + lookups++; + return ready.future; + }); + final community = Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier([community]), + ), + activeCommunityProvider.overrideWith((ref) async => community), + currentCommunityRoleProvider.overrideWith( + (ref) => ref.watch(permission), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(lookups, 1); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + final appearanceBefore = tester.getRect(find.text('Appearance')); + expect(find.text('Invite'), findsNothing); + ready.complete(role); + await tester.pumpAndSettle(); + expect(find.text('Invite'), findsNothing); + expect(tester.getRect(find.text('Appearance')), appearanceBefore); + Navigator.of(tester.element(find.text('Appearance'))).pop(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + expect( + find.text('Invite'), + role == CommunityMemberRole.admin ? findsOneWidget : findsNothing, + ); + expect(lookups, 1); + }); + } + + for (final removingInactive in [false, true]) { + for (final confirmed in [false, true]) { + testWidgets( + 'iOS switcher removal uses native confirmation (inactive: $removingInactive, confirmed: $confirmed)', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/confirmation_dialog'); + final answer = Completer(); + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) { + calls.add(call); + return answer.future; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final communities = [ + for (final name in ['Alpha', 'Bravo']) + Community( + id: name.toLowerCase(), + name: name, + relayUrl: 'wss://${name.toLowerCase()}.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier(testChannels), + ), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith( + (ref) async => communities.first, + ), + ], + ), + ); + await tester.pumpAndSettle(); + tester + .widget(find.byType(FrostedAppBar)) + .nativeLeading! + .onPressed!(); + await tester.pumpAndSettle(); + expect(find.text('Remove community'), findsNothing); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + tester + .widget(find.byType(IosNavigationBar).last) + .actions + .single + .onPressed!(); + await tester.pumpAndSettle(); + await tester.tap( + find.byTooltip(removingInactive ? 'Remove Bravo' : 'Remove Alpha'), + ); + await tester.pumpAndSettle(); + expect(calls.single.method, 'present'); + expect(calls.single.arguments['title'], 'Remove community?'); + expect(calls.single.arguments['confirmLabel'], 'Remove'); + expect(calls.single.arguments['cancelLabel'], 'Cancel'); + expect( + calls.single.arguments['message'], + contains(removingInactive ? 'Bravo' : 'Alpha'), + ); + expect(find.byType(AlertDialog), findsNothing); + expect(notifier.removedIds, isEmpty); + answer.complete(confirmed); + await tester.pumpAndSettle(); + expect( + notifier.removedIds, + confirmed ? [removingInactive ? 'bravo' : 'alpha'] : isEmpty, + ); + expect( + find.byKey(const Key('community-switcher-page')), + confirmed && !removingInactive ? findsNothing : findsOneWidget, + ); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + } + testWidgets('community settings hides invite for members', (tester) async { final community = Community( id: 'alpha', @@ -1259,6 +1410,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Invite'), findsNothing); expect(find.text('Appearance'), findsOneWidget); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('community-switcher-sheet')), findsNothing); + expect(find.byKey(const Key('community-switcher-page')), findsOneWidget); + await tester.tap(find.byTooltip('Close community switcher')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.byKey(const Key('community-switcher-sheet')), findsNothing); + expect(find.text('Alpha'), findsOneWidget); }); testWidgets('community switcher separates selection from edit removal', ( @@ -1305,19 +1465,23 @@ void main() { await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); - expect(find.text('Community settings'), findsOneWidget); + expect(find.text('Switch communities'), findsNothing); + expect(find.text('Edit'), findsNothing); + expect(find.byKey(const Key('community-switcher-options')), findsNothing); + expect(find.byKey(const Key('community-menu-switch')), findsOneWidget); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('community-switcher-sheet')), findsNothing); + + expect(find.text('Switch Community'), findsOneWidget); final options = find.byKey(const Key('community-switcher-options')); expect(options, findsOneWidget); - final editButton = find.byKey(const Key('community-switcher-edit')); - expect(tester.getSize(editButton), const Size(56, 44)); - expect(find.byTooltip('Close sheet'), findsNothing); - expect( - tester.getCenter(editButton).dx, - greaterThan(tester.getCenter(find.text('Community settings')).dx), - ); - expect(find.text('alpha.example.com'), findsOneWidget); - expect(find.text('bravo.example.com'), findsOneWidget); + + expect(find.byTooltip('Close community switcher'), findsOneWidget); + expect(find.text('alpha.example.com'), findsNothing); + expect(find.text('bravo.example.com'), findsNothing); + expect(find.text('Rename'), findsNothing); expect( find.descendant( @@ -1330,12 +1494,12 @@ void main() { expect(find.byIcon(LucideIcons.trash2), findsNothing); expect( tester.getSize(find.byKey(const Key('community-switcher-avatar-alpha'))), - const Size.square(36), + const Size.square(88), ); - final alphaAvatar = tester.widget( + final alphaAvatar = tester.widget( find.descendant( of: find.byKey(const Key('community-switcher-avatar-alpha')), - matching: find.byType(AvatarImage), + matching: find.byType(AvatarImageContent), ), ); expect(alphaAvatar.imageUrl, startsWith('data:image/png;base64,')); @@ -1346,13 +1510,22 @@ void main() { const Key('community-switcher-selection-bravo'), ); expect( - tester.getCenter(editButton).dx, - closeTo(tester.getCenter(activeSelection).dx, 0.01), + tester + .getTopLeft(find.byKey(const Key('community-switcher-row-alpha'))) + .dy, + tester + .getTopLeft(find.byKey(const Key('community-switcher-row-bravo'))) + .dy, ); - expect(tester.getSize(activeSelection), const Size.square(40)); expect( - tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))), - const Size.square(24), + tester.getTopLeft(find.byKey(const Key('community-switcher-add'))).dy, + greaterThan( + tester + .getBottomLeft( + find.byKey(const Key('community-switcher-row-alpha')), + ) + .dy, + ), ); expect( find.descendant( @@ -1370,44 +1543,12 @@ void main() { ); expect( find.descendant(of: options, matching: find.byType(Divider)), - findsNWidgets(2), + findsNothing, ); await tester.tap(find.text('Edit')); await tester.pump(); - final activeAction = find.byKey( - const Key('community-switcher-action-alpha'), - ); - final actionSwitcher = tester.widget( - find.descendant( - of: activeAction, - matching: find.byType(AnimatedSwitcher), - ), - ); - expect(actionSwitcher.duration, const Duration(milliseconds: 250)); - expect(activeSelection, findsOneWidget); - expect( - find.byKey(const Key('community-switcher-remove-alpha')), - findsOneWidget, - ); - - await tester.pump(const Duration(milliseconds: 125)); - - final transitioningOpacities = tester - .widgetList( - find.descendant(of: activeAction, matching: find.byType(Opacity)), - ) - .map((opacity) => opacity.opacity); - expect( - transitioningOpacities.any((opacity) => opacity > 0 && opacity < 1), - isTrue, - ); - expect( - find.descendant(of: activeAction, matching: find.byType(ImageFiltered)), - findsWidgets, - ); - await tester.pumpAndSettle(); expect(find.text('Done'), findsOneWidget); @@ -1431,7 +1572,7 @@ void main() { await tester.pumpAndSettle(); expect(communityNotifier.removedIds, ['bravo']); - expect(find.text('Community settings'), findsOneWidget); + expect(find.text('Switch Community'), findsOneWidget); expect(find.text('Bravo'), findsNothing); expect(find.text('Done'), findsOneWidget); @@ -1473,6 +1614,8 @@ void main() { await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); expect(iconLoads, greaterThan(initialIconLoads)); }); @@ -1503,14 +1646,90 @@ void main() { await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect( - tester.getSize(find.text('Community settings')).height, + tester.getSize(find.byKey(const Key('community-switcher-title'))).height, greaterThan(32), ); }); + testWidgets( + 'Edit morphs the check in place and fades other actions reversibly', + (tester) async { + final communities = [ + for (final id in ['alpha', 'bravo']) + Community( + id: id, + name: id, + relayUrl: 'wss://$id.example.com', + addedAt: DateTime(2025), + ), + ]; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier(communities), + ), + activeCommunityProvider.overrideWith( + (ref) async => communities.first, + ), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('alpha')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + final badge = find.byKey(const Key('community-switcher-badge-alpha')); + final center = tester.getCenter(badge); + final avatar = find.byKey(const Key('community-switcher-avatar-alpha')); + final avatarBounds = tester.getRect(avatar); + double opacity() => tester + .widget( + find.byKey(const Key('community-switcher-action-opacity-bravo')), + ) + .opacity; + double scale() => tester + .widget( + find.byKey(const Key('community-switcher-action-scale-bravo')), + ) + .transform + .storage[0]; + expect(opacity(), 0); + expect(find.byIcon(LucideIcons.check), findsOneWidget); + await tester.tap(find.text('Edit')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.getCenter(badge), center); + expect(tester.getRect(avatar), avatarBounds); + expect(tester.getSize(badge).width, inExclusiveRange(24, 36)); + expect(opacity(), inExclusiveRange(0, 1)); + expect(scale(), inExclusiveRange(.9, 1)); + final midSize = tester.getSize(badge); + await tester.tap(find.text('Done')); + await tester.pump(); + expect(tester.getSize(badge), midSize); + await tester.pumpAndSettle(); + expect(tester.getCenter(badge), center); + expect(opacity(), 0); + expect(find.byIcon(LucideIcons.check), findsOneWidget); + expect(find.byIcon(LucideIcons.trash2), findsNothing); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + expect(tester.getCenter(badge), center); + expect(opacity(), 1); + expect(scale(), 1); + expect(find.byIcon(LucideIcons.trash2), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('community switcher disables icon motion when requested', ( tester, ) async { @@ -1541,16 +1760,16 @@ void main() { await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); await tester.tap(find.text('Edit')); await tester.pump(); - final actionSwitcher = tester.widget( - find.descendant( - of: find.byKey(const Key('community-switcher-action-alpha')), - matching: find.byType(AnimatedSwitcher), - ), + expect(find.byIcon(LucideIcons.trash2), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('community-switcher-badge-alpha'))), + const Size.square(36), ); - expect(actionSwitcher.duration, Duration.zero); expect( find.byKey(const Key('community-switcher-selection-alpha')), findsNothing, @@ -1561,6 +1780,604 @@ void main() { ); }); + for (final nativeHeader in [false, true]) { + for (final reduceMotion in [false, true]) { + testWidgets( + 'community selection holds enlarged until content loads (native header: $nativeHeader, reduced motion: $reduceMotion)', + (tester) async { + debugDefaultTargetPlatformOverride = nativeHeader + ? TargetPlatform.iOS + : TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final communities = [ + Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ), + Community( + id: 'bravo', + name: 'Bravo', + relayUrl: 'wss://bravo.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities) + ..switchCompleter = Completer(); + final loadedChannels = Completer>(); + final unreadCatchUp = Completer(); + final profiles = _SwitchingUserCache(); + final progress = []; + await tester.pumpWidget( + buildTestable( + communityIcons: nativeHeader + ? { + 'wss://bravo.example.com': emojiAvatarDataUrl( + '🥳', + 0xFFFF8652, + ), + } + : const {}, + disableAnimations: reduceMotion, + // An offline cosmetic refresh must not block ready content. + loadCommunityIcon: nativeHeader + ? null + : (relayUrl) => relayUrl.contains('bravo') + ? Completer().future + : Future.value(null), + onSettingsTransitionProgress: progress.add, + overrides: [ + userCacheProvider.overrideWith(() => profiles), + readStateProvider.overrideWith( + () => _FakeReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'aabb', + contexts: {'1': 100}, + version: 0, + ), + ), + ), + channelsProvider.overrideWith( + () => _FakeNotifier( + testChannels, + load: () => notifier.activeId == 'bravo' + ? loadedChannels.future + : Future.value(testChannels), + unreadCatchUp: unreadCatchUp.future, + ), + ), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith((ref) async { + await ref.watch(communityListProvider.future); + return communities.firstWhere( + (community) => community.id == notifier.activeId, + ); + }), + ], + ), + ); + await tester.pumpAndSettle(); + final Rect destinationRect; + if (nativeHeader) { + destinationRect = const Rect.fromLTWH(16, 68, 36, 36); + final bar = tester.widget( + find.byType(FrostedAppBar), + ); + bar.nativeLeading!.onAvatarBoundsChanged!(destinationRect); + bar.nativeLeading!.onPressed!(); + } else { + final headerAvatar = find.descendant( + of: find.byType(FrostedAppBar).last, + matching: find.byType(ClipRSuperellipse), + ); + destinationRect = tester.getRect(headerAvatar); + await tester.tap(find.text('Alpha')); + } + final destination = destinationRect.center; + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + expect(progress.last, 1); + if (nativeHeader) { + var navigation = tester.widget( + find.byType(IosNavigationBar).last, + ); + expect(navigation.leading!.symbol, 'xmark'); + expect(navigation.leading!.label, 'Close community switcher'); + expect(navigation.actions.single.label, 'Edit'); + navigation.actions.single.onPressed!(); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('community-switcher-remove-bravo')), + findsOneWidget, + ); + navigation = tester.widget( + find.byType(IosNavigationBar).last, + ); + expect(navigation.actions.single.label, 'Done'); + navigation.actions.single.onPressed!(); + await tester.pumpAndSettle(); + } + await tester.tap( + find.byKey(const Key('community-switcher-row-bravo')), + ); + await tester.pump(); + final flyingAvatar = find.byKey(const Key('community-flying-avatar')); + expect(notifier.switchedIds, reduceMotion ? ['bravo'] : isEmpty); + if (nativeHeader) { + expect( + tester + .widget(find.byType(FrostedAppBar)) + .nativeLeading! + .avatarHidden, + isTrue, + ); + } + final origin = tester.getCenter(flyingAvatar); + await tester.pump(const Duration(milliseconds: 105)); + if (!reduceMotion) { + expect(notifier.switchedIds, isEmpty); + final inFlight = tester.getCenter(flyingAvatar); + const center = Offset(195, 422); + expect( + (inFlight - center).distance, + lessThan((origin - center).distance), + ); + expect((inFlight - center).distance, greaterThan(0)); + final delta = center - origin; + final travel = inFlight - origin; + // A curved approach differs from the old straight-line tween. + expect( + (delta.dx * travel.dy - delta.dy * travel.dx).abs(), + greaterThan(1), + ); + } + await tester.pump(const Duration(milliseconds: 325)); + await tester.pump(); + expect(notifier.switchedIds, ['bravo']); + final loader = find.byKey(const Key('community-switch-loading')); + final loadingIndicator = tester.widget(loader); + final loaderContext = tester.element(loader); + expect(loadingIndicator.size, 18); + expect( + loadingIndicator.color, + loaderContext.colors.onSecondaryContainer, + ); + expect(tester.getSize(loader), const Size.square(18)); + final spinner = find.descendant( + of: loader, + matching: find.byType(RotationTransition), + ); + final turns = tester.widget(spinner).turns; + final before = turns.value; + await tester.pump(const Duration(milliseconds: 125)); + expect( + (turns.value - before + 1) % 1, + closeTo(reduceMotion ? 0 : 0.25, 0.001), + ); + final initial = tester.widget( + find.descendant(of: flyingAvatar, matching: find.byType(RichText)), + ); + expect( + initial.text.style?.decoration, + anyOf(isNull, TextDecoration.none), + ); + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + expect( + tester.widget(flyingAvatar).transform.storage[0], + 1.5, + ); + expect( + find.byKey(const Key('community-switch-loading')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const Key('community-loading-backdrop')), + ) + .opacity, + 1, + ); + // Storage completing does not release the avatar: the new channel list + // is still pending, and the old snapshot must not count as ready. + notifier.switchCompleter!.complete(); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + for (final id in ['alpha', 'bravo']) { + expect( + find.descendant( + of: find.byKey(Key('community-switcher-selection-$id')), + matching: find.byIcon(LucideIcons.check), + ), + id == 'alpha' ? findsOneWidget : findsNothing, + ); + } + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + expect( + tester.widget(flyingAvatar).transform.storage[0], + 1.5, + ); + expect( + find.byKey(const Key('community-switch-loading')), + findsOneWidget, + ); + await tester.binding.handlePopRoute(); + await tester.tapAt(const Offset(280, 150)); + expect(notifier.switchedIds, ['bravo']); + loadedChannels.complete(testChannels); + // Data is ready, but the page is still revealing its content. + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + if (!reduceMotion || nativeHeader) { + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + expect( + find.byKey(const Key('community-switch-loading')), + findsOneWidget, + ); + } + await tester.pump(const Duration(milliseconds: 220)); + if (nativeHeader) { + // Native images/layout may finish after the Flutter body. Keep the + // hold, and use fresh bounds rather than the old community's slot. + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + final header = tester.widget( + find.byType(FrostedAppBar), + ); + header.nativeLeading!.onAvatarBoundsChanged!( + const Rect.fromLTWH(24, 76, 36, 36), + ); + header.onNativeReadyChanged!(true); + } + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + // The loaded list and header are ready, but unread history and DM + // labels must also settle before the background can be revealed. + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelsPage)), + ); + (container.read(channelsProvider.notifier) as _FakeNotifier) + .addUnread('1', _observed(id: 'late-mention', createdAt: 200)); + unreadCatchUp.complete(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + expect( + tester + .widget( + find.byKey(const Key('community-loading-backdrop')), + ) + .opacity, + 1, + ); + profiles.finish(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect( + tester.widget(find.text('general')).style!.fontWeight, + FontWeight.w700, + ); + expect(find.text('Alice ready'), findsOneWidget); + expect(progress.last, 0); + if (!reduceMotion) { + await tester.pump(const Duration(milliseconds: 65)); + expect(tester.getCenter(flyingAvatar).dx, greaterThan(195)); + await tester.pump(const Duration(milliseconds: 195)); + expect(tester.getCenter(flyingAvatar).dy, lessThan(422)); + expect( + tester.getCenter(flyingAvatar).dy, + greaterThan(destination.dy), + ); + expect( + tester.widget(flyingAvatar).transform.storage[0], + lessThan(1.5), + ); + expect( + find.byKey(const Key('community-switch-loading')), + findsNothing, + ); + final opacity = tester + .widget( + find.byKey(const Key('community-loading-backdrop')), + ) + .opacity; + expect(opacity, 1); + await tester.pump(const Duration(milliseconds: 259)); + expect( + (tester.getCenter(flyingAvatar) - + (nativeHeader ? const Offset(42, 94) : destination)) + .distance, + lessThan(0.5), + ); + await tester.pump(const Duration(milliseconds: 11)); + await tester.pump(); + final landed = tester.getCenter(flyingAvatar); + await tester.pump(const Duration(milliseconds: 80)); + expect(tester.getCenter(flyingAvatar), landed); + expect( + tester + .widget( + find.byKey(const Key('community-loading-backdrop')), + ) + .opacity, + allOf(greaterThan(0), lessThan(1)), + ); + } + await tester.pumpAndSettle(); + expect(notifier.switchedIds, ['bravo']); + expect( + find.byKey(const Key('community-switcher-page')), + findsNothing, + ); + if (nativeHeader) { + final bar = tester.widget( + find.byType(FrostedAppBar), + ); + expect(bar.nativeTitle, 'Bravo'); + expect(bar.nativeLeading!.avatarHidden, isFalse); + } else { + expect(find.text('Bravo'), findsOneWidget); + } + expect(progress.last, 0); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + } + + testWidgets('failed community switch restores the picker and allows retry', ( + tester, + ) async { + final communities = [ + Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ), + Community( + id: 'bravo', + name: 'Bravo', + relayUrl: 'wss://bravo.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities)..failSwitch = true; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith( + (ref) async => communities.first, + ), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + await tester.pumpAndSettle(); + expect( + find.text('Could not switch communities. Please try again.'), + findsOneWidget, + ); + expect(find.byKey(const Key('community-flying-avatar')), findsNothing); + expect( + tester + .widget(find.byKey(const Key('community-picker-opacity'))) + .opacity, + 1, + ); + notifier.failSwitch = false; + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + await tester.pumpAndSettle(); + expect(notifier.switchedIds, ['bravo', 'bravo']); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(tester.takeException(), isNull); + }); + + for (final timeout in [false, true]) { + testWidgets( + 'community loading failure can retry the active selection (timeout: $timeout)', + (tester) async { + final communities = [ + Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ), + Community( + id: 'bravo', + name: 'Bravo', + relayUrl: 'wss://bravo.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities); + final pending = Completer>(); + var retry = false; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier( + testChannels, + load: () { + if (notifier.activeId == 'alpha' || retry) { + return Future.value(testChannels); + } + return pending.future; + }, + ), + ), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith((ref) async { + await ref.watch(communityListProvider.future); + return communities.firstWhere( + (community) => community.id == notifier.activeId, + ); + }), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 440)); + await tester.pump(); + await tester.pump(); + expect( + find.byKey(const Key('community-switch-loading')), + findsOneWidget, + ); + if (timeout) { + await tester.pump(const Duration(seconds: 21)); + } else { + pending.completeError(StateError('Relay unavailable')); + await tester.pump(); + } + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('community-switcher-page')), + findsOneWidget, + ); + expect(find.byKey(const Key('community-switch-loading')), findsNothing); + expect( + find.text( + timeout + ? 'This community is taking longer to load. Select it to try again, or choose another.' + : 'Could not switch communities. Please try again.', + ), + findsOneWidget, + ); + retry = true; + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + await tester.pumpAndSettle(); + expect(notifier.switchedIds, ['bravo', 'bravo']); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.text('Bravo'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final count in [0, 1, 3, 12]) { + testWidgets('community grid fits $count communities with Add last', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final communities = [ + for (var i = 0; i < count; i++) + Community( + id: '$i', + name: 'Community $i', + relayUrl: 'wss://community-$i.example.com', + addedAt: DateTime(2025), + ), + ]; + await tester.pumpWidget( + buildTestable( + textScaler: TextScaler.linear(2), + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier([])), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier(communities), + ), + activeCommunityProvider.overrideWith( + (ref) async => communities.firstOrNull, + ), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(count == 0 ? 'Community' : 'Community 0')); + await tester.pumpAndSettle(); + final switchRow = find.byKey(const Key('community-menu-switch')); + await tester.ensureVisible(switchRow); + await tester.tap(switchRow); + await tester.pumpAndSettle(); + final add = find.byKey(const Key('community-switcher-add')); + await tester.ensureVisible(add); + await tester.pumpAndSettle(); + expect(tester.getRect(add).bottom, lessThanOrEqualTo(568)); + expect(tester.getRect(add).top, greaterThanOrEqualTo(0)); + if (count > 0) { + final last = find.byKey(Key('community-switcher-row-${count - 1}')); + final addPosition = tester.getTopLeft(add); + final lastPosition = tester.getTopLeft(last); + if (count.isOdd) { + expect(addPosition.dy, lastPosition.dy); + expect(addPosition.dx, greaterThan(lastPosition.dx)); + } else { + expect(addPosition.dy, greaterThan(lastPosition.dy)); + } + } + expect(tester.takeException(), isNull); + }); + } + + testWidgets('Add Community opens the pairing page after the picker exits', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier([]), + ), + activeCommunityProvider.overrideWith((ref) async => null), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Community')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-switcher-add'))); + // The pairing welcome bee repeats; advance finite frames instead of settling. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.byType(PairingPage), findsOneWidget); + expect(find.byType(FallbackPairingQrScanner), findsNothing); + expect(find.text('Scan a QR code'), findsOneWidget); + expect(find.text('Use pairing code'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + testWidgets('quick actions slide behind navigation when leaving home', ( tester, ) async { @@ -2670,10 +3487,14 @@ Widget _buildSettingsPage(BuildContext context) => class _FakeNotifier extends ChannelsNotifier { final List _channels; + final Future> Function()? load; + final Future? unreadCatchUp; final Map> _observedEventsByChannel; _FakeNotifier( this._channels, { + this.load, + this.unreadCatchUp, Map> observedEventsByChannel = const {}, }) : _observedEventsByChannel = { for (final entry in observedEventsByChannel.entries) @@ -2681,7 +3502,16 @@ class _FakeNotifier extends ChannelsNotifier { }; @override - Future> build() async => _channels; + Future> build() async => + load == null ? _channels : await load!(); + + @override + Future waitForUnreadCatchUp() async => await unreadCatchUp; + + void addUnread(String channelId, ObservedUnreadEvent event) { + (_observedEventsByChannel[channelId] ??= {})[event.id] = event; + state = AsyncData([...state.requireValue]); + } @override Future ensureDirectoryLoaded() async { @@ -2818,6 +3648,19 @@ class _FakeCommunityListNotifier extends CommunityListNotifier { List _communities; final List removedIds = []; + final List switchedIds = []; + bool failSwitch = false; + String activeId = 'alpha'; + Completer? switchCompleter; + + @override + Future switchCommunity(String id) async { + switchedIds.add(id); + if (failSwitch) throw StateError('Storage unavailable'); + await switchCompleter?.future; + activeId = id; + state = AsyncData([..._communities]); + } @override Future> build() async => _communities; @@ -2970,3 +3813,23 @@ class _CommunitySettingsLifecycle extends AppLifecycleNotifier { @override AppLifecycleState build() => AppLifecycleState.resumed; } + +class _SwitchingUserCache extends UserCacheNotifier { + final loaded = Completer(); + + @override + Map build() => {}; + + @override + UserProfile? get(String pubkey) => state[pubkey]; + + @override + Future preload(List pubkeys) => loaded.future; + + void finish() { + state = { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice ready'), + }; + loaded.complete(true); + } +} diff --git a/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart b/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart new file mode 100644 index 0000000000..836c73276c --- /dev/null +++ b/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart @@ -0,0 +1,154 @@ +part of '../channels_page_test.dart'; + +void _communityUnreadLandingTests( + Widget Function({required List overrides}) buildTestable, +) { + testWidgets('unread deadline lands the destination without a 20-second wait', ( + tester, + ) async { + final communities = [ + for (final id in ['alpha', 'bravo']) + Community( + id: id, + name: id, + relayUrl: 'wss://$id.example.com', + addedAt: DateTime(2025), + ), + ]; + final communitiesNotifier = _FakeCommunityListNotifier(communities); + final session = _UnreadDeadlineLandingSession(communitiesNotifier); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelSectionsProvider.overrideWith(_LandingSections.new), + channelStarsProvider.overrideWith(_LandingStars.new), + channelMutesProvider.overrideWith(_LandingMutes.new), + channelSortProvider.overrideWith(_LandingSort.new), + readStateProvider.overrideWith( + () => _FakeReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'pk', + contexts: {}, + version: 0, + ), + ), + ), + myPubkeyProvider.overrideWith((ref) => 'pk'), + relaySessionProvider.overrideWith(() => session), + communityListProvider.overrideWith(() => communitiesNotifier), + activeCommunityProvider.overrideWith((ref) async { + await ref.watch(communityListProvider.future); + return communities.firstWhere( + (c) => c.id == communitiesNotifier.activeId, + ); + }), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('alpha')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + + // Exercise the real ChannelsNotifier and actual picker. The whole landing + // must finish in three seconds of animation frames, not the error deadline. + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(session.destinationUnreadRequests, greaterThan(0)); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.byKey(const Key('community-switch-loading')), findsNothing); + expect(find.text('bravo'), findsOneWidget); + expect(find.text('bravo-channel'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} + +class _UnreadDeadlineLandingSession extends RelaySessionNotifier { + _UnreadDeadlineLandingSession(this.communities); + final _FakeCommunityListNotifier communities; + int destinationUnreadRequests = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + NostrEvent event(int kind, List> tags) => NostrEvent( + id: '$kind-${communities.activeId}', + pubkey: 'pk', + createdAt: 1, + kind: kind, + tags: tags, + content: '', + sig: 'sig', + ); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filters.length == 1 && filters.single.kinds.contains(39002)) { + return filters.single.until == null + ? [ + event(39002, [ + ['d', '${communities.activeId}-channel'], + ['p', 'pk'], + ]), + ] + : []; + } + if (communities.activeId == 'bravo' && + filters.isNotEmpty && + filters.every((f) => f.since != null)) { + destinationUnreadRequests++; + throw RelayException(503, '{"error":"query timed out"}'); + } + return []; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => filter.kinds.contains(39000) + ? [ + event(39000, [ + ['d', '${communities.activeId}-channel'], + ['name', '${communities.activeId}-channel'], + ['t', 'stream'], + ['public'], + ]), + ] + : []; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async => () {}; +} + +class _LandingSections extends ChannelSectionsNotifier { + @override + ChannelSectionsState build() => const ChannelSectionsState(isReady: true); +} + +class _LandingStars extends ChannelStarsNotifier { + @override + ChannelStarsState build() => const ChannelStarsState(isReady: true); +} + +class _LandingMutes extends ChannelMutesNotifier { + @override + ChannelMutesState build() => const ChannelMutesState(isReady: true); +} + +class _LandingSort extends ChannelSortNotifier { + @override + ChannelSortState build() => const ChannelSortState(isReady: true); +} diff --git a/mobile/test/features/channels/channels_provider_readiness_cases.dart b/mobile/test/features/channels/channels_provider_readiness_cases.dart new file mode 100644 index 0000000000..da7479e21a --- /dev/null +++ b/mobile/test/features/channels/channels_provider_readiness_cases.dart @@ -0,0 +1,177 @@ +part of 'channels_provider_test.dart'; + +void _unreadReadinessCases() { + test( + 'retired unread completion cannot overwrite destination settlement', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, 'me')], + metadata: [_meta(id: _channelA, name: 'Alpha')], + )..pauseNextUnreadCatchUpQuery(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await session.nextUnreadCatchUpQueryStarted; + final notifier = container.read(channelsProvider.notifier); + + session.memberships = [_membership(_channelB, 'me')]; + session.metadata = [_meta(id: _channelB, name: 'Bravo')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://bravo.example'); + await container.read(channelsProvider.future); + await notifier.waitForUnreadCatchUp(); + + // Alpha finishes after Bravo has already settled. Its finally path must + // not replace Bravo's generation, even when Alpha's request failed. + session.failClaimedUnreadCatchUpQuery = true; + session.resumePausedUnreadCatchUpQuery(); + await _settle(); + var settled = false; + unawaited(notifier.waitForUnreadCatchUp().then((_) => settled = true)); + await _settle(); + expect( + settled, + isTrue, + reason: 'Retired history overwrote destination settlement', + ); + expect( + container.read(channelsProvider).requireValue.single.id, + _channelB, + ); + }, + ); + + for (final failure in ['deadline', 'exception', 'read-state unavailable']) { + test('destination unread $failure permits degraded landing', () async { + final session = _UnreadFailureSession(failure); + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()), + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWith( + (ref) => ref.watch(_testPubkeyProvider), + ), + if (failure == 'read-state unavailable') + readStateProvider.overrideWith( + _UnavailableDestinationReadState.new, + ), + ], + ); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + final notifier = container.read(channelsProvider.notifier); + await notifier.waitForUnreadCatchUp(); + + session.memberships = [_membership(_channelB, 'me')]; + session.metadata = [_meta(id: _channelB, name: 'Bravo')]; + session.failDestination = true; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://bravo.example'); + expect( + (await container.read(channelsProvider.future)).single.id, + _channelB, + ); + var landed = false; + unawaited(notifier.waitForUnreadCatchUp().then((_) => landed = true)); + + if (failure != 'read-state unavailable') { + await session.started.future; + await _settle(); + expect(landed, isFalse); + session.emit( + const NostrEvent( + id: 'retained-mention', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelB], + ['p', 'me'], + ], + content: 'Hi', + sig: 'sig', + ), + ); + expect( + notifier.observedUnreadEventsByChannel[_channelB], + contains('retained-mention'), + ); + session.release.complete(); + } + // No 20-second presentation timeout or unrelated reconciliation needed. + await _settle(); + expect( + landed, + isTrue, + reason: 'Terminal history must settle this destination', + ); + expect( + container.read(channelsProvider).requireValue.single.id, + _channelB, + ); + if (failure != 'read-state unavailable') { + expect( + notifier.observedUnreadEventsByChannel[_channelB], + contains('retained-mention'), + ); + } + }); + } +} + +class _UnreadFailureSession extends _FakeRelaySession { + _UnreadFailureSession(this.failure) + : super( + memberships: [_membership(_channelA, 'me')], + metadata: [_meta(id: _channelA, name: 'Alpha')], + ); + + final String failure; + bool failDestination = false; + final started = Completer(); + final release = Completer(); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (failDestination && + filters.isNotEmpty && + filters.every((filter) => filter.since != null)) { + started.complete(); + await release.future; + if (failure == 'deadline') { + throw RelayException(503, '{"error":"query timed out"}'); + } + throw StateError('Unread history unavailable'); + } + return super.queryRelay(filters, timeout: timeout); + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (failDestination && + filter.since != null && + filter.kinds.contains(EventKind.streamMessageV2)) { + throw StateError('Unread fallback unavailable'); + } + return super.fetchHistory(filter, timeout: timeout); + } +} + +class _UnavailableDestinationReadState extends ReadStateNotifier { + @override + ReadStateState build() { + if (ref.watch(relayConfigProvider).baseUrl == 'https://bravo.example') { + throw StateError('Destination read state unavailable'); + } + return const ReadStateState.inert(); + } +} diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 6f3d4ea514..7e100548d5 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -7,9 +7,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; part 'channels_provider_live_cases.dart'; part 'channels_provider_terminal_cases.dart'; +part 'channels_provider_readiness_cases.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// @@ -24,6 +26,7 @@ part 'channels_provider_terminal_cases.dart'; /// records [subscribe] calls so we can assert filter shapes and emit live /// events on demand. void main() { + _unreadReadinessCases(); const myPk = 'me'; test( @@ -877,6 +880,117 @@ void main() { ); }); + test( + 'presentation waits for unread catch-up without delaying the channel list', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: const [ + NostrEvent( + id: 'initial-mention', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'hello', + sig: 'sig', + ), + ], + )..pauseNextUnreadCatchUpQuery(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + final channels = await container.read(channelsProvider.future); + expect(channels.single.name, 'general'); + final notifier = container.read(channelsProvider.notifier); + var ready = false; + final presentation = notifier.waitForUnreadCatchUp().then( + (_) => ready = true, + ); + await session.nextUnreadCatchUpQueryStarted; + await _settle(); + expect(ready, isFalse); + expect(notifier.observedUnreadEventsByChannel, isEmpty); + + session.resumePausedUnreadCatchUpQuery(); + await presentation; + expect( + notifier.observedUnreadEventsByChannel[_channelA], + contains('initial-mention'), + ); + expect(ready, isTrue); + }, + ); + + test( + 'landing waits for destination unread generation after disconnect', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'Alpha')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + final notifier = container.read(channelsProvider.notifier); + await notifier.waitForUnreadCatchUp(); + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'Bravo')]; + session.pauseNextSubscribe(); + final subscribing = session.nextSubscribeStarted; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://bravo.example'); + expect( + (await container.read(channelsProvider.future)).single.id, + _channelB, + ); + await subscribing; + var ready = false; + final landing = notifier.waitForUnreadCatchUp().then((_) => ready = true); + session.setStatus(SessionStatus.disconnected); + session.resumePausedSubscribe(); + await _settle(); + expect( + ready, + isFalse, + reason: 'Alpha history cannot satisfy Bravo readiness', + ); + + session.recentMessages = const [ + NostrEvent( + id: 'bravo-mention', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelB], + ['p', myPk], + ], + content: 'Bravo mention', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final catchingUp = session.nextUnreadCatchUpQueryStarted; + session.setStatus(SessionStatus.connected); + await catchingUp; + await _settle(); + expect(ready, isFalse); + session.resumePausedUnreadCatchUpQuery(); + await landing; + expect( + notifier.observedUnreadEventsByChannel[_channelB], + contains('bravo-mention'), + ); + expect(ready, isTrue); + }, + ); + test('community switch discards a parked unread catch-up', () async { final session = _FakeRelaySession( memberships: const [], @@ -2201,7 +2315,7 @@ void main() { ); test( - 'refreshes cached channels after a disconnected community switch', + 'waits for destination channels after a disconnected community switch', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -2222,12 +2336,21 @@ void main() { .read(relayConfigProvider.notifier) .update(baseUrl: 'https://new-community.example'); await Future.delayed(Duration.zero); - expect(container.read(channelsProvider).value?.single.name, 'general'); + expect(container.read(channelsProvider).isLoading, isTrue); + expect(container.read(channelsProvider.notifier).hasLoaded, isFalse); + var destinationReady = false; + final destination = container.read(channelsProvider.future).then((value) { + destinationReady = true; + return value; + }); + await _settle(); + expect(destinationReady, isFalse); session.setStatus(SessionStatus.connected); await Future.delayed(Duration.zero); - expect(container.read(channelsProvider).value?.single.name, 'random'); + expect((await destination).single.name, 'random'); + expect(container.read(channelsProvider).isLoading, isFalse); }, ); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 150ccd4290..a829d4b4cc 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1094,6 +1094,13 @@ void main() { await tester.pump(); expect(find.byType(BuzzLoadingIndicator), findsOneWidget); + final spinner = find.byType(BuzzLoadingIndicator); + expect(tester.getSize(spinner), const Size.square(18)); + expect( + tester.widget(spinner).color, + tester.element(spinner).colors.onSecondaryContainer, + ); + expect(find.byType(CircularProgressIndicator), findsNothing); expect( find.bySemanticsLabel('Cancel voice note loading'), diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 74840cd5ff..b34ef51ae3 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -285,13 +285,20 @@ void main() { expect(notifier.denied, isTrue); }); - testWidgets('keeps the add-community header outside SAS', (tester) async { + testWidgets('keeps only Back on the clear add-community header', ( + tester, + ) async { await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)), ); expect(find.byType(AppBar), findsOneWidget); - expect(find.text('Add Community'), findsOneWidget); + expect(find.text('Add Community'), findsNothing); + final appBar = tester.widget(find.byType(AppBar)); + expect(appBar.backgroundColor, Colors.transparent); + expect(appBar.surfaceTintColor, Colors.transparent); + expect(appBar.elevation, 0); + expect(appBar.scrolledUnderElevation, 0); expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget); }); diff --git a/mobile/test/fixtures/community_icons/compressed-16000.png b/mobile/test/fixtures/community_icons/compressed-16000.png new file mode 100644 index 0000000000000000000000000000000000000000..73759b553b2d675ec4693e541cd7b56c0a65b58f GIT binary patch literal 248907 zcmeI*ziLx)90l+jo1qk{RVack?!G{mN-FVBmr^91Rn#%NTZ7WUt&58fp_7Y?xCy#j zo%#UcP_UC&bBjyv{oNNh_Y)}eenW@zIhit?zS`Znx4Lp|rK+mc?fc^gRds7t|18fB z)zkIag=*>RmF@A?^1PBlyK!5-N0t5&UXb2b#Xn=SmK!5-N0t5&UAV7cs0RrIy1_Qzi z7LNb{0t5&UAV7cs0RjXFGz1I=G(bEOAV7cs0RjXF5FkK+0D*7;g8|_Mi${O}0RjXF z5FkK+009C7ngRv`nj)SF5FkK+009C72oNAZfIzl@!GP?3r6WLq009C72oNAZfB*pk zO#y=eO%cxo2oNAZfB*pk1PBlyKp1PBlyK!5-N0t5&U2o^9H5L~Wk1PBlyK!5-N0t5&U zAV8oXU@)M7;zWP|0RjXF5FkK+009C7f&~l)1eYrs0RjXF5FkK+009C72oNX<7z`+> zxDg;gfB*pk1PBlyK!5;&Tmgdtx!p=efB*pk1PBlyK!5-N0t89|1_MeeZUhJrAV7cs z0RjXF5FkJxSHNIEZnu&VAV7cs0RjXF5FkK+0D+=_!GI!*BLM;g2oNAZfB*pk1PBm_ z6)+eOTdiOO2oNAZfB*pk1PBlyK%gjKFrdidNPqwV0t5&UAV7cs0RjYK1q=qnRx20* z0t5&UAV7cs0RjXF5GV^63@E#}5+Fc;009C72oNAZfB=D10fPal&B{f9009C72oNAZ zfB*pk1j+&i1IjM01PBlyK!5-N0t5&UAV45hz+gaXvvLt2K!5-N0t5&UAV7csferzK z0Ua>T1PBlyK!5-N0t5&UAV45gz+gaVv0@P*K!5-N0t5&UAV7csferzK0Ua>T1PBly zK!5-N0t5&UAV45gz+gaVv0@P*K!5-N0t5&UAV7csfi3}q0bMfg1PBlyK!5-N0t5&U zAV45fz+gaTuTl{pK!5-N0t5&UAV7csfi3}q0bMfg1PBlyK!5-N0t5&UAV45fz+gaT zuTl{pK!5-N0t5&UAV7csfldK~0i877AV7cs0RjXF5FkK+009D#0tN#jYZZzB0RjXF z5FkK+009C72y_Y<4Ctis1_1&D2oNAZfB*pk1PBm_6nK4lJgVle(c#D6)y+x$s}}(R h1PBlyK!Cu1fvZoZ)!^BOFK_DS?K?Z;x3?d?_y^Z(Uy=X- literal 0 HcmV?d00001 diff --git a/mobile/test/fixtures/community_icons/compressed-8192.png b/mobile/test/fixtures/community_icons/compressed-8192.png new file mode 100644 index 0000000000000000000000000000000000000000..d33e453734f42508daf8acb991a9c393e304b1c2 GIT binary patch literal 65303 zcmeI*Jx&5a7zW@WQV=4Fg^e~8-a#4?G5AA6jDgr_M0#v|BjMK7H<@TV{y zz*rDtVq!p+jw{SQTTFI0+2Vb^sit`M`k=8_$gkx?2!;B7?J$JW^tKdo(={9oZ~GyP z-|Mw%^WyDun8{3E_HM=xU%BK17XbnU2oNAZfB*pk1PBly5Vb(>^=ctG3<#yk^X@^* zMSuVS0t5&UAV7cs0RjXF#4d2Qyp&DO0(yU^>($u9%13|z0RjXF5FkK+009C72(VAV7cs0RjXF5FkJxWdaHVQZ{gL2@oJafB*pk1PBlyK!AY40DFJ{0RjXF5FkK+ z009C72&79uVL-a(EiC~81PBlyK!5-N0t5&U=vT_c*4jifN3_&phHF3ze0ECdJ;AV7cs0RjXF5Fn5` Yfya~M5Hjc0-*ytczSF4PZ?`Z10mOW)UH||9 literal 0 HcmV?d00001 diff --git a/mobile/test/fixtures/community_icons/landscape-1024.png b/mobile/test/fixtures/community_icons/landscape-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..fa321f9c4056d27c7fdaee185819e3641d3302cf GIT binary patch literal 588 zcmeAS@N?(olHy`uVBq!ia0y~yU;#3j7&w3=gXJ>ER3Jy#)5S5Q;?~=PjUd6p3w{RA kf4~;X2;z)_5g7sp%$X_}7`K=>>wvuP>FVdQ&MBb@0KZlfZU6uP literal 0 HcmV?d00001 diff --git a/mobile/test/shared/community/community_icon_provider_test.dart b/mobile/test/shared/community/community_icon_provider_test.dart index b2992c3967..aecc3d45d7 100644 --- a/mobile/test/shared/community/community_icon_provider_test.dart +++ b/mobile/test/shared/community/community_icon_provider_test.dart @@ -1,93 +1,547 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:buzz/shared/community/community_icon_cache.dart'; +import 'package:buzz/shared/emoji/emoji_avatar.dart'; import 'package:buzz/shared/community/community_icon_provider.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart' as http_testing; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { - test('fetches the NIP-11 icon over the relay HTTP URL', () async { - late http.Request capturedRequest; - final client = http_testing.MockClient((request) async { - capturedRequest = request; - return http.Response('{"icon":"data:image/png;base64,icon-bytes"}', 200); + const relay = 'wss://relay.example.com'; + const key = 'https://relay.example.com/'; + final artwork = emojiAvatarDataUrl('🐝', 0xFFFFCC00); + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + ProviderContainer containerFor( + Future Function(http.Request) handler, + ) { + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + communityIconHttpClientProvider.overrideWithValue(MockClient(handler)), + ], + ); + addTearDown(container.dispose); + return container; + } + + test('fetches and saves inline NIP-11 artwork using public HTTP', () async { + final container = containerFor((request) async { + expect(request.url.toString(), key); + expect(request.headers['Accept'], 'application/nostr+json'); + expect(request.headers.containsKey('authorization'), isFalse); + return http.Response(jsonEncode({'icon': artwork}), 200); }); - final container = ProviderContainer( - overrides: [communityIconHttpClientProvider.overrideWithValue(client)], - ); - addTearDown(container.dispose); - - final icon = await container.read( - communityIconProvider('wss://relay.example.com').future, - ); - - expect(capturedRequest.url, Uri.parse('https://relay.example.com')); - expect(capturedRequest.headers['Accept'], 'application/nostr+json'); - expect(icon, 'data:image/png;base64,icon-bytes'); + expect(await container.read(communityIconProvider(relay).future), artwork); + expect(container.read(communityIconCacheProvider)[key], artwork); + expect(prefs.getString('buzz.community-icons.v3'), contains('image/svg')); }); - test('returns null when the relay has no usable icon', () async { - final client = http_testing.MockClient( - (_) async => http.Response('{"icon":""}', 200), - ); - final container = ProviderContainer( - overrides: [communityIconHttpClientProvider.overrideWithValue(client)], - ); - addTearDown(container.dispose); + test( + 'saved artwork is synchronous after reopening and an offline restart', + () async { + final first = containerFor( + (_) async => http.Response(jsonEncode({'icon': artwork}), 200), + ); + final presentation = communityIconPresentationProvider(relay); + final listener = first.listen(presentation, (_, _) {}); + await first.read(communityIconProvider(relay).future); + listener.close(); + await first.pump(); + first.invalidate(communityIconProvider); + expect(first.read(presentation), artwork); - final icon = await container.read( - communityIconProvider('https://relay.example.com').future, - ); + final response = Completer(); + final restarted = containerFor((_) => response.future); + final subscription = restarted.listen(presentation, (_, _) {}); + addTearDown(subscription.close); + expect(restarted.read(presentation), artwork); + expect( + restarted.read( + communityIconPresentationProvider('wss://other.example.com'), + ), + isNull, + ); + response.complete(http.Response('offline', 503)); + await restarted.read(communityIconProvider(relay).future); + expect(restarted.read(presentation), artwork); + }, + ); - expect(icon, isNull); + test( + 'downloads remote image bytes and retains them when refresh fails', + () async { + var offline = false; + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAABJRU5ErkJggg==', + ); + final container = containerFor((request) async { + if (offline) return http.Response('offline', 503); + if (request.url.path == '/icon.png') { + expect(request.headers.containsKey('authorization'), isFalse); + return http.Response.bytes( + png, + 200, + headers: {'content-type': 'image/png'}, + ); + } + return http.Response( + '{"icon":"https://relay.example.com/icon.png"}', + 200, + ); + }); + final provider = communityIconProvider(relay); + final subscription = container.listen(provider, (_, _) {}); + addTearDown(subscription.close); + final saved = await container.read(provider.future); + expect(saved, isNotNull); + final dimensions = await _dimensions( + UriData.parse(saved!).contentAsBytes(), + ); + expect(dimensions, (1, 1)); + offline = true; + container.invalidate(provider); + expect(await container.read(provider.future), saved); + final restarted = containerFor( + (_) async => http.Response('offline', 503), + ); + expect(restarted.read(communityIconPresentationProvider(relay)), saved); + }, + ); + + test( + 'refresh replaces changed artwork and clears explicitly removed icons', + () async { + var document = jsonEncode({'icon': artwork}); + final container = containerFor((_) async => http.Response(document, 200)); + final provider = communityIconProvider(relay); + final presentation = communityIconPresentationProvider(relay); + final subscription = container.listen(presentation, (_, _) {}); + addTearDown(subscription.close); + await container.read(provider.future); + final newArtwork = emojiAvatarDataUrl('🥳', 0xFFFF8652); + document = jsonEncode({'icon': newArtwork}); + container.invalidate(provider); + expect(container.read(presentation), artwork); + await container.read(provider.future); + expect(container.read(presentation), newArtwork); + document = '{"icon":""}'; + container.invalidate(provider); + await container.read(provider.future); + expect(container.read(presentation), isNull); + expect(container.read(communityIconCacheProvider), isEmpty); + }, + ); + + test('an invalidated older lookup cannot overwrite newer artwork', () async { + final slow = Completer(); + var calls = 0; + final container = containerFor( + (_) => ++calls == 1 + ? slow.future + : Future.value(http.Response(jsonEncode({'icon': artwork}), 200)), + ); + final provider = communityIconProvider(relay); + final subscription = container.listen(provider, (_, _) {}); + addTearDown(subscription.close); + final oldLookup = container.read(provider.future); + container.invalidate(provider); + await container.read(provider.future); + slow.complete(http.Response('{"icon":""}', 200)); + await oldLookup; + expect(container.read(communityIconCacheProvider)[key], artwork); }); - test('returns null when relay information cannot be loaded', () async { - final client = http_testing.MockClient( - (_) async => http.Response('unavailable', 503), + test('ignores corrupt persistence and bounds cache entries', () async { + await prefs.setString('buzz.community-icons.v3', '{bad json'); + final container = containerFor((_) async => http.Response('{}', 200)); + expect(container.read(communityIconCacheProvider), isEmpty); + final cache = container.read(communityIconCacheProvider.notifier); + for (var i = 0; i < 35; i++) { + await cache.remember('https://relay$i.example.com/', artwork); + } + expect(container.read(communityIconCacheProvider).length, 32); + expect( + container + .read(communityIconCacheProvider) + .containsKey('https://relay0.example.com/'), + isFalse, ); - final container = ProviderContainer( - overrides: [communityIconHttpClientProvider.overrideWithValue(client)], - ); - addTearDown(container.dispose); - - final icon = await container.read( - communityIconProvider('https://relay.example.com').future, - ); - - expect(icon, isNull); }); - test('refreshes a transient failure when explicitly invalidated', () async { - var requestCount = 0; - final client = http_testing.MockClient((_) async { - requestCount++; - if (requestCount == 1) { - return http.Response('unavailable', 503); - } - return http.Response( - '{"icon":"https://relay.example.com/icon.png"}', - 200, + for (final saved in [false, true]) { + for (final rejection in ['oversized', 'invalid MIME', 'HTTP failure']) { + test('$rejection never exposes an unchecked URL, saved=$saved', () async { + final container = containerFor((request) async { + if (request.url.path == '/') { + return http.Response( + '{"icon":"https://relay.example.com/rejected.png"}', + 200, + ); + } + return http.Response.bytes( + List.filled(rejection == 'oversized' ? 256 * 1024 + 1 : 4, 0), + rejection == 'HTTP failure' ? 503 : 200, + headers: { + 'content-type': rejection == 'invalid MIME' + ? 'text/html' + : 'image/png', + }, + ); + }); + if (saved) { + await container + .read(communityIconCacheProvider.notifier) + .remember(key, artwork); + } + final presentation = communityIconPresentationProvider(relay); + final subscription = container.listen(presentation, (_, _) {}); + addTearDown(subscription.close); + final expected = saved ? artwork : null; + expect( + await container.read(communityIconProvider(relay).future), + expected, + ); + expect(container.read(presentation), expected); + expect(container.read(communityIconCacheProvider)[key], expected); + }); + } + } + + for (final stalledImage in [false, true]) { + test('timeout aborts the request, image=$stalledImage', () async { + var aborted = false; + var pending = false; + final started = Completer(); + final client = MockClient.streaming((request, _) async { + if (stalledImage && request.url.path == '/') { + return http.StreamedResponse( + Stream.value( + utf8.encode('{"icon":"https://relay.example.com/stalled.png"}'), + ), + 200, + ); + } + expect(request, isA()); + pending = true; + started.complete(); + await (request as http.AbortableRequest).abortTrigger; + pending = false; + aborted = true; + throw http.RequestAbortedException(request.url); + }); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + communityIconHttpClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + final subscription = container.listen( + communityIconProvider(relay), + (_, _) {}, + ); + addTearDown(subscription.close); + final result = container.read(communityIconProvider(relay).future); + await started.future; + expect(pending, isTrue); + expect(await result, isNull); + expect(aborted, isTrue); + expect(pending, isFalse); + }); + } + + for (final inline in [false, true]) { + test('rejects compressed oversized dimensions, inline=$inline', () async { + final bytes = await File( + 'test/fixtures/community_icons/compressed-16000.png', + ).readAsBytes(); + expect(bytes.length, lessThan(256 * 1024)); + // Read the encoded descriptor, never allocate this 256-million-pixel image. + expect(await _dimensions(bytes), (16000, 16000)); + final source = Uri.dataFromBytes(bytes, mimeType: 'image/png').toString(); + final container = containerFor((request) async { + if (request.url.path == '/') { + return http.Response( + jsonEncode({ + 'icon': inline ? source : 'https://relay.example.com/icon.png', + }), + 200, + ); + } + return http.Response.bytes( + bytes, + 200, + headers: {'content-type': 'image/png'}, + ); + }); + expect(await container.read(communityIconProvider(relay).future), isNull); + expect(container.read(communityIconCacheProvider), isEmpty); + expect(prefs.getString('buzz.community-icons.v3'), isNull); + final restarted = containerFor( + (_) async => http.Response('offline', 503), + ); + expect(restarted.read(communityIconPresentationProvider(relay)), isNull); + }); + } + + test( + 'old cache cannot reintroduce oversized artwork after restart', + () async { + final bytes = await File( + 'test/fixtures/community_icons/compressed-16000.png', + ).readAsBytes(); + await prefs.setString( + 'buzz.community-icons.v1', + jsonEncode({ + key: Uri.dataFromBytes(bytes, mimeType: 'image/png').toString(), + }), + ); + final container = containerFor( + (_) async => http.Response('offline', 503), + ); + expect(container.read(communityIconPresentationProvider(relay)), isNull); + expect(container.read(communityIconCacheProvider), isEmpty); + }, + ); + + for (final remote in [false, true]) { + test('rejects SVG-wrapped oversized raster, remote=$remote', () async { + final bytes = await File( + 'test/fixtures/community_icons/compressed-8192.png', + ).readAsBytes(); + expect(await _dimensions(bytes), (8192, 8192)); + final svg = + '' + '' + ''; + expect(utf8.encode(svg).length, lessThan(256 * 1024)); + final wrapped = Uri.dataFromString( + svg, + mimeType: 'image/svg+xml', + encoding: utf8, + ).toString(); + final container = containerFor((request) async { + if (request.url.path == '/icon.svg') { + return http.Response( + svg, + 200, + headers: {'content-type': 'image/svg+xml'}, + ); + } + return http.Response( + jsonEncode({'icon': remote ? '${key}icon.svg' : wrapped}), + 200, + ); + }); + expect(await container.read(communityIconProvider(relay).future), isNull); + expect(container.read(communityIconCacheProvider), isEmpty); + expect(prefs.getString('buzz.community-icons.v3'), isNull); + + // Even an entry persisted by the previous version must not render. + await prefs.setString( + 'buzz.community-icons.v2', + jsonEncode({key: wrapped}), + ); + final restarted = containerFor( + (_) async => http.Response('offline', 503), + ); + expect(restarted.read(communityIconPresentationProvider(relay)), isNull); + expect(restarted.read(communityIconCacheProvider), isEmpty); + }); + } + + for (final radius in ['', ' rx="112"', ' rx="256"']) { + test('retains desktop emoji artwork with radius $radius', () async { + final svg = UriData.parse( + artwork, + ).contentAsString(encoding: utf8).replaceFirst(' rx="256"', radius); + final container = containerFor( + (_) async => http.Response( + jsonEncode({ + 'icon': Uri.dataFromString( + svg, + mimeType: 'image/svg+xml', + encoding: utf8, + ).toString(), + }), + 200, + ), + ); + expect( + await container.read(communityIconProvider(relay).future), + artwork, ); }); - final container = ProviderContainer( - overrides: [communityIconHttpClientProvider.overrideWithValue(client)], - ); - final provider = communityIconProvider('https://relay.example.com'); - final subscription = container.listen(provider, (_, _) {}); - addTearDown(() { - subscription.close(); - container.dispose(); + } + + for (final extra in [ + '', + '', + '', + ]) { + test('rejects resource added to otherwise valid emoji: $extra', () async { + final svg = UriData.parse( + artwork, + ).contentAsString(encoding: utf8).replaceFirst('', '$extra'); + final container = containerFor( + (_) async => http.Response( + jsonEncode({ + 'icon': Uri.dataFromString( + svg, + mimeType: 'image/svg+xml', + encoding: utf8, + ).toString(), + }), + 200, + ), + ); + await container + .read(communityIconCacheProvider.notifier) + .remember(key, artwork); + expect( + await container.read(communityIconProvider(relay).future), + artwork, + ); + expect(container.read(communityIconCacheProvider)[key], artwork); }); + } - expect(await container.read(provider.future), isNull); - expect(requestCount, 1); + test( + 'cached raster is bounded before rendering and after offline restart', + () async { + final bytes = await File( + 'test/fixtures/community_icons/landscape-1024.png', + ).readAsBytes(); + expect(await _dimensions(bytes), (1024, 512)); + final container = containerFor( + (_) async => http.Response( + jsonEncode({ + 'icon': Uri.dataFromBytes(bytes, mimeType: 'image/png').toString(), + }), + 200, + ), + ); + final subscription = container.listen( + communityIconProvider(relay), + (_, _) {}, + ); + addTearDown(subscription.close); + final saved = await container.read(communityIconProvider(relay).future); + expect(saved, isNotNull); + expect(await _dimensions(UriData.parse(saved!).contentAsBytes()), ( + 512, + 256, + )); + final restarted = containerFor( + (_) async => http.Response('offline', 503), + ); + final restored = restarted.read(communityIconPresentationProvider(relay)); + expect(restored, saved); + expect(await _dimensions(UriData.parse(restored!).contentAsBytes()), ( + 512, + 256, + )); + }, + ); - container.invalidate(provider); - - expect( - await container.read(provider.future), - 'https://relay.example.com/icon.png', + for (final stalledImage in [false, true]) { + test( + 'invalidation and disposal abort pending headers, image=$stalledImage', + () async { + var started = 0; + var active = 0; + var aborted = 0; + final client = MockClient.streaming((request, _) async { + if (stalledImage && request.url.path == '/') { + return http.StreamedResponse( + Stream.value( + utf8.encode('{"icon":"https://relay.example.com/stalled.png"}'), + ), + 200, + ); + } + started++; + active++; + await (request as http.AbortableRequest).abortTrigger; + active--; + aborted++; + throw http.RequestAbortedException(request.url); + }); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + communityIconHttpClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + final provider = communityIconProvider(relay); + final subscription = container.listen(provider, (_, _) {}); + for (var iteration = 0; iteration < 3; iteration++) { + await _waitFor(() => started == iteration + 1); + expect(active, 1); + container.invalidate(provider); + await _waitFor(() => aborted == iteration + 1); + } + await _waitFor(() => started == 4); + expect(active, 1); + subscription.close(); + await container.pump(); + await _waitFor(() => active == 0); + expect(aborted, 4); + }, ); - expect(requestCount, 2); + } + + test('oversized downloads do not replace a usable saved image', () async { + final container = containerFor( + (request) async => request.url.path == '/' + ? http.Response('{"icon":"https://relay.example.com/large.png"}', 200) + : http.Response.bytes( + List.filled(256 * 1024 + 1, 0), + 200, + headers: {'content-type': 'image/png'}, + ), + ); + await container + .read(communityIconCacheProvider.notifier) + .remember(key, artwork); + expect(await container.read(communityIconProvider(relay).future), artwork); }); } + +Future<(int, int)> _dimensions(Uint8List bytes) async { + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + try { + final descriptor = await ui.ImageDescriptor.encoded(buffer); + try { + return (descriptor.width, descriptor.height); + } finally { + descriptor.dispose(); + } + } finally { + buffer.dispose(); + } +} + +Future _waitFor(bool Function() ready) async { + for (var i = 0; i < 100 && !ready(); i++) { + await Future.delayed(Duration.zero); + } + expect(ready(), isTrue, reason: 'Request cancellation did not settle'); +} diff --git a/mobile/test/shared/widgets/frosted_scaffold_test.dart b/mobile/test/shared/widgets/frosted_scaffold_test.dart index 16a958f0e9..2c11bee975 100644 --- a/mobile/test/shared/widgets/frosted_scaffold_test.dart +++ b/mobile/test/shared/widgets/frosted_scaffold_test.dart @@ -67,43 +67,47 @@ void main() { ); }); - testWidgets('page divider appears only after content scrolls under header', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - home: FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Theme')), - body: ListView.builder( - key: const ValueKey('page-scroll-view'), - padding: const EdgeInsets.only(top: 57), - itemCount: 40, - itemBuilder: (_, index) => - SizedBox(height: 48, child: Text('Theme option $index')), + testWidgets( + 'page frost and divider appear only while content scrolls under header', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Theme')), + body: ListView.builder( + key: const ValueKey('page-scroll-view'), + padding: const EdgeInsets.only(top: 57), + itemCount: 40, + itemBuilder: (_, index) => + SizedBox(height: 48, child: Text('Theme option $index')), + ), ), ), - ), - ); + ); - expect(_appBarBorder(tester)?.bottom.color.a, 0); + expect(_appBarBorder(tester)?.bottom.color.a, 0); + expect(find.byType(BackdropFilter), findsNothing); - await tester.drag( - find.byKey(const ValueKey('page-scroll-view')), - const Offset(0, -120), - ); - await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const ValueKey('page-scroll-view')), + const Offset(0, -120), + ); + await tester.pumpAndSettle(); - final border = _appBarBorder(tester); - expect(border, isNotNull); - expect(border!.bottom.color.a, greaterThan(0)); + final border = _appBarBorder(tester); + expect(border, isNotNull); + expect(border!.bottom.color.a, greaterThan(0)); + expect(find.byType(BackdropFilter), findsOneWidget); - await tester.drag( - find.byKey(const ValueKey('page-scroll-view')), - const Offset(0, 500), - ); - await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const ValueKey('page-scroll-view')), + const Offset(0, 500), + ); + await tester.pumpAndSettle(); - expect(_appBarBorder(tester)?.bottom.color.a, 0); - }); + expect(_appBarBorder(tester)?.bottom.color.a, 0); + expect(find.byType(BackdropFilter), findsNothing); + }, + ); } diff --git a/mobile/test/shared/widgets/ios_navigation_bar_test.dart b/mobile/test/shared/widgets/ios_navigation_bar_test.dart index 9667849258..b923e9c247 100644 --- a/mobile/test/shared/widgets/ios_navigation_bar_test.dart +++ b/mobile/test/shared/widgets/ios_navigation_bar_test.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'package:buzz/shared/relay/media_image.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/frosted_scaffold.dart'; @@ -9,6 +13,223 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { + testWidgets( + 'native readiness waits for configuration and layout acknowledgement', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/ios_navigation_bar/844'); + final applied = Completer(); + final readiness = []; + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + calls.add(call.method); + if (call.method == 'prepareForReveal') await applied.future; + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.pumpWidget( + _testApp( + home: SizedBox( + height: 100, + child: IosNavigationBar( + title: 'Bravo', + onReadyChanged: readiness.add, + ), + ), + ), + ); + tester.widget(find.byType(UiKitView)).onPlatformViewCreated!( + 844, + ); + await tester.pumpAndSettle(); + expect(calls, contains('prepareForReveal')); + expect(readiness.last, false); + applied.complete(); + await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(); + expect(readiness.last, true); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets('native landing ignores pending profile artwork', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final pending = Completer(); + var requested = false; + final client = MockClient((_) { + requested = true; + return pending.future; + }); + addTearDown(client.close); + const channel = MethodChannel('buzz/ios_navigation_bar/846'); + final readiness = []; + final calls = []; + final createdChannels = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + (call) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final native = MethodChannel('${IosNavigationBar.viewType}/$id'); + createdChannels.add(native); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + native, + (call) async { + calls.add(call.method); + return null; + }, + ); + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ); + for (final native in createdChannels) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + native, + null, + ); + } + }); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + calls.add(call.method); + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [mediaHttpClientProvider.overrideWithValue(client)], + child: _testApp( + home: IosNavigationBar( + title: 'Bravo', + leading: IosNavigationAction( + label: 'Community settings', + avatarInitial: 'B', + onAvatarBoundsChanged: (_) {}, + ), + actions: const [ + IosNavigationAction( + label: 'Profile', + avatarInitial: 'P', + imageUrl: 'https://slow.example.com/profile.png', + ), + ], + onReadyChanged: readiness.add, + ), + ), + ), + ); + tester.widget(find.byType(UiKitView)).onPlatformViewCreated!( + 846, + ); + await tester.pumpAndSettle(); + Map leading() => + (tester.widget(find.byType(UiKitView)).creationParams + as Map)['leading'] + as Map; + await tester.runAsync(() async { + for ( + var attempt = 0; + attempt < 100 && leading()['imageData'] == null; + attempt++ + ) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + }); + await tester.pumpAndSettle(); + expect(requested, isTrue); + expect(pending.isCompleted, isFalse); + expect(calls, contains('prepareForReveal')); + expect(readiness.last, isTrue); + expect(leading()['imageData'], isNotNull); + pending.complete(http.Response('offline', 503)); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'native avatar reports global transition bounds and retains a hidden slot', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('${IosNavigationBar.viewType}/842'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + Rect? measured; + await tester.pumpWidget( + _testApp( + home: Padding( + padding: const EdgeInsets.only(top: 30), + child: IosNavigationBar( + title: 'Alpha', + leading: IosNavigationAction( + label: 'Community settings', + avatarInitial: 'A', + avatarHidden: true, + onAvatarBoundsChanged: (bounds) => measured = bounds, + onPressed: () {}, + ), + ), + ), + ), + ); + final view = tester.widget(find.byType(UiKitView)); + final leading = (view.creationParams as Map)['leading'] as Map; + expect(leading['tracksAvatarBounds'], isTrue); + expect(leading['avatarHidden'], isTrue); + view.onPlatformViewCreated!(842); + await tester.pump(); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + const MethodCall('avatarBounds', { + 'id': 'leading', + 'x': 16.0, + 'y': 4.0, + 'width': 36.0, + 'height': 36.0, + }), + ), + (_) {}, + ); + expect(measured, const Rect.fromLTWH(16, 34, 36, 36)); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + for (final reverse in [false, true]) { testWidgets('initial material tracks content above reverse=$reverse', ( tester, diff --git a/mobile/test/shared/widgets/skeleton_test.dart b/mobile/test/shared/widgets/skeleton_test.dart index 5b2d25bd63..7256a84452 100644 --- a/mobile/test/shared/widgets/skeleton_test.dart +++ b/mobile/test/shared/widgets/skeleton_test.dart @@ -29,6 +29,7 @@ void main() { Widget buildRevealTestable({ required ValueNotifier loading, bool disableAnimations = false, + ValueChanged? onReadyChanged, }) { return MaterialApp( theme: AppTheme.light(), @@ -43,6 +44,7 @@ void main() { valueListenable: loading, builder: (context, isLoading, _) => SkeletonReveal( loading: isLoading, + onReadyChanged: onReadyChanged, skeleton: const SkeletonBar(width: 120, height: 16), content: const Text('Loaded content'), ), @@ -51,6 +53,28 @@ void main() { ); } + testWidgets('reports ready only after the loaded reveal finishes', ( + tester, + ) async { + final loading = ValueNotifier(true); + addTearDown(loading.dispose); + final readiness = []; + await tester.pumpWidget( + buildRevealTestable(loading: loading, onReadyChanged: readiness.add), + ); + expect(readiness.last, false); + loading.value = false; + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(readiness.last, false); + await tester.pump(const Duration(milliseconds: 220)); + expect(readiness.last, true); + loading.value = true; + await tester.pump(); + expect(readiness.last, false); + }); + double layerOpacity(WidgetTester tester, String key) => tester.widget(find.byKey(Key(key))).opacity;