diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 3d2d70c9c4..310ab676b7 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -146,6 +146,7 @@ jobs: xcrun simctl boot "$device" xcrun simctl bootstatus "$device" -b /bin/bash scripts/test-mobile-pagination-accessibility.sh "$device" + /bin/bash scripts/test-mobile-member-picker.sh "$device" - name: Build complete unsigned iOS release run: | /bin/bash scripts/test-mobile-gateway-recipes.sh diff --git a/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift b/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift new file mode 100644 index 0000000000..dedb06f245 --- /dev/null +++ b/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift @@ -0,0 +1,46 @@ +import Flutter +import UIKit + +@main +struct AddMembersIdentityTests { + @MainActor static func main() { + let keys = [String(repeating: "a", count: 64), String(repeating: "b", count: 64)] + let labels = ["Scout · aaaaaaaa", "Scout · bbbbbbbb"] + let users: [[String: Any]] = zip(keys, labels).map { key, label in + ["pubkey": key, "name": label, "detail": "Same owner", "agent": true, "initial": "S", "avatarKey": key] + } + var selected: [String] = [] + var avatarReplies: [String: (Data?) -> Void] = [:] + let avatar = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { context in + UIColor.blue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 40)) + }.pngData() + let picker = NativeAddMembersViewController( + state: ["users": users, "selected": [], "query": "", "loading": false], + loadAvatar: { key, _, reply in avatarReplies[key] = reply }, + send: { event, values in + if event == "toggle", let key = values["pubkey"] as? String { selected.append(key) } + } + ) + picker.loadViewIfNeeded() + for index in keys.indices { + let path = IndexPath(row: index, section: 1) + let cell = picker.tableView(picker.tableView, cellForRowAt: path) + let content = cell.contentConfiguration as? UIListContentConfiguration + precondition(content?.text == labels[index], "Visible identity must retain its qualifier") + precondition(cell.isAccessibilityElement, "The row owns accessibility") + precondition(cell.contentView.accessibilityElementsHidden, "No duplicate content stop") + precondition(cell.accessibilityLabel == "\(labels[index]), Same owner", "VoiceOver must retain the qualifier") + avatarReplies[keys[index]]?(avatar) + precondition(cell.accessibilityLabel == "\(labels[index]), Same owner", "Avatar loading retains identity") + precondition(cell.contentView.accessibilityElementsHidden, "Avatar loading keeps one accessibility owner") + picker.tableView(picker.tableView, didSelectRowAt: path) + } + precondition(selected == keys, "Each distinct label must select its own pubkey") + picker.update(["users": users, "selected": [users[1]], "query": "", "loading": false]) + let chosen = picker.tableView(picker.tableView, cellForRowAt: IndexPath(row: 0, section: 0)) + precondition(chosen.accessibilityLabel == "\(labels[1]), Same owner", "Selected identity retains its qualifier") + precondition(chosen.accessibilityTraits.contains(.selected), "Selection is exposed to VoiceOver") + print("Native member picker identity and accessibility passed") + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 12c5c95667..0d9974d1f4 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; }; + C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000022 /* NativePresenceMenu.swift */; }; + C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000012 /* NativeAddMembersSheet.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 */; }; @@ -91,6 +93,8 @@ /* Begin PBXFileReference section */ C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = ""; }; + C0AF00000000000000000022 /* NativePresenceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePresenceMenu.swift; sourceTree = ""; }; + C0AF00000000000000000012 /* NativeAddMembersSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAddMembersSheet.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 = ""; }; @@ -282,6 +286,8 @@ 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */, 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */, C0AF00000000000000000002 /* NativeConfirmationDialog.swift */, + C0AF00000000000000000022 /* NativePresenceMenu.swift */, + C0AF00000000000000000012 /* NativeAddMembersSheet.swift */, 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */, @@ -616,6 +622,8 @@ 4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */, 4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */, C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */, + C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */, + C0AF00000000000000000011 /* NativeAddMembersSheet.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 d07baeff40..4d1b95c386 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 nativeAddMembersSheetCoordinator: NativeAddMembersSheetCoordinator? private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator? private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator? private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? @@ -244,6 +245,17 @@ import os.log parentViewController: nativeEmojiPickerRegistrar?.viewController ) + engineBridge.pluginRegistry.registrar(forPlugin: "BuzzNativePresenceMenu")?.register( + NativePresenceMenuFactory(messenger: messenger), withId: "buzz/presence_menu" + ) + + nativeAddMembersSheetCoordinator = NativeAddMembersSheetCoordinator( + messenger: messenger, + parentViewController: engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeAddMembersSheet" + )?.viewController + ) + nativeConfirmationDialogCoordinator = NativeConfirmationDialogCoordinator( messenger: messenger, parentViewController: engineBridge.pluginRegistry.registrar( diff --git a/mobile/ios/Runner/NativeAddMembersSheet.swift b/mobile/ios/Runner/NativeAddMembersSheet.swift new file mode 100644 index 0000000000..da0db0066a --- /dev/null +++ b/mobile/ios/Runner/NativeAddMembersSheet.swift @@ -0,0 +1,302 @@ +import Flutter +import UIKit + +/// UIKit owns presentation; Flutter retains search, selection and relay writes. +final class NativeAddMembersSheetCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { + private let channel: FlutterMethodChannel + private weak var parent: UIViewController? + private var navigation: UINavigationController? + private var picker: NativeAddMembersViewController? + private var session: String? + + init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) { + channel = FlutterMethodChannel(name: "buzz/add_members_sheet", binaryMessenger: messenger) + parent = parentViewController + super.init() + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let data = call.arguments as? [String: Any], let request = data["session"] as? String else { + result(FlutterError(code: "invalid_arguments", message: "Expected a picker session.", details: nil)) + return + } + switch call.method { + case "present": + guard navigation == nil else { + result(FlutterError(code: "busy", message: "A member picker is already open.", details: nil)) + return + } + var presenter = parent ?? UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows).first(where: \.isKeyWindow)?.rootViewController + while let next = presenter?.presentedViewController { presenter = next } + guard let presenter, presenter.view.window != nil, !presenter.isBeingDismissed else { + result(FlutterError(code: "unavailable", message: "Cannot open the member picker.", details: nil)) + return + } + session = request + let picker = NativeAddMembersViewController(state: data, loadAvatar: { [weak self] pubkey, key, completion in + guard let self, self.session == request else { completion(nil); return } + self.channel.invokeMethod("avatar", arguments: ["session": request, "pubkey": pubkey, "avatarKey": key]) { value in + completion((value as? FlutterStandardTypedData)?.data) + } + }) { [weak self] event, values in + guard let self, self.session == request else { return } + if event == "closed" { + self.close(notify: true) + } else { + self.channel.invokeMethod(event, arguments: ["session": request].merging(values) { _, new in new }) + } + } + let navigation = UINavigationController(rootViewController: picker) + navigation.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light + if UIDevice.current.userInterfaceIdiom == .pad { navigation.modalPresentationStyle = .formSheet } + navigation.sheetPresentationController?.detents = [.large()] + self.navigation = navigation + self.picker = picker + presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled) { + navigation.presentationController?.delegate = self + result(nil) + } + case "update": + if request == session { picker?.update(data) } + result(nil) + case "dismiss": + if request == session { close(notify: false) } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + private func close(notify: Bool) { + guard let request = session, let navigation else { return } + session = nil + self.navigation = nil + picker = nil + navigation.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in + if notify { self?.channel.invokeMethod("closed", arguments: ["session": request]) } + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard let request = session else { return } + session = nil + navigation = nil + picker = nil + channel.invokeMethod("closed", arguments: ["session": request]) + } +} + +final class NativeAddMembersViewController: UITableViewController, UISearchResultsUpdating { + private var state: [String: Any] + private let send: (String, [String: Any]) -> Void + private let loadAvatar: (String, String, @escaping (Data?) -> Void) -> Void + private let search = UISearchController(searchResultsController: nil) + private let pageColor: UIColor + private let rowColor: UIColor + private var selected: [[String: Any]] { state["selected"] as? [[String: Any]] ?? [] } + private var users: [[String: Any]] { + let selectedKeys = Set(selected.compactMap { $0["pubkey"] as? String }) + return (state["users"] as? [[String: Any]] ?? []).filter { + !selectedKeys.contains($0["pubkey"] as? String ?? "") + } + } + private var busy: Bool { state["submitting"] as? Bool == true } + private var loading: Bool { state["loading"] as? Bool == true } + private var loadError: Bool { state["loadError"] as? Bool == true } + + init(state: [String: Any], loadAvatar: @escaping (String, String, @escaping (Data?) -> Void) -> Void, send: @escaping (String, [String: Any]) -> Void) { + self.state = state + self.loadAvatar = loadAvatar + self.send = send + pageColor = Self.color(state["pageColor"]) ?? .systemGroupedBackground + rowColor = Self.color(state["rowColor"]) ?? .secondarySystemGroupedBackground + super.init(style: .insetGrouped) + title = "Add members" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.backgroundColor = pageColor + tableView.keyboardDismissMode = .interactive + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 64 + search.obscuresBackgroundDuringPresentation = false + search.hidesNavigationBarDuringPresentation = false + search.automaticallyShowsCancelButton = false + search.searchResultsUpdater = self + search.searchBar.placeholder = "Search for people or agents" + search.searchBar.autocorrectionType = .no + search.searchBar.autocapitalizationType = .none + search.searchBar.showsCancelButton = false + navigationItem.searchController = search + navigationItem.hidesSearchBarWhenScrolling = false + definesPresentationContext = true + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)) + navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Add", style: .done, target: self, action: #selector(add)) + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = pageColor + appearance.shadowColor = .clear + navigationItem.standardAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + applyState() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + search.isActive = true + search.searchBar.becomeFirstResponder() + } + + func update(_ data: [String: Any]) { + // A queued render from before the latest keystroke must not replace results. + guard (data["query"] as? String ?? "") == (search.searchBar.text ?? "") else { return } + state = data + if isViewLoaded { applyState() } + } + + private func applyState() { + navigationController?.isModalInPresentation = busy + navigationItem.leftBarButtonItem?.isEnabled = !busy + navigationItem.rightBarButtonItem?.isEnabled = !busy && !selected.isEmpty + navigationItem.rightBarButtonItem?.title = busy ? "Adding…" : selected.isEmpty ? "Add" : "Add (\(selected.count))" + search.searchBar.isUserInteractionEnabled = !busy + tableView.reloadData() + let error = state["error"] as? String ?? "" + if error.isEmpty { + tableView.tableFooterView = nil + } else { + let label = UILabel() + label.text = error + label.textColor = .systemRed + label.font = .preferredFont(forTextStyle: .footnote) + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + label.textAlignment = .center + label.frame = CGRect(x: 20, y: 0, width: max(1, tableView.bounds.width - 40), height: 0) + label.sizeToFit() + tableView.tableFooterView = label + UIAccessibility.post(notification: .announcement, argument: error) + } + } + + func updateSearchResults(for searchController: UISearchController) { + guard !busy else { return } + let query = searchController.searchBar.text ?? "" + guard query != (state["query"] as? String ?? "") else { return } + state["query"] = query + state["loading"] = true + tableView.reloadData() + send("query", ["value": searchController.searchBar.text ?? ""]) + } + + @objc private func cancel() { if !busy { send("closed", [:]) } } + @objc private func add() { + guard !busy && !selected.isEmpty else { return } + state["submitting"] = true + applyState() + send("submit", [:]) + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? selected.count : (loading || loadError || users.isEmpty ? 1 : users.count) + } + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? (selected.isEmpty ? nil : "Selected") : "People and agents" + } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + cell.backgroundColor = rowColor + var content = cell.defaultContentConfiguration() + var avatarRequest: (String, String)? + var memberLabel: String? + if indexPath.section == 1 && (loading || loadError || users.isEmpty) { + content.text = loading ? "Searching…" : loadError ? "Couldn't load people or agents. Tap to retry." : "No matching people or agents." + content.textProperties.color = .secondaryLabel + content.textProperties.numberOfLines = 0 + cell.selectionStyle = loadError && !loading ? .default : .none + if loadError && !loading { cell.accessibilityTraits.insert(.button) } + } else { + let user = indexPath.section == 0 ? selected[indexPath.row] : users[indexPath.row] + content.text = user["name"] as? String + content.secondaryText = user["detail"] as? String + memberLabel = [content.text, content.secondaryText] + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ") + content.textProperties.numberOfLines = 0 + content.secondaryTextProperties.numberOfLines = 1 + content.image = fallbackAvatar(user) + content.imageProperties.maximumSize = CGSize(width: 40, height: 40) + content.imageProperties.reservedLayoutSize = CGSize(width: 40, height: 40) + if let pubkey = user["pubkey"] as? String, let key = user["avatarKey"] as? String { + avatarRequest = (pubkey, key) + } + cell.accessoryType = indexPath.section == 0 ? .checkmark : .none + cell.accessibilityTraits.insert(.button) + if indexPath.section == 0 { cell.accessibilityTraits.insert(.selected) } + } + cell.contentConfiguration = content + if let memberLabel { + cell.isAccessibilityElement = true + cell.accessibilityLabel = memberLabel + cell.contentView.accessibilityElementsHidden = true + } + if let (pubkey, key) = avatarRequest { + cell.accessibilityIdentifier = key + loadAvatar(pubkey, key) { [weak cell] data in + guard let cell, cell.accessibilityIdentifier == key, + let data, let image = UIImage(data: data), + var configuration = cell.contentConfiguration as? UIListContentConfiguration else { return } + configuration.image = image.withRenderingMode(.alwaysOriginal) + let label = cell.accessibilityLabel + cell.contentConfiguration = configuration + cell.accessibilityLabel = label + cell.contentView.accessibilityElementsHidden = true + } + } + cell.isUserInteractionEnabled = !busy + return cell + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard !busy else { return } + if indexPath.section == 1 && (loading || loadError || users.isEmpty) { + if loadError && !loading { send("retry", [:]) } + return + } + let user = indexPath.section == 0 ? selected[indexPath.row] : users[indexPath.row] + if let pubkey = user["pubkey"] as? String { send("toggle", ["pubkey": pubkey]) } + } + + private func fallbackAvatar(_ user: [String: Any]) -> UIImage { + let size = CGSize(width: 40, height: 40) + return UIGraphicsImageRenderer(size: size).image { _ in + (Self.color(user["avatarBackground"]) ?? .tertiarySystemFill).setFill() + UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), + cornerRadius: user["agent"] as? Bool == true ? 12 : 20).fill() + let text = (user["initial"] as? String ?? "?") as NSString + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 18, weight: .medium), + .foregroundColor: Self.color(user["avatarForeground"]) ?? UIColor.label, + ] + let measured = text.size(withAttributes: attributes) + text.draw(at: CGPoint(x: (40 - measured.width) / 2, y: (40 - measured.height) / 2), withAttributes: attributes) + }.withRenderingMode(.alwaysOriginal) + } + + private static func color(_ value: Any?) -> UIColor? { + guard let number = value as? NSNumber else { return nil } + let argb = number.uint32Value + return UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255, + blue: CGFloat(argb & 255) / 255, alpha: CGFloat((argb >> 24) & 255) / 255) + } +} diff --git a/mobile/ios/Runner/NativePresenceMenu.swift b/mobile/ios/Runner/NativePresenceMenu.swift new file mode 100644 index 0000000000..aca0771f52 --- /dev/null +++ b/mobile/ios/Runner/NativePresenceMenu.swift @@ -0,0 +1,79 @@ +import Flutter +import UIKit + +final class NativePresenceMenuFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + init(messenger: FlutterBinaryMessenger) { self.messenger = messenger; super.init() } + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { FlutterStandardMessageCodec.sharedInstance() } + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView { + NativePresenceMenu(frame: frame, id: viewId, args: args, messenger: messenger) + } +} + +private final class NativePresenceMenu: NSObject, FlutterPlatformView { + private let container: UIView + private let button = UIButton(type: .system) + private let channel: FlutterMethodChannel + + init(frame: CGRect, id: Int64, args: Any?, messenger: FlutterBinaryMessenger) { + container = UIView(frame: frame) + channel = FlutterMethodChannel(name: "buzz/presence_menu/\(id)", binaryMessenger: messenger) + super.init() + container.backgroundColor = .clear + container.isOpaque = false + button.translatesAutoresizingMaskIntoConstraints = false + button.showsMenuAsPrimaryAction = true + container.addSubview(button) + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: container.leadingAnchor), + button.trailingAnchor.constraint(equalTo: container.trailingAnchor), + button.topAnchor.constraint(equalTo: container.topAnchor), + button.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + update(args as? [String: Any] ?? [:]) + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "update", let data = call.arguments as? [String: Any] else { + result(FlutterMethodNotImplemented); return + } + self?.update(data) + result(nil) + } + } + + func view() -> UIView { container } + + private func update(_ data: [String: Any]) { + let presence = data["presence"] as? String ?? "offline" + let label = data["label"] as? String ?? "Offline" + container.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light + var configuration = UIButton.Configuration.plain() + configuration.title = label + configuration.baseForegroundColor = Self.color(data["foreground"]) + configuration.background.backgroundColor = Self.color(data["background"]) + configuration.background.cornerRadius = 100 + configuration.background.backgroundInsets = NSDirectionalEdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 0) + configuration.contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12) + let fontSize = (data["fontSize"] as? NSNumber)?.doubleValue ?? 15 + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var attributes = incoming + attributes.font = UIFont.systemFont(ofSize: fontSize, weight: .medium) + return attributes + } + button.configuration = configuration + button.accessibilityLabel = "Availability" + button.accessibilityValue = label + let options: [(String, String, UIColor)] = [("online", "Online", .systemGreen), ("away", "Away", .systemOrange), ("offline", "Offline", .systemGray)] + button.menu = UIMenu(children: options.map { value, title, color in + UIAction(title: title, + image: UIImage(systemName: "circle.fill")?.withTintColor(color, renderingMode: .alwaysOriginal), + state: presence == value ? .on : .off + ) { [weak self] _ in self?.channel.invokeMethod("selected", arguments: value) } + }) + } + + private static func color(_ value: Any?) -> UIColor { + let argb = (value as? NSNumber)?.uint32Value ?? 0 + return UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255, + blue: CGFloat(argb & 255) / 255, alpha: CGFloat((argb >> 24) & 255) / 255) + } +} diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 77908156d8..b9d3434ca4 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -28,7 +28,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, _ call: FlutterMethodCall, result: @escaping FlutterResult ) { - guard call.method == "present" else { + guard call.method == "present" || call.method == "presentChannel" else { result(FlutterMethodNotImplemented) return } @@ -66,6 +66,13 @@ final class NativeProfileTextEditorCoordinator: NSObject, containerBackgroundColor: UIColor(argb: containerBackgroundArgb.uint32Value), containerCornerRadius: CGFloat(containerCornerRadius.doubleValue), allowUnchangedSubmission: allowUnchangedSubmission, + channelDescription: call.method == "presentChannel" ? arguments["description"] as? String : nil, + originalName: arguments["originalName"] as? String, + originalDescription: arguments["originalDescription"] as? String, + canEditDetails: arguments["canEditDetails"] as? Bool ?? true, + canEditCanvas: arguments["canEditCanvas"] as? Bool ?? false, + canvasContent: arguments["canvasContent"] as? String ?? "", + canvasLoaded: arguments["canvasLoaded"] as? Bool ?? false, result: result ) } @@ -82,6 +89,13 @@ final class NativeProfileTextEditorCoordinator: NSObject, containerBackgroundColor: UIColor, containerCornerRadius: CGFloat, allowUnchangedSubmission: Bool, + channelDescription: String?, + originalName: String?, + originalDescription: String?, + canEditDetails: Bool, + canEditCanvas: Bool, + canvasContent: String, + canvasLoaded: Bool, result: @escaping FlutterResult ) { guard presentedController == nil else { @@ -118,6 +132,13 @@ final class NativeProfileTextEditorCoordinator: NSObject, containerBackgroundColor: containerBackgroundColor, containerCornerRadius: containerCornerRadius, allowUnchangedSubmission: allowUnchangedSubmission, + channelDescription: channelDescription, + originalName: originalName, + originalDescription: originalDescription, + canEditDetails: canEditDetails, + canEditCanvas: canEditCanvas, + canvasContent: canvasContent, + canvasLoaded: canvasLoaded, onCancel: { [weak self] in self?.finish(value: nil) }, onSet: { [weak self] value in self?.finish(value: value) } ) @@ -129,6 +150,10 @@ final class NativeProfileTextEditorCoordinator: NSObject, navigationController.modalPresentationStyle = .formSheet } + if channelDescription != nil { + navigationController.sheetPresentationController?.detents = [.large()] + } + pendingResult = result presentedController = navigationController presenter.present(navigationController, animated: true) { [weak self] in @@ -137,7 +162,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, } @MainActor - private func finish(value: String?) { + private func finish(value: Any?) { guard let controller = presentedController else { resolve(value: value) return @@ -154,7 +179,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, } @MainActor - private func resolve(value: String?) { + private func resolve(value: Any?) { let result = pendingResult pendingResult = nil presentedController = nil @@ -201,7 +226,18 @@ private final class NativeProfileTextEditorViewController: private let containerCornerRadius: CGFloat private let allowUnchangedSubmission: Bool private let onCancel: () -> Void - private let onSet: (String) -> Void + private let onSet: (Any) -> Void + private let channelDescription: String? + private let originalName: String? + private let originalDescription: String? + private let canEditDetails: Bool + private let canEditCanvas: Bool + private let canvasContent: String + private let canvasLoaded: Bool + private var hasCanvas: Bool { + canvasLoaded && !canvasContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + private var isChannelEditor: Bool { channelDescription != nil } private lazy var textField: UITextField = { let field = UITextField() @@ -211,7 +247,9 @@ private final class NativeProfileTextEditorViewController: field.font = .preferredFont(forTextStyle: .body) field.adjustsFontForContentSizeCategory = true field.clearButtonMode = .whileEditing - field.returnKeyType = .done + field.returnKeyType = isChannelEditor ? .next : .done + field.isEnabled = canEditDetails + field.accessibilityLabel = placeholder field.autocapitalizationType = .sentences field.delegate = self field.addTarget(self, action: #selector(textDidChange), for: .editingChanged) @@ -221,7 +259,9 @@ private final class NativeProfileTextEditorViewController: private lazy var textView: UITextView = { let view = UITextView() view.translatesAutoresizingMaskIntoConstraints = false - view.text = initialValue + view.text = channelDescription ?? initialValue + view.isEditable = canEditDetails + view.accessibilityLabel = isChannelEditor ? "Description" : placeholder view.font = .preferredFont(forTextStyle: .body) view.adjustsFontForContentSizeCategory = true view.backgroundColor = .clear @@ -235,7 +275,7 @@ private final class NativeProfileTextEditorViewController: private lazy var placeholderLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false - label.text = placeholder + label.text = isChannelEditor ? "Description" : placeholder label.font = .preferredFont(forTextStyle: .body) label.adjustsFontForContentSizeCategory = true label.textColor = .placeholderText @@ -252,8 +292,15 @@ private final class NativeProfileTextEditorViewController: containerBackgroundColor: UIColor, containerCornerRadius: CGFloat, allowUnchangedSubmission: Bool, + channelDescription: String?, + originalName: String?, + originalDescription: String?, + canEditDetails: Bool, + canEditCanvas: Bool, + canvasContent: String, + canvasLoaded: Bool, onCancel: @escaping () -> Void, - onSet: @escaping (String) -> Void + onSet: @escaping (Any) -> Void ) { self.initialValue = initialValue self.placeholder = placeholder @@ -262,6 +309,13 @@ private final class NativeProfileTextEditorViewController: self.containerBackgroundColor = containerBackgroundColor self.containerCornerRadius = containerCornerRadius self.allowUnchangedSubmission = allowUnchangedSubmission + self.channelDescription = channelDescription + self.originalName = originalName + self.originalDescription = originalDescription + self.canEditDetails = canEditDetails + self.canEditCanvas = canEditCanvas + self.canvasContent = canvasContent + self.canvasLoaded = canvasLoaded self.onCancel = onCancel self.onSet = onSet super.init(style: .insetGrouped) @@ -297,7 +351,7 @@ private final class NativeProfileTextEditorViewController: action: #selector(cancelTapped) ) navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "Set", + title: isChannelEditor ? "Save" : "Set", style: .done, target: self, action: #selector(setTapped) @@ -307,6 +361,7 @@ private final class NativeProfileTextEditorViewController: override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + guard canEditDetails else { return } if multiline { textView.becomeFirstResponder() } else { @@ -319,7 +374,14 @@ private final class NativeProfileTextEditorViewController: } private var hasUnsavedChanges: Bool { - allowUnchangedSubmission + if isChannelEditor { + return allowUnchangedSubmission + || currentValue.trimmingCharacters(in: .whitespacesAndNewlines) + != (originalName ?? initialValue).trimmingCharacters(in: .whitespacesAndNewlines) + || textView.text.trimmingCharacters(in: .whitespacesAndNewlines) + != (originalDescription ?? channelDescription ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + return allowUnchangedSubmission || currentValue.trimmingCharacters(in: .whitespacesAndNewlines) != initialValue.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -338,7 +400,8 @@ private final class NativeProfileTextEditorViewController: } private func updateNavigation() { - navigationItem.rightBarButtonItem?.isEnabled = hasUnsavedChanges + navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges + && (!isChannelEditor || !canonicalChannelName.isEmpty) navigationController?.isModalInPresentation = hasUnsavedChanges placeholderLabel.isHidden = !textView.text.isEmpty } @@ -365,17 +428,59 @@ private final class NativeProfileTextEditorViewController: present(alert, animated: true) } + private var canonicalChannelName: String { + currentValue.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^#+", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + @objc private func setTapped() { - guard hasUnsavedChanges else { return } - onSet(currentValue) + guard canEditDetails && hasUnsavedChanges else { return } + if isChannelEditor { + guard !canonicalChannelName.isEmpty else { return } + onSet(["action": "save", "name": canonicalChannelName, "description": textView.text ?? ""]) + } else { + onSet(currentValue) + } + } + + @objc private func editCanvasTapped() { + guard canEditCanvas else { return } + onSet(["action": "canvas", "name": currentValue, "description": textView.text ?? ""]) + } + + private func removeCanvasTapped() { + guard canEditCanvas && hasCanvas else { return } + let alert = UIAlertController( + title: "Remove canvas?", + message: "This removes the canvas content for everyone in this channel.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Remove", style: .destructive) { [weak self] _ in + guard let self else { return } + self.onSet(["action": "removeCanvas", "name": self.currentValue, "description": self.textView.text ?? ""]) + }) + present(alert, animated: true) + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard isChannelEditor && canEditCanvas else { return } + if indexPath.section == 2 { editCanvasTapped() } + if indexPath.section == 3 { removeCanvasTapped() } } func textFieldShouldReturn(_ textField: UITextField) -> Bool { - setTapped() + if isChannelEditor { + textView.becomeFirstResponder() + } else { + setTapped() + } return false } - override func numberOfSections(in tableView: UITableView) -> Int { 1 } + override func numberOfSections(in tableView: UITableView) -> Int { isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 } override func tableView( _ tableView: UITableView, @@ -386,7 +491,8 @@ private final class NativeProfileTextEditorViewController: _ tableView: UITableView, heightForRowAt indexPath: IndexPath ) -> CGFloat { - multiline ? 132 : 52 + if isChannelEditor && indexPath.section >= 2 { return UITableView.automaticDimension } + return (isChannelEditor ? indexPath.section == 1 : multiline) ? 132 : 52 } override func tableView( @@ -399,7 +505,29 @@ private final class NativeProfileTextEditorViewController: cell.layer.cornerRadius = containerCornerRadius cell.layer.cornerCurve = .continuous cell.clipsToBounds = true - if multiline { + if isChannelEditor && indexPath.section >= 2 { + var content = cell.defaultContentConfiguration() + content.textProperties.font = .preferredFont(forTextStyle: .body) + if indexPath.section == 3 { + content.text = "Remove Canvas" + content.textProperties.color = .systemRed + } else if hasCanvas { + content.text = "Canvas" + content.secondaryText = String(canvasContent.prefix(600)) + content.secondaryTextProperties.font = .preferredFont(forTextStyle: .body) + content.secondaryTextProperties.numberOfLines = 4 + cell.accessoryType = canEditCanvas ? .disclosureIndicator : .none + cell.accessibilityHint = canEditCanvas ? "Opens the canvas editor" : nil + } else { + content.text = canvasLoaded ? "Add Canvas" : "Retry loading canvas" + content.textProperties.color = canEditCanvas ? .label : .secondaryLabel + cell.accessoryType = canEditCanvas ? .disclosureIndicator : .none + } + cell.contentConfiguration = content + cell.selectionStyle = canEditCanvas ? .default : .none + cell.isUserInteractionEnabled = canEditCanvas + cell.accessibilityTraits = canEditCanvas ? .button : .staticText + } else if isChannelEditor ? indexPath.section == 1 : multiline { cell.contentView.addSubview(textView) textView.addSubview(placeholderLabel) NSLayoutConstraint.activate([ diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index 47bc804aec..e5bfa59d22 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; @@ -10,7 +12,54 @@ import '../../shared/widgets/app_list_card_item.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/identity_names/identity_names_provider.dart'; +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/relay/relay.dart'; import 'channel_management_provider.dart'; +import 'ios_add_members_sheet.dart'; +import '../../shared/widgets/modal_presentation.dart'; + +bool _nativePickerOpen = false; + +/// Opens the platform member picker, retaining the Android sheet presentation. +Future showAddChannelMembersSheet({ + required BuildContext context, + required String channelId, + required Set existingPubkeys, +}) async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { + if (_nativePickerOpen) return null; + _nativePickerOpen = true; + try { + return await showGeneralDialog( + context: context, + barrierColor: Colors.transparent, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => AddChannelMembersSheet( + channelId: channelId, + existingPubkeys: existingPubkeys, + nativePresentation: true, + ), + ); + } finally { + _nativePickerOpen = false; + } + } + final mediaQuery = MediaQuery.of(context); + return showBuzzModalBottomSheet( + context: context, + title: 'Add members', + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.xs, + ), + builder: (_) => AddChannelMembersSheet( + channelId: channelId, + existingPubkeys: existingPubkeys, + ), + ); +} /// Searchable multi-select used to add people or agents to a channel. class AddChannelMembersSheet extends HookConsumerWidget { @@ -18,10 +67,12 @@ class AddChannelMembersSheet extends HookConsumerWidget { super.key, required this.channelId, required this.existingPubkeys, + this.nativePresentation = false, }); final String channelId; final Set existingPubkeys; + final bool nativePresentation; @override Widget build(BuildContext context, WidgetRef ref) { @@ -31,6 +82,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { final selectedUsers = useState>([]); final isSubmitting = useState(false); final submitError = useState(null); + final actions = useMemoized(() => ref.read(channelActionsProvider)); useEffect(() { final timer = Timer(const Duration(milliseconds: 250), () { @@ -101,14 +153,13 @@ class AddChannelMembersSheet extends HookConsumerWidget { isSubmitting.value = true; submitError.value = null; try { - await ref - .read(channelActionsProvider) - .addMembers( - channelId: channelId, - pubkeys: submittedUsers.map((user) => user.pubkey).toList(), - ); + await actions.addMembers( + channelId: channelId, + pubkeys: submittedUsers.map((user) => user.pubkey).toList(), + ); if (context.mounted) Navigator.of(context).pop(true); } on AddMembersException catch (error) { + if (!context.mounted) return; final failedPubkeys = error.failures.keys .map((pubkey) => pubkey.toLowerCase()) .toSet(); @@ -124,12 +175,87 @@ class AddChannelMembersSheet extends HookConsumerWidget { ]; submitError.value = error.message; } catch (error) { + if (!context.mounted) return; submitError.value = error.toString(); } finally { - isSubmitting.value = false; + if (context.mounted) isSubmitting.value = false; } } + if (nativePresentation) { + final profiles = ref.watch(userCacheProvider); + final auth = ref.watch(mediaGetAuthServiceProvider); + final client = ref.watch(mediaHttpClientProvider); + final colors = Theme.of(context).colorScheme; + String? avatarUrl(DirectoryUser user) => + profiles[user.pubkey]?.avatarUrl ?? user.avatarUrl; + String avatarKey(DirectoryUser user) => jsonEncode([ + user.pubkey, + avatarUrl(user), + user.initial, + user.isAgent, + colors.primaryContainer.toARGB32(), + colors.onPrimaryContainer.toARGB32(), + ]); + Map row(DirectoryUser user) => { + 'pubkey': user.pubkey, + 'name': labelFor(user), + 'detail': user.secondaryLabel, + 'agent': user.isAgent, + 'avatarKey': avatarKey(user), + 'initial': user.initial, + 'avatarBackground': colors.primaryContainer.toARGB32(), + 'avatarForeground': colors.onPrimaryContainer.toARGB32(), + 'selected': selectedPubkeys.contains(user.pubkey.toLowerCase()), + }; + return IosAddMembersSheet( + onAvatar: (pubkey, key) async { + final user = choices + .where((user) => user.pubkey == pubkey) + .firstOrNull; + if (user == null || avatarKey(user) != key) return null; + return nativeAvatarImage( + url: avatarUrl(user), + initial: user.initial, + isAgent: user.isAgent, + background: colors.primaryContainer, + foreground: colors.onPrimaryContainer, + networkImage: (url) => + MediaImageProvider(url: url, auth: auth, client: client), + ); + }, + state: { + 'query': query.value, + 'loading': + directoryAsync.isLoading || + query.value.trim().toLowerCase() != normalizedQuery, + 'loadError': directoryAsync.hasError, + 'error': submitError.value, + 'submitting': isSubmitting.value, + 'selected': [for (final user in selectedUsers.value) row(user)], + 'users': [for (final user in availableUsers) row(user)], + }, + onQuery: (value) { + if (!isSubmitting.value) query.value = value; + }, + onToggle: (pubkey) { + final user = choices + .where((user) => user.pubkey == pubkey) + .firstOrNull; + if (user != null) toggleUser(user); + }, + onSubmit: () => unawaited(addSelectedMembers()), + onRetry: () { + if (normalizedQuery.isEmpty) { + ref.invalidate(relayDirectoryUsersProvider); + } else { + ref.invalidate(relayDirectorySearchProvider(normalizedQuery)); + } + }, + onClose: () => Navigator.of(context).pop(false), + ); + } + return Padding( padding: EdgeInsets.fromLTRB( Grid.gutter, diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index fb687cc092..d5bdd33d89 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -216,26 +216,14 @@ class ChannelActionsSheet extends HookConsumerWidget { leading: const Icon(BuzzIcons.settings), title: const Text('Manage channel'), onTap: () async { - final shouldClose = - await showBuzzModalBottomSheet( - context: context, - title: 'Manage channel', - isScrollControlled: true, - showDragHandle: true, - constraints: BoxConstraints( - maxWidth: 640, - maxHeight: - MediaQuery.sizeOf(context).height * 0.9, - ), - builder: (_) => ManageChannelSheet( - channel: currentChannel, - canEditDetails: - canManageLifecycle && - !currentChannel.isArchived, - onChannelUpdated: (updated) => - displayedChannel.value = updated, - ), - ); + final shouldClose = await showManageChannelSheet( + context: context, + channel: currentChannel, + canEditDetails: + canManageLifecycle && !currentChannel.isArchived, + onChannelUpdated: (updated) => + displayedChannel.value = updated, + ); if (shouldClose == true && context.mounted) { Navigator.of(context).pop(true); } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 5b002cee29..5549d6af63 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -65,6 +65,7 @@ import 'message_actions.dart'; import 'message_action_backdrop_state.dart'; import 'message_long_press_region.dart'; import 'message_content.dart'; +import 'message_mention_pill.dart'; import 'message_skeleton_body.dart'; import '../../shared/read_state/deferred_read_state_update.dart'; import '../../shared/read_state/read_state_provider.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 358d064d2e..9d8eb03312 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -28,18 +28,6 @@ class _SystemMessageRow extends HookConsumerWidget { final userCache = ref.watch(userCacheProvider); final sourceMessages = groupedMessages ?? [message]; final groupedMembership = _membershipDisplayEvent(sourceMessages); - final messageStyleAction = switch (systemEvent.type) { - SystemEventType.channelCreated => 'created this channel', - SystemEventType.huddleStarted => 'started a huddle', - SystemEventType.huddleEnded => 'ended the huddle', - _ => null, - }; - final messageStyleActor = messageStyleAction == null - ? null - : systemEvent.actorPubkey?.trim(); - final usesMessageStyleLayout = - groupedMembership != null || - (messageStyleActor != null && messageStyleActor.isNotEmpty); final identityNames = ref.watch(channelIdentityNamesProvider(channelId)); String resolveLabel(String? pubkey) { @@ -50,6 +38,67 @@ class _SystemMessageRow extends HookConsumerWidget { return identityNames.labelFor(pubkey); } + final target = systemEvent.targetPubkey?.trim().toLowerCase(); + final targetIsAgent = + ref.watch(agentMentionPubkeysProvider(channelId)).contains(target) || + userCache[target]?.ownerPubkey != null; + final messageStyleAction = switch (systemEvent.type) { + SystemEventType.memberRemoved => [ + const TextSpan(text: 'removed '), + if (target == null || target.isEmpty) + TextSpan(text: resolveLabel(target)) + else + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Semantics( + container: true, + label: resolveLabel(target), + button: true, + child: GestureDetector( + onTap: () => showUserProfileSheet( + context, + target, + names: channelIdentityNamesProvider(channelId), + ), + child: ExcludeSemantics( + child: MessageMentionPill( + label: resolveLabel(target), + isAgent: targetIsAgent, + textStyle: _systemActionTextStyle(context), + ), + ), + ), + ), + ), + const TextSpan(text: ' from the channel'), + ], + SystemEventType.topicChanged => [ + TextSpan( + text: describeChannelTextFieldChange('topic', systemEvent.topic), + ), + ], + SystemEventType.purposeChanged => [ + TextSpan( + text: describeChannelTextFieldChange('purpose', systemEvent.purpose), + ), + ], + SystemEventType.channelCreated => [ + const TextSpan(text: 'created this channel'), + ], + SystemEventType.huddleStarted => [ + const TextSpan(text: 'started a huddle'), + ], + SystemEventType.huddleEnded => [const TextSpan(text: 'ended the huddle')], + _ => null, + }; + final messageStyleActor = messageStyleAction == null + ? null + : systemEvent.actorPubkey?.trim(); + final usesMessageStyleLayout = + groupedMembership != null || + (messageStyleActor != null && messageStyleActor.isNotEmpty); + final reactions = groupedMessages == null ? message.reactions : _aggregateSystemMessageReactions(sourceMessages); @@ -138,7 +187,7 @@ class _SystemMessageRow extends HookConsumerWidget { createdAt: message.createdAt, resolveLabel: resolveLabel, userCache: userCache, - actionSpans: [TextSpan(text: messageStyleAction)], + actionSpans: messageStyleAction, ) : Row( children: [ diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 5bec76d1f5..cbf87ba52d 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -212,41 +212,21 @@ class ChannelDetailsPage extends HookConsumerWidget { ); Future openAddMembers() async { - final mediaQuery = MediaQuery.of(context); - await showBuzzModalBottomSheet( + await showAddChannelMembersSheet( context: context, - title: 'Add members', - isScrollControlled: true, - showDragHandle: true, - constraints: BoxConstraints( - maxWidth: 640, - maxHeight: - mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.xs, - ), - builder: (_) => AddChannelMembersSheet( - channelId: resolvedChannel.id, - existingPubkeys: { - for (final member in members) member.pubkey.toLowerCase(), - }, - ), + channelId: resolvedChannel.id, + existingPubkeys: { + for (final member in members) member.pubkey.toLowerCase(), + }, ); } Future openManageChannel() async { - final shouldClose = await showBuzzModalBottomSheet( + final shouldClose = await showManageChannelSheet( context: context, - title: 'Manage channel', - isScrollControlled: true, - showDragHandle: true, - constraints: BoxConstraints( - maxWidth: 640, - maxHeight: MediaQuery.sizeOf(context).height * 0.9, - ), - builder: (_) => ManageChannelSheet( - channel: resolvedChannel, - canEditDetails: canEdit, - onChannelUpdated: (updated) => displayedChannel.value = updated, - ), + channel: resolvedChannel, + canEditDetails: canEdit, + onChannelUpdated: (updated) => displayedChannel.value = updated, ); if (shouldClose == true && context.mounted) { Navigator.of(context).pop(true); diff --git a/mobile/lib/features/channels/channel_member_profile_actions.dart b/mobile/lib/features/channels/channel_member_profile_actions.dart index 8a95df9de8..4ce31bd43f 100644 --- a/mobile/lib/features/channels/channel_member_profile_actions.dart +++ b/mobile/lib/features/channels/channel_member_profile_actions.dart @@ -6,7 +6,7 @@ import 'package:buzz/shared/theme/buzz_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; import '../../shared/widgets/app_list_card.dart'; -import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/confirmation_dialog.dart'; import 'channel.dart'; import 'channel_identity_names_provider.dart'; import 'channel_management_provider.dart'; @@ -93,22 +93,11 @@ class ChannelMemberProfileActions extends HookConsumerWidget { final label = ref .read(channelIdentityNamesProvider(channel.id)) .labelFor(target); - final confirmed = await showBuzzDialog( + final confirmed = await showDestructiveConfirmation( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Remove from channel?'), - content: Text('Remove $label from ${currentChannel.name}?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('Remove'), - ), - ], - ), + title: 'Remove from channel?', + message: 'Remove $label from ${currentChannel.name}?', + confirmLabel: 'Remove', ); if (!context.mounted || confirmed != true || !stillAllowed()) return; await ref diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index e27bfe3cd1..4719cd5ca3 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -585,11 +585,14 @@ class _SettingsPageRoute extends PageRouteBuilder { _SettingsPageRoute({ required WidgetBuilder builder, required this.onTransitionProgress, + bool? opaque, }) : super( pageBuilder: (context, animation, secondaryAnimation) => builder(context), transitionsBuilder: _buildSettingsTransition, - opaque: false, + // Stop compositing Home's UIKit controls once Settings has settled. + // Opaque routes still paint the previous page during both transitions. + opaque: opaque ?? defaultTargetPlatform == TargetPlatform.iOS, allowSnapshotting: false, transitionDuration: const Duration(milliseconds: 150), reverseTransitionDuration: const Duration(milliseconds: 150), diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart index d642aea17e..18796dc8b2 100644 --- a/mobile/lib/features/channels/channels_page/community_switcher.dart +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -40,7 +40,7 @@ class _CommunitySwitcherRoute extends _SettingsPageRoute { _CommunitySwitcherRoute({ required super.builder, required super.onTransitionProgress, - }); + }) : super(opaque: false); bool flying = false; diff --git a/mobile/lib/features/channels/ios_add_members_sheet.dart b/mobile/lib/features/channels/ios_add_members_sheet.dart new file mode 100644 index 0000000000..dac34bde15 --- /dev/null +++ b/mobile/lib/features/channels/ios_add_members_sheet.dart @@ -0,0 +1,193 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../shared/theme/theme.dart'; + +/// Hosts the native iOS member picker while Flutter owns its data and actions. +class IosAddMembersSheet extends StatefulWidget { + const IosAddMembersSheet({ + super.key, + required this.state, + required this.onAvatar, + required this.onQuery, + required this.onToggle, + required this.onSubmit, + required this.onRetry, + required this.onClose, + }); + + final Map state; + final Future Function(String pubkey, String avatarKey) onAvatar; + final ValueChanged onQuery; + final ValueChanged onToggle; + final VoidCallback onSubmit; + final VoidCallback onRetry; + final VoidCallback onClose; + + @override + State createState() => _IosAddMembersSheetState(); +} + +class _IosAddMembersSheetState extends State { + static const _channel = MethodChannel('buzz/add_members_sheet'); + final _session = UniqueKey().toString(); + bool _presented = false; + bool _closed = false; + final _avatars = {}; + final _pendingAvatars = >{}; + final _avatarQueue = + Queue< + ({ + String key, + Future Function() load, + Completer result, + }) + >(); + int _activeAvatars = 0; + + Future _avatar(String pubkey, String key) { + if (_avatars[key] case final bytes?) return Future.value(bytes); + if (_pendingAvatars[key] case final pending?) return pending; + // Favor newly visible rows while bounding both work and retained results. + if (_avatarQueue.length >= 32) { + final dropped = _avatarQueue.removeFirst(); + _pendingAvatars.remove(dropped.key); + dropped.result.complete(null); + } + final result = Completer(); + final load = widget.onAvatar; + _pendingAvatars[key] = result.future; + _avatarQueue.add((key: key, load: () => load(pubkey, key), result: result)); + _drainAvatars(); + return result.future; + } + + void _drainAvatars() { + while (mounted && + !_closed && + _activeAvatars < 4 && + _avatarQueue.isNotEmpty) { + final job = _avatarQueue.removeFirst(); + _activeAvatars++; + unawaited(() async { + Uint8List? bytes; + try { + bytes = await job.load(); + if (mounted && !_closed && bytes != null) { + if (_avatars.length >= 64) _avatars.remove(_avatars.keys.first); + _avatars[job.key] = bytes; + } + } catch (_) { + // The native cell keeps its initial when media cannot be rendered. + } finally { + _pendingAvatars.remove(job.key); + _activeAvatars--; + job.result.complete(mounted && !_closed ? bytes : null); + _drainAvatars(); + } + }()); + } + } + + @override + void initState() { + super.initState(); + _channel.setMethodCallHandler((call) async { + if (!mounted || _closed) return null; + final args = Map.from(call.arguments as Map); + if (args['session'] != _session) return null; + switch (call.method) { + case 'avatar': + return _avatar( + args['pubkey'] as String? ?? '', + args['avatarKey'] as String? ?? '', + ); + case 'query': + widget.onQuery(args['value'] as String? ?? ''); + case 'toggle': + widget.onToggle(args['pubkey'] as String? ?? ''); + case 'submit': + widget.onSubmit(); + case 'retry': + widget.onRetry(); + case 'closed': + _closed = true; + widget.onClose(); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _present()); + } + + Map get _state => {'session': _session, ...widget.state}; + + Future _present() async { + if (!mounted) return; + final theme = utilitySurfaceThemeData(Theme.of(context)); + try { + await _channel.invokeMethod('present', { + ..._state, + 'dark': theme.brightness == Brightness.dark, + 'pageColor': theme.colorScheme.surface.toARGB32(), + 'rowColor': theme.colorScheme.surfaceContainerHighest.toARGB32(), + }); + _presented = true; + if (!mounted) { + await _channel.invokeMethod('dismiss', {'session': _session}); + return; + } + await _update(); + } on PlatformException { + _failed(); + } on MissingPluginException { + _failed(); + } + } + + void _failed() { + if (!mounted || _closed) return; + _closed = true; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Couldn't open Add members. Try again.")), + ); + widget.onClose(); + } + + Future _update() async { + if (!_presented || !mounted || _closed) return; + try { + await _channel.invokeMethod('update', _state); + } on PlatformException { + _failed(); + } + } + + @override + void didUpdateWidget(covariant IosAddMembersSheet oldWidget) { + super.didUpdateWidget(oldWidget); + unawaited(_update()); + } + + @override + void dispose() { + _channel.setMethodCallHandler(null); + for (final job in _avatarQueue) { + job.result.complete(null); + } + _avatarQueue.clear(); + _avatars.clear(); + unawaited( + _channel.invokeMethod('dismiss', {'session': _session}).catchError(( + Object _, + ) { + // The engine may already have disposed its native presentation. + }), + ); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index 588ca49653..38cb8aecab 100644 --- a/mobile/lib/features/channels/manage_channel_sheet.dart +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -1,349 +1,421 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; -import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/text_editor.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; +String _channelName(String value) => + value.trim().replaceFirst(RegExp(r'^#+'), '').trim(); + +/// Opens name and description directly in one full editing sheet. +Future showManageChannelSheet({ + required BuildContext context, + required Channel channel, + required bool canEditDetails, + ValueChanged? onChannelUpdated, +}) async { + final container = ProviderScope.containerOf(context, listen: false); + final actions = container.read(channelActionsProvider); + var nameDraft = channel.name; + var descriptionDraft = channel.description; + var retry = false; + final canEditCanvas = channel.isMember && !channel.isArchived; + bool canPresent() => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true); + + if (defaultTargetPlatform == TargetPlatform.iOS) { + final theme = utilitySurfaceThemeData(Theme.of(context)); + const bridge = MethodChannel('buzz/profile_text_editor'); + // Start the provider without delaying metadata editing on relay I/O. + // A cold or failed canvas can be loaded/retried from its row. + final cachedCanvas = container + .read(channelCanvasProvider(channel.id)) + .asData; + String? canvasContent = cachedCanvas?.value.content ?? ''; + var canvasLoaded = cachedCanvas != null; + while (canPresent()) { + Map? result; + try { + result = await bridge + .invokeMapMethod('presentChannel', { + 'title': 'Edit channel', + 'initialValue': nameDraft, + 'description': descriptionDraft, + 'originalName': channel.name, + 'originalDescription': channel.description, + 'placeholder': 'Channel name', + 'multiline': false, + 'brightness': theme.brightness.name, + 'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(), + 'containerBackgroundArgb': theme + .colorScheme + .surfaceContainerHighest + .toARGB32(), + 'containerCornerRadius': Radii.container, + 'allowUnchangedSubmission': retry, + 'canEditDetails': canEditDetails, + 'canEditCanvas': canEditCanvas, + 'canvasContent': canvasContent, + 'canvasLoaded': canvasLoaded, + }); + } on MissingPluginException { + break; + } on PlatformException { + break; + } + if (result == null || !context.mounted || !canPresent()) return false; + nameDraft = result['name'] as String? ?? nameDraft; + descriptionDraft = result['description'] as String? ?? descriptionDraft; + try { + if (result['action'] == 'canvas') { + if (canEditCanvas) { + final saved = await _editCanvas( + context, + container, + actions, + channel.id, + initialContent: canvasLoaded ? canvasContent : null, + ); + if (saved != null) { + canvasContent = saved; + canvasLoaded = true; + } + } + continue; + } + if (result['action'] == 'removeCanvas') { + if (canEditCanvas && + canvasLoaded && + (canvasContent?.trim().isNotEmpty ?? false)) { + await actions.setCanvas(channelId: channel.id, content: ''); + canvasContent = ''; + } + continue; + } + if (result['action'] != 'save' || !canEditDetails) return false; + final updated = await _saveDetails( + actions, + channel, + nameDraft, + descriptionDraft, + ); + if (context.mounted) onChannelUpdated?.call(updated); + return false; + } catch (_) { + if (!context.mounted || !canPresent()) return false; + retry = true; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("We couldn't save this change. Try again."), + ), + ); + } + } + } + if (!context.mounted || !canPresent()) return false; + return showBuzzModalBottomSheet( + context: context, + title: 'Edit channel', + isScrollControlled: true, + useSafeArea: true, + constraints: const BoxConstraints(maxWidth: 640), + builder: (_) => SizedBox( + height: MediaQuery.sizeOf(context).height * 0.9, + child: ManageChannelSheet( + channel: channel, + canEditDetails: canEditDetails, + onChannelUpdated: onChannelUpdated, + initialName: nameDraft, + initialDescription: descriptionDraft, + ), + ), + ); +} + +Future _saveDetails( + ChannelActions actions, + Channel channel, + String name, + String description, +) async { + final canonicalName = _channelName(name); + final trimmedDescription = description.trim(); + if (canonicalName.isEmpty) { + throw const FormatException('Channel name is required.'); + } + await actions.updateChannel( + channelId: channel.id, + name: canonicalName != channel.name.trim() ? canonicalName : null, + description: trimmedDescription != channel.description.trim() + ? trimmedDescription + : null, + ); + return channel.copyWith(name: canonicalName, description: trimmedDescription); +} + +Future _editCanvas( + BuildContext context, + ProviderContainer container, + ChannelActions actions, + String channelId, { + String? initialContent, +}) async { + if (initialContent == null && + container.read(channelCanvasProvider(channelId)).hasError) { + container.invalidate(channelCanvasProvider(channelId)); + } + final content = + initialContent ?? + (await container + .read(channelCanvasProvider(channelId).future) + .timeout(const Duration(seconds: 5))) + .content ?? + ''; + if (!context.mounted) return null; + String? saved; + await showTextEditor( + context: context, + title: 'Canvas', + initialValue: content, + hintText: 'Write your canvas content in Markdown…', + multiline: true, + onSave: (value) async { + final trimmed = value.trim(); + await actions.setCanvas(channelId: channelId, content: trimmed); + saved = trimmed; + }, + ); + // A successful load is also useful when the editor is dismissed unchanged. + return saved ?? content; +} + +/// Flutter fallback for the full channel editor. class ManageChannelSheet extends HookConsumerWidget { const ManageChannelSheet({ super.key, required this.channel, this.canEditDetails = true, this.onChannelUpdated, + this.initialName, + this.initialDescription, }); final Channel channel; final bool canEditDetails; final ValueChanged? onChannelUpdated; + final String? initialName; + final String? initialDescription; @override Widget build(BuildContext context, WidgetRef ref) { - final canvasAsync = ref.watch(channelCanvasProvider(channel.id)); - final nameController = useTextEditingController(text: channel.name); - final descriptionController = useTextEditingController( - text: channel.description, + final name = useTextEditingController(text: initialName ?? channel.name); + final description = useTextEditingController( + text: initialDescription ?? channel.description, ); - useListenable(nameController); - useListenable(descriptionController); - final canvasController = useTextEditingController(); - final isEditingCanvas = useState(false); - final isSavingCanvas = useState(false); - final isSavingDetails = useState(false); - final actionError = useState(null); - - useEffect(() { - final canvas = canvasAsync.asData?.value; - if (!isEditingCanvas.value) { - canvasController.text = canvas?.content ?? ''; - } - return null; - }, [canvasAsync.asData?.value.content, isEditingCanvas.value]); - - final canonicalName = nameController.text - .trim() - .replaceFirst(RegExp(r'^#+'), '') - .trim(); - final description = descriptionController.text.trim(); - final nameDirty = canonicalName != channel.name.trim(); - final descriptionDirty = description != channel.description.trim(); - final canSaveDetails = - canEditDetails && - canonicalName.isNotEmpty && - (nameDirty || descriptionDirty) && - !isSavingDetails.value; + useListenable(name); + useListenable(description); + final saving = useState(false); + final openingCanvas = useState(false); + final error = useState(null); + final canvas = ref.watch(channelCanvasProvider(channel.id)); + final savedCanvas = useState(null); + final canvasContent = + savedCanvas.value ?? canvas.asData?.value.content ?? ''; + final canvasLoaded = savedCanvas.value != null || canvas.hasValue; + final hasCanvas = canvasLoaded && canvasContent.trim().isNotEmpty; final canEditCanvas = channel.isMember && !channel.isArchived; + // Keep actions bound to the community in which this editor was opened. + final actions = useMemoized(() => ref.read(channelActionsProvider)); + final canonicalName = _channelName(name.text); + final changed = + canonicalName != channel.name.trim() || + description.text.trim() != channel.description.trim(); + final busy = saving.value || openingCanvas.value; - Future saveDetails() async { - if (!canSaveDetails) return; - isSavingDetails.value = true; - actionError.value = null; + Future save() async { + if (busy || !canEditDetails || !changed || canonicalName.isEmpty) return; + saving.value = true; + error.value = null; try { - await ref - .read(channelActionsProvider) - .updateChannel( - channelId: channel.id, - name: nameDirty ? canonicalName : null, - description: descriptionDirty ? description : null, - ); - final updated = channel.copyWith( - name: canonicalName, - description: description, + final updated = await _saveDetails( + actions, + channel, + name.text, + description.text, ); + if (!context.mounted) return; onChannelUpdated?.call(updated); - if (context.mounted) Navigator.of(context).pop(false); - } catch (error) { - actionError.value = error.toString(); + Navigator.of(context).pop(false); + } catch (_) { + if (context.mounted) { + error.value = "We couldn't save this change. Try again."; + } } finally { - isSavingDetails.value = false; + if (context.mounted) saving.value = false; } } - Future saveCanvas() async { - if (isSavingCanvas.value) return; - isSavingCanvas.value = true; - actionError.value = null; + Future editCanvas() async { + if (busy) return; + openingCanvas.value = true; + error.value = null; try { - await ref - .read(channelActionsProvider) - .setCanvas( - channelId: channel.id, - content: canvasController.text.trim(), - ); - if (context.mounted) isEditingCanvas.value = false; - } catch (error) { - actionError.value = error.toString(); + final saved = await _editCanvas( + context, + ProviderScope.containerOf(context, listen: false), + actions, + channel.id, + initialContent: canvasLoaded ? canvasContent : null, + ); + if (context.mounted && saved != null) savedCanvas.value = saved; + } catch (_) { + if (context.mounted) { + error.value = "Couldn't load the canvas. Try again."; + } } finally { - isSavingCanvas.value = false; + if (context.mounted) openingCanvas.value = false; } } - return Padding( + Future removeCanvas() async { + if (busy || !canEditCanvas || !hasCanvas) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove canvas?'), + content: const Text( + 'This removes the canvas content for everyone in this channel.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + openingCanvas.value = true; + error.value = null; + try { + await actions.setCanvas(channelId: channel.id, content: ''); + if (context.mounted) savedCanvas.value = ''; + } catch (_) { + if (context.mounted) { + error.value = "Couldn't remove the canvas. Try again."; + } + } finally { + if (context.mounted) openingCanvas.value = false; + } + } + + return SingleChildScrollView( padding: EdgeInsets.fromLTRB( Grid.gutter, - 0, + Grid.xxs, Grid.gutter, MediaQuery.viewInsetsOf(context).bottom + Grid.xs, ), - child: SafeArea( - top: false, - child: ListView( - shrinkWrap: true, - children: [ - _ManageChannelTextField( - fieldKey: const ValueKey('manage-channel-name'), - outlineKey: const ValueKey('manage-channel-name-outline'), - controller: nameController, - enabled: canEditDetails && !isSavingDetails.value, - hintText: 'Channel name', - textInputAction: TextInputAction.next, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('manage-channel-name'), + controller: name, + autofocus: canEditDetails, + enabled: canEditDetails && !busy, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.next, + decoration: const InputDecoration(hintText: 'Channel name'), + ), + const SizedBox(height: Grid.xs), + TextField( + key: const ValueKey('manage-channel-description'), + controller: description, + enabled: canEditDetails && !busy, + minLines: 4, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration(hintText: 'Description'), + ), + const SizedBox(height: Grid.xs), + if (hasCanvas) ...[ + Material( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.container), + clipBehavior: Clip.antiAlias, + child: ListTile( + key: const ValueKey('manage-channel-canvas'), + title: const Text('Canvas'), + subtitle: Text( + canvasContent, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right), + enabled: canEditCanvas && !busy, + onTap: canEditCanvas && !busy ? editCanvas : null, + ), ), - const SizedBox(height: Grid.xs), - _ManageChannelTextField( - fieldKey: const ValueKey('manage-channel-description'), - outlineKey: const ValueKey('manage-channel-description-outline'), - controller: descriptionController, - enabled: canEditDetails && !isSavingDetails.value, - hintText: 'Description', - minLines: 2, - maxLines: 4, - textInputAction: TextInputAction.newline, + if (canEditCanvas) + TextButton( + key: const ValueKey('manage-channel-remove-canvas'), + onPressed: busy ? null : removeCanvas, + style: TextButton.styleFrom( + foregroundColor: context.colors.error, + ), + child: const Text('Remove Canvas'), + ), + ] else + FilledButton.tonal( + key: const ValueKey('manage-channel-canvas'), + onPressed: canEditCanvas && !busy ? editCanvas : null, + child: Text( + openingCanvas.value || (!canvasLoaded && canvas.isLoading) + ? 'Loading…' + : canvasLoaded + ? 'Add Canvas' + : 'Retry loading canvas', + ), ), - if (canonicalName.isEmpty) ...[ - const SizedBox(height: Grid.half), - Text( - 'Channel name is required.', + if (canonicalName.isEmpty || error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Semantics( + liveRegion: true, + child: Text( + canonicalName.isEmpty + ? 'Channel name is required.' + : error.value!, style: context.textTheme.bodySmall?.copyWith( color: context.colors.error, ), ), - ], - const SizedBox(height: Grid.xs), - Align( - alignment: Alignment.centerRight, - child: FilledButton( - key: const ValueKey('manage-channel-save-details'), - onPressed: canSaveDetails ? saveDetails : null, - child: Text(isSavingDetails.value ? 'Saving…' : 'Save changes'), - ), ), - const SizedBox(height: Grid.sm), - _ManageChannelSection( - label: 'Canvas', - child: canvasAsync.when( - data: (canvas) { - if (isEditingCanvas.value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - key: const ValueKey('manage-channel-canvas'), - controller: canvasController, - maxLines: 8, - minLines: 6, - decoration: const InputDecoration( - hintText: 'Write your canvas content in Markdown…', - ), - ), - const SizedBox(height: Grid.xxs), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: isSavingCanvas.value - ? null - : () { - isEditingCanvas.value = false; - canvasController.text = - canvas.content ?? ''; - }, - child: const Text('Cancel'), - ), - const SizedBox(width: Grid.half), - FilledButton( - onPressed: isSavingCanvas.value - ? null - : saveCanvas, - child: Text( - isSavingCanvas.value - ? 'Saving…' - : 'Save canvas', - ), - ), - ], - ), - ], - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - canvas.content?.trim().isNotEmpty == true - ? canvas.content! - : 'No canvas set for this channel.', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - const SizedBox(height: Grid.xxs), - Align( - alignment: Alignment.centerRight, - child: FilledButton.tonal( - onPressed: canEditCanvas - ? () => isEditingCanvas.value = true - : null, - child: Text( - canvas.content?.trim().isNotEmpty == true - ? 'Edit canvas' - : 'Create canvas', - ), - ), - ), - ], - ); - }, - loading: () => const Center( - child: BuzzLoadingIndicator( - size: 40, - semanticLabel: 'Loading channel canvas', - ), - ), - error: (error, _) => Text( - 'Couldn’t load the channel canvas.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ), - ), - if (actionError.value case final error?) ...[ - const SizedBox(height: Grid.xxs), - Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ], ], - ), + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('manage-channel-save-details'), + onPressed: + canEditDetails && changed && canonicalName.isNotEmpty && !busy + ? save + : null, + child: Text(saving.value ? 'Saving…' : 'Save'), + ), + ], ), ); } } - -class _ManageChannelTextField extends StatelessWidget { - const _ManageChannelTextField({ - required this.fieldKey, - required this.outlineKey, - required this.controller, - required this.enabled, - required this.hintText, - required this.textInputAction, - this.minLines = 1, - this.maxLines = 1, - }); - - final Key fieldKey; - final Key outlineKey; - final TextEditingController controller; - final bool enabled; - final String hintText; - final TextInputAction textInputAction; - final int minLines; - final int maxLines; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - key: outlineKey, - decoration: BoxDecoration( - border: Border.all( - color: context.colors.outlineVariant.withValues(alpha: 0.8), - ), - borderRadius: BorderRadius.circular(Radii.card), - ), - child: Semantics( - label: hintText, - textField: true, - child: TextField( - key: fieldKey, - controller: controller, - enabled: enabled, - minLines: minLines, - maxLines: maxLines, - style: context.textTheme.bodyLarge, - decoration: InputDecoration( - hintText: hintText, - hintStyle: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.twelve, - ), - ), - textInputAction: textInputAction, - ), - ), - ); - } -} - -class _ManageChannelSection extends StatelessWidget { - const _ManageChannelSection({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs), - child: Text( - label, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - Material( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.card), - clipBehavior: Clip.antiAlias, - child: Padding(padding: const EdgeInsets.all(Grid.xs), child: child), - ), - ], - ); -} diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index ea16b418b6..bbfceddb60 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -8,6 +8,7 @@ import '../../shared/widgets/sheet_action_section.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/confirmation_dialog.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../profile/user_status.dart'; @@ -358,25 +359,11 @@ class _MemberTile extends ConsumerWidget { ), onTap: () async { Navigator.of(context).pop(); - final confirmed = await showBuzzDialog( + final confirmed = await showDestructiveConfirmation( context: context, - builder: (context) => AlertDialog( - title: const Text('Remove member'), - content: Text('Remove $label from this channel?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text( - 'Remove', - style: TextStyle(color: context.colors.error), - ), - ), - ], - ), + title: 'Remove member', + message: 'Remove $label from this channel?', + confirmLabel: 'Remove', ); if (confirmed == true) { await ref diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index b805ced637..c09fedfe68 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -224,7 +224,7 @@ final mentionCandidatesProvider = Provider.family return rankMentionCandidates( [ for (final candidate in candidates) - candidate.withContextLabel(names.resolve(candidate.pubkey)?.name), + candidate.withContextLabel(names.labelFor(candidate.pubkey)), ], args.query, viewer: currentPubkey, diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 4c3504322c..a3ac34d859 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -32,6 +32,7 @@ import 'channels_provider.dart'; import 'media_viewer_page.dart'; import 'message_content/link_normalizer.dart'; import 'message_media.dart'; +import 'message_mention_pill.dart'; import 'message_gallery.dart'; import 'message_gallery_frame.dart'; import 'message_media_geometry.dart'; @@ -874,7 +875,7 @@ class _MentionMd extends InlineMd { RegExp(r'\(([0-9a-f]{64})\)'), (m) => '(${m[1]!.substring(0, 8)}…${m[1]!.substring(60)})', ); - final pill = _MentionPill( + final pill = MessageMentionPill( label: visibleLabel, semanticsLabel: fullLabel, isAgent: isAgent, @@ -891,68 +892,4 @@ class _MentionMd extends InlineMd { } } -class _MentionPill extends StatelessWidget { - final String label; - final String? semanticsLabel; - final bool isAgent; - final TextStyle? textStyle; - - const _MentionPill({ - required this.label, - this.semanticsLabel, - required this.isAgent, - this.textStyle, - }); - - @override - Widget build(BuildContext context) { - final style = - (textStyle ?? context.textTheme.bodyMedium)?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w500, - height: 1, - ) ?? - TextStyle( - color: context.colors.primary, - fontWeight: FontWeight.w500, - height: 1, - ); - final fontSize = style.fontSize ?? 16; - - return Container( - padding: const EdgeInsets.fromLTRB( - Grid.half, - Grid.quarter + 1, - Grid.half, - Grid.quarter, - ), - decoration: BoxDecoration( - color: context.colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(Radii.sm), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (isAgent) ...[ - Icon( - BuzzIcons.bot, - size: fontSize * 0.95, - color: context.colors.primary, - ), - const SizedBox(width: Grid.quarter + 1), - ] else - Transform.translate( - offset: const Offset(0, -Grid.quarter), - child: Text('@', style: style), - ), - Flexible( - child: Text(label, style: style, semanticsLabel: semanticsLabel), - ), - ], - ), - ); - } -} - String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0'); diff --git a/mobile/lib/features/channels/message_mention_pill.dart b/mobile/lib/features/channels/message_mention_pill.dart new file mode 100644 index 0000000000..8b58bad991 --- /dev/null +++ b/mobile/lib/features/channels/message_mention_pill.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import '../../shared/theme/buzz_icons.dart'; + +import '../../shared/theme/theme.dart'; + +/// The inline identity pill shared by chat mentions and channel actions. +class MessageMentionPill extends StatelessWidget { + /// Visible contextual identity name. + final String label; + + /// Full identity label for assistive technologies. + final String? semanticsLabel; + + /// Whether to use the bot icon instead of the @ prefix. + final bool isAgent; + + /// Typography inherited from the surrounding message. + final TextStyle? textStyle; + + /// Creates a chat mention with an agent icon or human @ prefix. + const MessageMentionPill({ + super.key, + required this.label, + this.semanticsLabel, + required this.isAgent, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final style = + (textStyle ?? context.textTheme.bodyMedium)?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w500, + height: 1, + ) ?? + TextStyle( + color: context.colors.primary, + fontWeight: FontWeight.w500, + height: 1, + ); + final fontSize = style.fontSize ?? 16; + + return Container( + padding: const EdgeInsets.fromLTRB( + Grid.half, + Grid.quarter + 1, + Grid.half, + Grid.quarter, + ), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (isAgent) ...[ + Icon( + BuzzIcons.bot, + size: fontSize * 0.95, + color: context.colors.primary, + ), + const SizedBox(width: Grid.quarter + 1), + ] else + Transform.translate( + offset: const Offset(0, -Grid.quarter), + child: Text('@', style: style), + ), + Flexible( + child: Text(label, style: style, semanticsLabel: semanticsLabel), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index c36f6557b0..81bd0537f6 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -119,9 +119,9 @@ class SystemEvent { return '$actor removed $target from the channel'; }(), SystemEventType.topicChanged => - '$actor ${_describeTextFieldChange('topic', topic)}', + '$actor ${describeChannelTextFieldChange('topic', topic)}', SystemEventType.purposeChanged => - '$actor ${_describeTextFieldChange('purpose', purpose)}', + '$actor ${describeChannelTextFieldChange('purpose', purpose)}', SystemEventType.channelCreated => '$actor created this channel', SystemEventType.channelArchived => '$actor archived this channel', SystemEventType.channelUnarchived => '$actor unarchived this channel', @@ -142,7 +142,7 @@ class SystemEvent { /// /// Mirrors `describeChannelTextFieldChange` in /// `desktop/src/features/messages/lib/systemEventCopy.ts`. -String _describeTextFieldChange(String field, String? value) { +String describeChannelTextFieldChange(String field, String? value) { final trimmed = value?.trim(); if (trimmed == null || trimmed.isEmpty) { return 'cleared the $field'; diff --git a/mobile/lib/features/profile/ios_presence_menu.dart b/mobile/lib/features/profile/ios_presence_menu.dart new file mode 100644 index 0000000000..0b2816e22e --- /dev/null +++ b/mobile/lib/features/profile/ios_presence_menu.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Native iOS availability pill with a system menu owned by UIKit. +class IosPresenceMenu extends StatefulWidget { + const IosPresenceMenu({ + super.key, + required this.presence, + required this.label, + required this.foreground, + required this.background, + required this.onSelected, + }); + + final String presence; + final String label; + final Color foreground; + final Color background; + final ValueChanged onSelected; + + @override + State createState() => _IosPresenceMenuState(); +} + +class _IosPresenceMenuState extends State { + MethodChannel? _channel; + + Map _configuration(BuildContext context) => { + 'presence': widget.presence, + 'label': widget.label, + 'foreground': widget.foreground.toARGB32(), + 'background': widget.background.toARGB32(), + 'dark': Theme.of(context).brightness == Brightness.dark, + 'fontSize': MediaQuery.textScalerOf(context).scale(15), + }; + + @override + void didUpdateWidget(covariant IosPresenceMenu oldWidget) { + super.didUpdateWidget(oldWidget); + unawaited(_channel?.invokeMethod('update', _configuration(context))); + } + + @override + void dispose() { + _channel?.setMethodCallHandler(null); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fontSize = MediaQuery.textScalerOf(context).scale(15); + final text = TextPainter( + text: TextSpan( + text: widget.label, + style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w500), + ), + textDirection: Directionality.of(context), + )..layout(); + final width = text.width + 32; + final height = (fontSize + 24).clamp(44.0, double.infinity); + text.dispose(); + return SizedBox( + key: const ValueKey('settings-presence-target'), + width: width, + height: height, + child: UiKitView( + key: const ValueKey('settings-presence-menu'), + viewType: 'buzz/presence_menu', + creationParams: _configuration(context), + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (id) { + final channel = MethodChannel('buzz/presence_menu/$id'); + _channel = channel; + channel.setMethodCallHandler((call) async { + if (!mounted || call.method != 'selected') return; + final value = call.arguments; + if (value is String && + const ['online', 'away', 'offline'].contains(value)) { + widget.onSelected(value); + } + }); + unawaited( + channel.invokeMethod('update', _configuration(context)), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index 2975a1a591..7ead1d1651 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -1,78 +1,4 @@ -import 'package:flutter/services.dart'; +import '../../shared/widgets/ios_text_editor.dart'; -import '../../shared/theme/theme.dart'; - -/// Opens the native iOS form used to edit a single profile text field. -class IosProfileTextEditor { - IosProfileTextEditor._(); - - static const _channel = MethodChannel('buzz/profile_text_editor'); - - /// Presents the native editor and returns its submitted value, or null when - /// the user cancels. - static Future present({ - required String title, - required String initialValue, - required String placeholder, - required bool multiline, - required Brightness brightness, - required Color pageBackgroundColor, - required Color containerBackgroundColor, - bool allowUnchangedSubmission = false, - }) => _channel.invokeMethod('present', { - 'title': title, - 'initialValue': initialValue, - 'placeholder': placeholder, - 'multiline': multiline, - 'brightness': brightness.name, - 'pageBackgroundArgb': pageBackgroundColor.toARGB32(), - 'containerBackgroundArgb': containerBackgroundColor.toARGB32(), - 'containerCornerRadius': Radii.container, - 'allowUnchangedSubmission': allowUnchangedSubmission, - }); - - /// Keeps the native editor's latest value available until it saves or the - /// user cancels, so a transient publish failure never discards their text. - static Future presentUntilSaved({ - required String title, - required String initialValue, - required String placeholder, - required bool multiline, - required Brightness brightness, - required Color pageBackgroundColor, - required Color containerBackgroundColor, - required Future Function(String value) onSave, - required void Function() onSaveError, - bool Function(Object error)? shouldRetryOnError, - bool Function()? canPresent, - }) async { - var draft = initialValue; - var isRetry = false; - while (true) { - if (canPresent?.call() == false) return; - final value = await present( - title: title, - initialValue: draft, - placeholder: placeholder, - multiline: multiline, - brightness: brightness, - pageBackgroundColor: pageBackgroundColor, - containerBackgroundColor: containerBackgroundColor, - allowUnchangedSubmission: isRetry, - ); - if (value == null) return; - try { - await onSave(value); - return; - } catch (error) { - if (shouldRetryOnError?.call(error) == false || - canPresent?.call() == false) { - return; - } - draft = value; - isRetry = true; - onSaveError(); - } - } - } -} +/// The shared native text editor used by profile fields. +typedef IosProfileTextEditor = IosTextEditor; diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 62bc1c63bd..5a697344f3 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -1,15 +1,8 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; -import '../../shared/theme/theme.dart'; -import '../../shared/widgets/modal_presentation.dart'; -import 'ios_profile_text_editor.dart'; +import '../../shared/widgets/text_editor.dart'; import 'profile_provider.dart'; /// Opens the current user's display-name editor from a profile action surface. @@ -93,141 +86,13 @@ Future _showProfileTextEditor({ required String hintText, required Future Function(String value) onSave, bool multiline = false, -}) async { - if (defaultTargetPlatform == TargetPlatform.iOS) { - final sheetTheme = utilitySurfaceThemeData(Theme.of(context)); - try { - await IosProfileTextEditor.presentUntilSaved( - title: title, - initialValue: initialValue, - placeholder: hintText, - multiline: multiline, - brightness: sheetTheme.brightness, - pageBackgroundColor: sheetTheme.colorScheme.surface, - containerBackgroundColor: - sheetTheme.colorScheme.surfaceContainerHighest, - onSave: onSave, - shouldRetryOnError: (error) => - error is! ProfileCommunityChangedException, - canPresent: () => - context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), - onSaveError: () { - if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? true)) { - _showSaveError(context); - } - }, - ); - return; - } on MissingPluginException { - // Previews and older builds retain the complete Flutter fallback. - } on PlatformException { - // A temporary native presentation failure should not block editing. - } - } - - if (!context.mounted) return; - await showBuzzModalBottomSheet( - context: context, - title: title, - isScrollControlled: true, - requestFocus: true, - builder: (_) => _ProfileTextEditSheet( - initialValue: initialValue, - hintText: hintText, - multiline: multiline, - onSave: onSave, - ), - ); -} - -void _showSaveError(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("We couldn't save this change. Try again.")), - ); -} - -class _ProfileTextEditSheet extends HookWidget { - const _ProfileTextEditSheet({ - required this.initialValue, - required this.hintText, - required this.multiline, - required this.onSave, - }); - - final String initialValue; - final String hintText; - final bool multiline; - final Future Function(String value) onSave; - - @override - Widget build(BuildContext context) { - final controller = useTextEditingController(text: initialValue); - useListenable(controller); - final isSaving = useState(false); - final error = useState(null); - final hasChanges = controller.text.trim() != initialValue.trim(); - - Future save() async { - if (!hasChanges || isSaving.value) return; - isSaving.value = true; - error.value = null; - try { - await onSave(controller.text); - if (context.mounted) Navigator.of(context).pop(); - } on ProfileCommunityChangedException { - if (context.mounted) Navigator.of(context).pop(); - } catch (_) { - error.value = "We couldn't save this change. Try again."; - } finally { - if (context.mounted) isSaving.value = false; - } - } - - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - key: const ValueKey('profile-field-input'), - controller: controller, - autofocus: true, - enabled: !isSaving.value, - minLines: multiline ? 4 : 1, - maxLines: multiline ? 6 : 1, - textCapitalization: TextCapitalization.sentences, - textInputAction: multiline - ? TextInputAction.newline - : TextInputAction.done, - onSubmitted: multiline ? null : (_) => unawaited(save()), - decoration: InputDecoration(hintText: hintText), - ), - if (error.value != null) ...[ - const SizedBox(height: Grid.xxs), - Text( - error.value!, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ], - const SizedBox(height: Grid.xs), - FilledButton( - key: const ValueKey('profile-field-save'), - onPressed: hasChanges && !isSaving.value ? save : null, - child: Text(isSaving.value ? 'Saving…' : 'Save'), - ), - ], - ), - ), - ); - } -} +}) => showTextEditor( + context: context, + title: title, + initialValue: initialValue, + hintText: hintText, + onSave: onSave, + multiline: multiline, + keyPrefix: 'profile-field', + shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, +); diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index c97b5f33b0..e72f80ad02 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -16,6 +17,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/progressive_animated_avatar.dart'; import 'profile_provider.dart'; +import 'ios_presence_menu.dart'; import 'set_status_sheet.dart'; import 'user_status_provider.dart'; @@ -247,6 +249,16 @@ class _PresencePill extends StatelessWidget { }; final label = _presenceLabel(effectivePresence); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { + return IosPresenceMenu( + presence: effectivePresence, + label: label, + foreground: foregroundColor, + background: backgroundColor, + onSelected: onSelected, + ); + } + return Builder( builder: (buttonContext) => Semantics( button: true, diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 438f112566..b11947e00b 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -117,8 +117,7 @@ class UserProfileSheet extends HookConsumerWidget { // never placed on the clipboard. final npub = fullNpub(pubkey); - // The contextual label from the opening surface, so the sheet names the - // identity exactly as the row that was tapped. + // Keep the opening surface's comparison context and qualified identity. final opener = names; final displayName = (opener != null ? ref.watch(opener) : watchIdentityNames(ref, {pk})) diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 0f780b8a86..174310a27d 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -71,71 +71,68 @@ class SettingsPage extends HookConsumerWidget { bottomHeight: Grid.xxs, ); - return FrostedScaffold( - useUtilitySurfaceTheme: true, - appBar: FrostedAppBar( - nativeTitle: 'Settings', - nativeLeading: IosNavigationAction( - label: 'Close settings', - symbol: 'xmark', - onPressed: () => Navigator.of(context).pop(), - ), - automaticallyImplyLeading: false, - horizontalInset: Grid.gutter, - showBottomDivider: false, - leading: Theme.of(context).platform == TargetPlatform.iOS - ? IosGlassNavigationButton( - key: const ValueKey('settings-ios-glass-close'), - icon: IosGlassNavigationIcon.close, - semanticLabel: 'Close settings', - onPressed: () { - unawaited(HapticFeedback.lightImpact()); - Navigator.of(context).pop(); - }, - foregroundColor: navigationPrimaryForeground(context), - ) - : SizedBox( - width: Grid.xl, - height: Grid.xl, - child: IconButton( - tooltip: 'Close settings', + // Keep feedback local even while Home is mounted during the transition. + return ScaffoldMessenger( + child: FrostedScaffold( + useUtilitySurfaceTheme: true, + appBar: FrostedAppBar( + nativeTitle: 'Settings', + nativeLeading: IosNavigationAction( + label: 'Close settings', + symbol: 'xmark', + onPressed: () => Navigator.of(context).pop(), + ), + automaticallyImplyLeading: false, + horizontalInset: Grid.gutter, + showBottomDivider: false, + leading: Theme.of(context).platform == TargetPlatform.iOS + ? IosGlassNavigationButton( + key: const ValueKey('settings-ios-glass-close'), + icon: IosGlassNavigationIcon.close, + semanticLabel: 'Close settings', onPressed: () { unawaited(HapticFeedback.lightImpact()); Navigator.of(context).pop(); }, - color: navigationPrimaryForeground(context), - icon: const Icon(BuzzIcons.x), + foregroundColor: navigationPrimaryForeground(context), + ) + : SizedBox( + width: Grid.xl, + height: Grid.xl, + child: IconButton( + tooltip: 'Close settings', + onPressed: () { + unawaited(HapticFeedback.lightImpact()); + Navigator.of(context).pop(); + }, + color: navigationPrimaryForeground(context), + icon: const Icon(BuzzIcons.x), + ), ), + bottomHeight: Grid.xxs, + bottom: const SizedBox.expand(), + ), + body: ListView( + padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs), + children: [ + profileHeader, + _StatusSection(onSetStatus: onSetStatus), + _ProfileSection( + profileEditPageBuilder: profileEditPageBuilder, + onEditDisplayName: onEditDisplayName, + onEditProfileDescription: onEditProfileDescription, + ), + const _NotificationsSection(), + _ConnectionSection( + identityRecoveryPageBuilder: identityRecoveryPageBuilder, + ), + if (packageInfo.hasData) + _VersionFooter( + version: packageInfo.data!.version, + buildNumber: packageInfo.data!.buildNumber, ), - bottomHeight: Grid.xxs, - bottom: const SizedBox.expand(), - ), - body: Column( - children: [ - Expanded( - child: ListView( - padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs), - children: [ - profileHeader, - _StatusSection(onSetStatus: onSetStatus), - _ProfileSection( - profileEditPageBuilder: profileEditPageBuilder, - onEditDisplayName: onEditDisplayName, - onEditProfileDescription: onEditProfileDescription, - ), - const _NotificationsSection(), - _ConnectionSection( - identityRecoveryPageBuilder: identityRecoveryPageBuilder, - ), - ], - ), - ), - if (packageInfo.hasData) - _VersionFooter( - version: packageInfo.data!.version, - buildNumber: packageInfo.data!.buildNumber, - ), - ], + ], + ), ), ); } diff --git a/mobile/lib/shared/identity_names/identity_names.dart b/mobile/lib/shared/identity_names/identity_names.dart index ba45a2b434..81dd315fa1 100644 --- a/mobile/lib/shared/identity_names/identity_names.dart +++ b/mobile/lib/shared/identity_names/identity_names.dart @@ -161,8 +161,9 @@ class IdentityNames { }); } - /// The display label for [pubkey]. A malformed key is outside the naming - /// contract: it keeps its plain known name and is never disambiguated. + /// The unambiguous display label for [pubkey], including any qualifier + /// needed to distinguish identities with the same name and owner. + /// A malformed key keeps its plain known name and is never disambiguated. String labelFor(String pubkey) => resolve(pubkey)?.name ?? _plainName(pubkey.toLowerCase()); diff --git a/mobile/lib/shared/widgets/ios_text_editor.dart b/mobile/lib/shared/widgets/ios_text_editor.dart new file mode 100644 index 0000000000..e91329ae3b --- /dev/null +++ b/mobile/lib/shared/widgets/ios_text_editor.dart @@ -0,0 +1,78 @@ +import 'package:flutter/services.dart'; + +import '../theme/theme.dart'; + +/// Opens the native iOS form used to edit a single text field. +class IosTextEditor { + IosTextEditor._(); + + static const _channel = MethodChannel('buzz/profile_text_editor'); + + /// Presents the native editor and returns its submitted value, or null when + /// the user cancels. + static Future present({ + required String title, + required String initialValue, + required String placeholder, + required bool multiline, + required Brightness brightness, + required Color pageBackgroundColor, + required Color containerBackgroundColor, + bool allowUnchangedSubmission = false, + }) => _channel.invokeMethod('present', { + 'title': title, + 'initialValue': initialValue, + 'placeholder': placeholder, + 'multiline': multiline, + 'brightness': brightness.name, + 'pageBackgroundArgb': pageBackgroundColor.toARGB32(), + 'containerBackgroundArgb': containerBackgroundColor.toARGB32(), + 'containerCornerRadius': Radii.container, + 'allowUnchangedSubmission': allowUnchangedSubmission, + }); + + /// Keeps the native editor's latest value available until it saves or the + /// user cancels, so a transient publish failure never discards their text. + static Future presentUntilSaved({ + required String title, + required String initialValue, + required String placeholder, + required bool multiline, + required Brightness brightness, + required Color pageBackgroundColor, + required Color containerBackgroundColor, + required Future Function(String value) onSave, + required void Function() onSaveError, + bool Function(Object error)? shouldRetryOnError, + bool Function()? canPresent, + }) async { + var draft = initialValue; + var isRetry = false; + while (true) { + if (canPresent?.call() == false) return; + final value = await present( + title: title, + initialValue: draft, + placeholder: placeholder, + multiline: multiline, + brightness: brightness, + pageBackgroundColor: pageBackgroundColor, + containerBackgroundColor: containerBackgroundColor, + allowUnchangedSubmission: isRetry, + ); + if (value == null) return; + try { + await onSave(value); + return; + } catch (error) { + if (shouldRetryOnError?.call(error) == false || + canPresent?.call() == false) { + return; + } + draft = value; + isRetry = true; + onSaveError(); + } + } + } +} diff --git a/mobile/lib/shared/widgets/text_editor.dart b/mobile/lib/shared/widgets/text_editor.dart new file mode 100644 index 0000000000..f27d2d8777 --- /dev/null +++ b/mobile/lib/shared/widgets/text_editor.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../theme/theme.dart'; +import 'modal_presentation.dart'; +import 'ios_text_editor.dart'; + +/// Opens the shared native iOS text form, with a Flutter sheet fallback. +Future showTextEditor({ + required BuildContext context, + required String title, + required String initialValue, + required String hintText, + required Future Function(String value) onSave, + bool multiline = false, + String keyPrefix = 'text-editor', + bool Function(Object error)? shouldRetryOnError, +}) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + final sheetTheme = utilitySurfaceThemeData(Theme.of(context)); + try { + await IosTextEditor.presentUntilSaved( + title: title, + initialValue: initialValue, + placeholder: hintText, + multiline: multiline, + brightness: sheetTheme.brightness, + pageBackgroundColor: sheetTheme.colorScheme.surface, + containerBackgroundColor: + sheetTheme.colorScheme.surfaceContainerHighest, + onSave: onSave, + shouldRetryOnError: shouldRetryOnError, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), + onSaveError: () { + if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? true)) { + _showSaveError(context); + } + }, + ); + return; + } on MissingPluginException { + // Previews and older builds retain the complete Flutter fallback. + } on PlatformException { + // A temporary native presentation failure should not block editing. + } + } + + if (!context.mounted) return; + await showBuzzModalBottomSheet( + context: context, + title: title, + isScrollControlled: true, + requestFocus: true, + builder: (_) => _TextEditSheet( + initialValue: initialValue, + hintText: hintText, + multiline: multiline, + onSave: onSave, + shouldRetryOnError: shouldRetryOnError, + keyPrefix: keyPrefix, + ), + ); +} + +void _showSaveError(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("We couldn't save this change. Try again.")), + ); +} + +class _TextEditSheet extends HookWidget { + const _TextEditSheet({ + required this.initialValue, + required this.hintText, + required this.multiline, + required this.onSave, + this.shouldRetryOnError, + required this.keyPrefix, + }); + + final String keyPrefix; + final String initialValue; + final String hintText; + final bool multiline; + final Future Function(String value) onSave; + final bool Function(Object error)? shouldRetryOnError; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final isSaving = useState(false); + final error = useState(null); + final hasChanges = controller.text.trim() != initialValue.trim(); + + Future save() async { + if (!hasChanges || isSaving.value) return; + isSaving.value = true; + error.value = null; + try { + await onSave(controller.text); + if (context.mounted) Navigator.of(context).pop(); + } catch (saveError) { + if (!context.mounted) return; + if (shouldRetryOnError?.call(saveError) == false) { + Navigator.of(context).pop(); + return; + } + error.value = "We couldn't save this change. Try again."; + } finally { + if (context.mounted) isSaving.value = false; + } + } + + return SafeArea( + top: false, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: ValueKey('$keyPrefix-input'), + controller: controller, + autofocus: true, + enabled: !isSaving.value, + minLines: multiline ? 4 : 1, + maxLines: multiline ? 6 : 1, + textCapitalization: TextCapitalization.sentences, + textInputAction: multiline + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: multiline ? null : (_) => unawaited(save()), + decoration: InputDecoration(hintText: hintText), + ), + if (error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Text( + error.value!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.xs), + FilledButton( + key: ValueKey('$keyPrefix-save'), + onPressed: hasChanges && !isSaving.value ? save : null, + child: Text(isSaving.value ? 'Saving…' : 'Save'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/test/features/channels/channel_actions_sheet_test.dart b/mobile/test/features/channels/channel_actions_sheet_test.dart index ca7c41cbab..a381af16a2 100644 --- a/mobile/test/features/channels/channel_actions_sheet_test.dart +++ b/mobile/test/features/channels/channel_actions_sheet_test.dart @@ -10,6 +10,8 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -61,6 +63,8 @@ Widget _modalApp({ required Channel channel, required Future> Function() loadMembers, required ChannelActions Function(Ref ref) createChannelActions, + String? canvasContent, + Future? pendingCanvas, }) => ProviderScope( overrides: [ currentPubkeyProvider.overrideWith((ref) => _currentPubkey), @@ -72,11 +76,13 @@ Widget _modalApp({ const AsyncValue.data({}), ), channelCanvasProvider(channel.id).overrideWith( - (ref) async => const ChannelCanvas( - content: null, - updatedAt: null, - authorPubkey: null, - ), + (ref) => + pendingCanvas ?? + ChannelCanvas( + content: canvasContent, + updatedAt: null, + authorPubkey: null, + ), ), channelActionsProvider.overrideWith(createChannelActions), ], @@ -350,7 +356,7 @@ void main() { findsOneWidget, ); expect( - find.descendant(of: manageSheet, matching: find.text('Canvas')), + find.descendant(of: manageSheet, matching: find.text('Add Canvas')), findsOneWidget, ); expect( @@ -396,15 +402,18 @@ void main() { await tester.tap(find.text('Manage channel')); await tester.pumpAndSettle(); + expect( + tester.getSize(find.byType(BottomSheet).last).height, + greaterThan(480), + ); await tester.enterText( find.byKey(const ValueKey('manage-channel-name')), 'renamed', ); + await tester.pump(); await tester.tap(find.byKey(const ValueKey('manage-channel-save-details'))); await tester.pumpAndSettle(); - - expect(find.widgetWithText(ListTile, 'Manage channel'), findsOneWidget); - await tester.tap(find.widgetWithText(ListTile, 'Manage channel')); + await tester.tap(find.text('Manage channel')); await tester.pumpAndSettle(); final nameField = tester.widget( find.byKey(const ValueKey('manage-channel-name')), @@ -412,6 +421,337 @@ void main() { expect(nameField.controller?.text, 'renamed'); }); + testWidgets( + 'native channel editor shares the profile form and retains failed drafts', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const bridge = MethodChannel('buzz/profile_text_editor'); + final presentations = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, (call) async { + presentations.add( + Map.from(call.arguments as Map), + ); + expect(call.method, 'presentChannel'); + return { + 'action': 'save', + 'name': 'renamed', + 'description': 'Updated description', + }; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, null), + ); + var attempts = 0; + await tester.pumpWidget( + _modalApp( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onUpdateChannel: (_, name, description) async { + attempts++; + if (attempts == 1) throw Exception('offline'); + expect(name, 'renamed'); + expect(description, 'Updated description'); + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel')); + await tester.pumpAndSettle(); + expect(presentations, hasLength(2)); + expect(presentations.first['title'], 'Edit channel'); + expect(presentations.first['multiline'], false); + expect(presentations.last['initialValue'], 'renamed'); + expect(presentations.last['allowUnchangedSubmission'], true); + expect(presentations.last['description'], 'Updated description'); + expect(find.byType(ManageChannelSheet), findsNothing); + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets('native metadata editor opens before a cold canvas finishes', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final canvas = Completer(); + const bridge = MethodChannel('buzz/profile_text_editor'); + var opened = false; + var saved = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, (call) async { + expect(canvas.isCompleted, isFalse); + expect((call.arguments as Map)['canvasLoaded'], isFalse); + opened = true; + return { + 'action': 'save', + 'name': 'renamed', + 'description': 'New description', + }; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, null), + ); + await tester.pumpWidget( + _modalApp( + channel: _channel(), + pendingCanvas: canvas.future, + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onUpdateChannel: (_, name, description) async { + saved = true; + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel')); + await tester.pump(); + expect(opened, isTrue); + expect(saved, isTrue); + canvas.complete( + const ChannelCanvas(content: null, updatedAt: null, authorPubkey: null), + ); + await tester.pumpAndSettle(); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('native canvas round trip retains both channel drafts', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const bridge = MethodChannel('buzz/profile_text_editor'); + var channelPresentations = 0; + var savedDetails = false; + var savedCanvas = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, (call) async { + final args = Map.from(call.arguments as Map); + if (call.method == 'present') { + expect(args['title'], 'Canvas'); + expect(args['multiline'], true); + return 'New canvas'; + } + expect(call.method, 'presentChannel'); + channelPresentations++; + if (channelPresentations == 1) { + return { + 'action': 'canvas', + 'name': 'Draft name', + 'description': 'Draft description', + }; + } + expect(args['canvasContent'], 'New canvas'); + expect(args['canvasLoaded'], true); + expect(args['initialValue'], 'Draft name'); + expect(args['description'], 'Draft description'); + expect(args['originalName'], 'general'); + expect(args['originalDescription'], ''); + return { + 'action': 'save', + 'name': 'Draft name', + 'description': 'Draft description', + }; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, null), + ); + await tester.pumpWidget( + _modalApp( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onUpdateChannel: (_, name, description) async { + expect(name, 'Draft name'); + expect(description, 'Draft description'); + savedDetails = true; + }, + onSetCanvas: (content) async { + expect(content, 'New canvas'); + savedCanvas = true; + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel')); + await tester.pumpAndSettle(); + expect(channelPresentations, 2); + expect(savedDetails, isTrue); + expect(savedCanvas, isTrue); + expect(find.byType(ManageChannelSheet), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'canvas preview opens its content and removal retries without losing drafts', + (tester) async { + var removals = 0; + await tester.pumpWidget( + _modalApp( + channel: _channel(), + canvasContent: 'Shared team notes', + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onSetCanvas: (content) async { + expect(content, ''); + if (++removals == 1) throw Exception('offline'); + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel')); + await tester.pumpAndSettle(); + expect(find.text('Shared team notes'), findsOneWidget); + expect(find.text('Add Canvas'), findsNothing); + expect(find.text('Edit canvas'), findsNothing); + await tester.enterText( + find.byKey(const ValueKey('manage-channel-name')), + 'Draft name', + ); + await tester.tap(find.byKey(const ValueKey('manage-channel-canvas'))); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const ValueKey('text-editor-input'))) + .controller! + .text, + 'Shared team notes', + ); + Navigator.of( + tester.element(find.byKey(const ValueKey('text-editor-input'))), + ).pop(); + await tester.pumpAndSettle(); + for (var attempt = 0; attempt < 2; attempt++) { + await tester.ensureVisible(find.text('Remove Canvas')); + await tester.tap(find.text('Remove Canvas')); + await tester.pumpAndSettle(); + expect(removals, attempt); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + if (attempt == 0) { + expect(find.text('Shared team notes'), findsOneWidget); + expect( + find.text("Couldn't remove the canvas. Try again."), + findsOneWidget, + ); + } + } + expect(find.text('Add Canvas'), findsOneWidget); + expect(find.text('Remove Canvas'), findsNothing); + expect( + tester + .widget( + find.byKey(const ValueKey('manage-channel-name')), + ) + .controller! + .text, + 'Draft name', + ); + }, + ); + + testWidgets( + 'native removal retains canvas on failure and clears after retry', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const bridge = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + var attempts = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, (call) async { + final args = Map.from(call.arguments as Map); + expect(call.method, 'presentChannel'); + expect(args['canvasLoaded'], true); + expect( + args['canvasContent'], + presentations < 2 ? 'Existing canvas' : '', + ); + if (presentations > 0) { + expect(args['initialValue'], 'Draft name'); + expect(args['description'], 'Draft description'); + } + if (++presentations == 3) return null; + return { + 'action': 'removeCanvas', + 'name': 'Draft name', + 'description': 'Draft description', + }; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, null), + ); + await tester.pumpWidget( + _modalApp( + channel: _channel(), + canvasContent: 'Existing canvas', + loadMembers: () async => [], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onSetCanvas: (content) async { + expect(content, ''); + if (++attempts == 1) throw Exception('offline'); + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel')); + await tester.pumpAndSettle(); + expect(presentations, 3); + expect(attempts, 2); + debugDefaultTargetPlatformOverride = null; + }, + ); + testWidgets('non-member cannot edit canvas from Manage channel', ( tester, ) async { @@ -428,7 +768,7 @@ void main() { await tester.tap(find.text('Manage channel')); await tester.pumpAndSettle(); - final editCanvas = find.widgetWithText(FilledButton, 'Create canvas'); + final editCanvas = find.byKey(const ValueKey('manage-channel-canvas')); expect(editCanvas, findsOneWidget); expect(tester.widget(editCanvas).onPressed, isNull); }); @@ -466,15 +806,7 @@ void main() { expect( tester .widget( - find.byKey(const ValueKey('manage-channel-save-details')), - ) - .onPressed, - isNull, - ); - expect( - tester - .widget( - find.widgetWithText(FilledButton, 'Create canvas'), + find.byKey(const ValueKey('manage-channel-canvas')), ) .onPressed, isNotNull, @@ -539,7 +871,9 @@ class _FakeChannelActions extends ChannelActions { )? onUpdateChannel; - _FakeChannelActions(Ref ref, {this.onUpdateChannel}) + final Future Function(String)? onSetCanvas; + + _FakeChannelActions(Ref ref, {this.onUpdateChannel, this.onSetCanvas}) : super( ref: ref, session: ref.read(relaySessionProvider.notifier), @@ -559,6 +893,14 @@ class _FakeChannelActions extends ChannelActions { await onUpdateChannel?.call(channelId, name, description); } + @override + Future setCanvas({ + required String channelId, + required String content, + }) async { + await onSetCanvas?.call(content); + } + String? unarchivedChannelId; @override diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 692e518eef..37edf53898 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -45,6 +45,7 @@ import 'package:buzz/features/channels/message_action_backdrop_state.dart'; import 'package:buzz/features/channels/message_actions.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; import 'package:buzz/features/channels/reaction_row.dart'; +import 'package:buzz/features/channels/message_mention_pill.dart'; import 'package:buzz/features/channels/thread_detail_page.dart'; import 'package:buzz/features/channels/thread_replies_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; @@ -78,6 +79,7 @@ part 'thread_reply_refresh_cases.dart'; part 'thread_title_capsule_cases.dart'; part 'channel_detail_page_test/loading_review_tests.dart'; part 'channel_detail_page_test/presence_tests.dart'; +part 'channel_detail_page_test/action_row_tests.dart'; const _channelId = '11111111-2222-4333-8444-555555555555'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; @@ -496,6 +498,7 @@ void main() { threadReplyRefreshTests(); threadTitleCapsuleTests(); presenceTests(); + actionRowTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); @@ -3052,7 +3055,7 @@ void main() { final sheet = find.byType(BottomSheet).last; expect(find.byType(BottomSheet), findsOneWidget); - expect(tester.getSize(sheet).height, lessThanOrEqualTo(720)); + expect(tester.getSize(sheet).height, greaterThan(640)); final sheetTop = tester.getTopLeft(sheet).dy; await tester.dragFrom( @@ -3061,7 +3064,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Manage channel'), findsNothing); + expect(find.text('Edit channel'), findsNothing); }); testWidgets('Edit updates name and description without legacy fields', ( @@ -3103,28 +3106,9 @@ void main() { expect(find.text('Leave channel'), findsNothing); expect(find.text('Topic'), findsNothing); expect(find.text('Purpose'), findsNothing); - expect(find.text('Canvas'), findsOneWidget); - - final nameField = tester.widget( - find.byKey(const ValueKey('manage-channel-name')), - ); - final descriptionField = tester.widget( - find.byKey(const ValueKey('manage-channel-description')), - ); - expect(nameField.decoration?.labelText, isNull); - expect(nameField.decoration?.hintText, 'Channel name'); - expect(nameField.decoration?.border, InputBorder.none); - expect(descriptionField.decoration?.labelText, isNull); - expect(descriptionField.decoration?.hintText, 'Description'); - expect(descriptionField.decoration?.border, InputBorder.none); - final nameOutline = tester.getRect( - find.byKey(const ValueKey('manage-channel-name-outline')), - ); - final descriptionOutline = tester.getRect( - find.byKey(const ValueKey('manage-channel-description-outline')), - ); - expect(descriptionOutline.top - nameOutline.bottom, Grid.xs); + expect(find.text('Add Canvas'), findsOneWidget); + expect(find.text('Edit channel'), findsOneWidget); await tester.enterText( find.byKey(const ValueKey('manage-channel-name')), ' #renamed ', @@ -3138,7 +3122,6 @@ void main() { find.byKey(const ValueKey('manage-channel-save-details')), ); await tester.pumpAndSettle(); - expect(updatedName, 'renamed'); expect(updatedDescription, 'A new description'); expect(find.text('renamed'), findsOneWidget); @@ -9433,11 +9416,7 @@ void main() { messages: [ _systemMsg( id: 'sys-accessible', - payload: { - 'type': 'topic_changed', - 'actor': 'alice', - 'topic': 'Release planning', - }, + payload: {'type': 'member_left', 'actor': 'alice'}, createdAt: DateTime(2026, 7, 28, 12, 34).millisecondsSinceEpoch ~/ 1000, @@ -9488,7 +9467,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Alice removed Bob from the channel'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + expect(findRichText('removed '), findsOneWidget); + expect(find.widgetWithText(MessageMentionPill, 'Bob'), findsOneWidget); }); testWidgets('renders topic_changed system event', (tester) async { @@ -9514,7 +9495,7 @@ void main() { await tester.pumpAndSettle(); expect( - find.text('Alice changed the topic to "Release planning"'), + findRichText('changed the topic to "Release planning"'), findsOneWidget, ); }); @@ -9542,7 +9523,7 @@ void main() { await tester.pumpAndSettle(); expect( - find.text('Alice changed the purpose to "Team standup notes"'), + findRichText('changed the purpose to "Team standup notes"'), findsOneWidget, ); }); diff --git a/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart b/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart new file mode 100644 index 0000000000..0364ff6f0b --- /dev/null +++ b/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart @@ -0,0 +1,356 @@ +part of '../channel_detail_page_test.dart'; + +void actionRowTests() { + const users = { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }; + final cases = [ + ( + payload: {'type': 'member_removed', 'target': 'bob'}, + action: 'removed Bob from the channel', + ), + ( + payload: {'type': 'member_removed'}, + action: 'removed Someone from the channel', + ), + for (final field in ['topic', 'purpose']) ...[ + ( + payload: { + 'type': '${field}_changed', + field: ' Release planning ', + }, + action: 'changed the $field to "Release planning"', + ), + for (final blank in [null, '', ' \n\t ']) + ( + payload: {'type': '${field}_changed', field: blank}, + action: 'cleared the $field', + ), + ], + ]; + + group('Channel action rows', () { + for (var index = 0; index < cases.length; index++) { + final scenario = cases[index]; + testWidgets('uses message layout for action $index', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'regular', + pubkey: 'bob', + content: 'Regular message', + createdAt: 1000, + ), + _systemMsg( + id: 'action', + payload: {...scenario.payload, 'actor': 'alice'}, + createdAt: 1010, + ), + _reaction(id: 'reaction', targetId: 'action'), + ], + users: users, + ), + ); + await tester.pumpAndSettle(); + + final row = find.byKey(const ValueKey('system-message-row-action')); + final avatars = find.descendant( + of: row, + matching: find.byType(CircleAvatar), + ); + expect(avatars, findsOneWidget); + expect(tester.getSize(avatars), const Size.square(messageAvatarSize)); + final author = find.byKey( + const ValueKey('system-message-author-alice'), + ); + expect(author, findsOneWidget); + expect( + find.byKey(const ValueKey('system-message-author-bob')), + findsNothing, + ); + expect(findRichText('Alice ${scenario.action}'), findsNothing); + final body = findRichText( + scenario.payload['target'] == 'bob' ? 'removed ' : scenario.action, + ); + if (scenario.payload['target'] == 'bob') { + expect( + find.widgetWithText(MessageMentionPill, 'Bob'), + findsOneWidget, + ); + expect(find.text('@'), findsOneWidget); + } + expect(body, findsOneWidget); + final regularBody = tester.getRect(findRichText('Regular message')); + final regularAuthor = tester.getRect( + find.byKey(const ValueKey('message-author-regular')), + ); + final actionBody = tester.getRect(body); + final actionAuthor = tester.getRect(author); + expect(actionBody.left, regularBody.left); + expect(actionAuthor.left, regularAuthor.left); + expect( + actionBody.top - actionAuthor.bottom, + closeTo(regularBody.top - regularAuthor.bottom, 0.01), + ); + final timestamp = find.byKey( + const ValueKey('system-message-timestamp-alice'), + ); + expect(tester.widget(timestamp).data, formatMessageTime(1010)); + expect(tester.getRect(timestamp).top, lessThan(actionBody.top)); + final reactions = find.descendant( + of: row, + matching: find.byType(ReactionRow), + ); + expect(reactions, findsOneWidget); + expect(tester.getTopLeft(reactions).dx, actionBody.left); + expect(tester.takeException(), isNull); + }); + + testWidgets('keeps missing-actor fallback for action $index', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [_systemMsg(id: 'fallback', payload: scenario.payload)], + users: users, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Someone ${scenario.action}'), findsOneWidget); + expect(find.byIcon(BuzzIcons.arrowLeftRight), findsOneWidget); + expect( + find.byKey(const ValueKey('system-message-timestamp-fallback')), + findsOneWidget, + ); + }); + } + + testWidgets('removal keeps both profiles and reaction actions accessible', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'removal', + payload: { + 'type': 'member_removed', + 'actor': 'alice', + 'target': 'bob', + }, + ), + ], + users: users, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(UserProfileSheet)).pubkey, + 'alice', + ); + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + final targetSemantics = find.semantics.byLabel('Bob').evaluate(); + expect(targetSemantics, hasLength(1)); + expect( + targetSemantics.single.getSemanticsData().hasAction( + SemanticsAction.tap, + ), + isTrue, + ); + await tester.tap(find.widgetWithText(MessageMentionPill, 'Bob')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(UserProfileSheet)).pubkey, + 'bob', + ); + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + await tester.longPress( + find.byKey(const ValueKey('system-message-row-removal')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); + Navigator.of( + tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), + ).pop(); + await tester.pumpAndSettle(); + }); + + for (final source in ['directory', 'profile', 'channel role']) { + testWidgets('removed agent uses chat mention pill from $source', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'agent-removal', + payload: { + 'type': 'member_removed', + 'actor': 'alice', + 'target': 'bob', + }, + ), + ], + users: { + ...users, + 'bob': UserProfile( + pubkey: 'bob', + displayName: 'Helper Bot', + ownerPubkey: source == 'profile' ? 'alice' : null, + ), + }, + knownAgentPubkeys: source == 'directory' ? {'bob'} : {}, + loadChannelBotPubkeys: () async => + source == 'channel role' ? {'bob'} : {}, + ), + ); + await tester.pumpAndSettle(); + final pill = find.widgetWithText(MessageMentionPill, 'Helper Bot'); + expect(pill, findsOneWidget); + expect(tester.widget(pill).isAgent, isTrue); + expect( + find.descendant(of: pill, matching: find.byIcon(BuzzIcons.bot)), + findsOneWidget, + ); + expect( + find.descendant(of: pill, matching: find.text('@')), + findsNothing, + ); + expect( + tester.widget(find.text('Helper Bot')).style?.decoration, + isNot(TextDecoration.underline), + ); + final target = find.semantics.byLabel('Helper Bot').evaluate(); + expect(target, hasLength(1)); + expect( + target.single.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + await tester.tap(pill); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(UserProfileSheet)).pubkey, + 'bob', + ); + }); + } + + testWidgets( + 'agent headers, action targets, and profiles keep distinct identities', + (tester) async { + final first = 'a' * 64, second = 'b' * 64; + final agents = {first, second}; + final profiles = { + for (final key in agents) + key: UserProfile(pubkey: key, displayName: 'Scout'), + }; + final names = IdentityNameSources( + profiles: profiles, + agentPubkeys: agents, + ).scope(agents); + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'agent-message', pubkey: first, content: 'hello'), + _systemMsg( + id: 'agent-removal', + payload: { + 'type': 'member_removed', + 'actor': first, + 'target': second, + }, + ), + ], + users: profiles, + knownAgentPubkeys: agents, + members: [ + for (final key in agents) + ChannelMember( + pubkey: key, + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + final header = find.byKey( + const ValueKey('message-author-agent-message'), + ); + expect(tester.widget(header).data, names.labelFor(first)); + expect( + tester + .widget( + find.byKey(ValueKey('system-message-author-$first')), + ) + .data, + names.labelFor(first), + ); + final pill = find.widgetWithText( + MessageMentionPill, + names.labelFor(second), + ); + expect(pill, findsOneWidget); + expect(names.labelFor(first), isNot(names.labelFor(second))); + expect(find.text('Scout'), findsNothing); + for (final entry in [(header, first), (pill, second)]) { + await tester.tap(entry.$1); + await tester.pumpAndSettle(); + final sheet = find.byType(UserProfileSheet); + expect(tester.widget(sheet).pubkey, entry.$2); + expect( + find.descendant( + of: sheet, + matching: find.text(names.resolve(entry.$2)!.name), + ), + findsOneWidget, + ); + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }, + ); + + for (final scenario in [cases.first, cases[2]]) { + testWidgets('wraps ${scenario.payload['type']} at large text sizes', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'large', + payload: {...scenario.payload, 'actor': 'alice'}, + ), + ], + users: users, + textScaler: const TextScaler.linear(3), + ), + ); + await tester.pumpAndSettle(); + expect( + findRichText( + scenario.payload['target'] == 'bob' ? 'removed ' : scenario.action, + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('system-message-timestamp-alice')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + } + }); +} diff --git a/mobile/test/features/channels/channel_member_profile_actions_test.dart b/mobile/test/features/channels/channel_member_profile_actions_test.dart index 883ae9adfb..8408b90fa6 100644 --- a/mobile/test/features/channels/channel_member_profile_actions_test.dart +++ b/mobile/test/features/channels/channel_member_profile_actions_test.dart @@ -7,6 +7,8 @@ import 'package:buzz/features/channels/channel_member_profile_actions.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -113,6 +115,108 @@ Future _pump( } void main() { + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets( + 'same-name agents have distinct ${platform.name} removal confirmations', + (tester) async { + debugDefaultTargetPlatformOverride = platform; + const bridge = MethodChannel('buzz/confirmation_dialog'); + final messages = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, (call) async { + messages.add((call.arguments as Map)['message'] as String); + return true; + }); + final keys = ['a' * 64, 'b' * 64]; + final actions = _Actions(); + try { + for (final key in keys) { + await _pump( + tester, + target: key, + actions: actions, + inSheet: true, + load: () async => [ + _member('self', 'owner'), + for (final agent in keys) + ChannelMember( + pubkey: agent, + displayName: 'Scout', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + ); + await tester.tap(find.text('Remove from channel')); + await tester.pumpAndSettle(); + if (platform == TargetPlatform.android) { + final dialog = tester.widget( + find.byWidgetPredicate((widget) => widget is AlertDialog), + ); + messages.add((dialog.content! as Text).data!); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + } + } + expect(messages, hasLength(2)); + expect(messages.toSet(), hasLength(2)); + for (final message in messages) { + expect(message, startsWith('Remove Scout · ')); + } + expect(actions.calls, [for (final key in keys) 'test:$key:remove']); + } finally { + await tester.pumpWidget(const SizedBox()); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, null); + debugDefaultTargetPlatformOverride = null; + } + }, + ); + } + + for (final confirmed in [false, true]) { + testWidgets('iOS removal uses native confirmation: $confirmed', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const bridge = MethodChannel('buzz/confirmation_dialog'); + final response = Completer(); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, (call) { + calls.add(call); + return response.future; + }); + try { + final actions = _Actions(); + await _pump(tester, actions: actions, inSheet: true); + await tester.tap(find.text('Remove from channel')); + await tester.pumpAndSettle(); + expect(calls, hasLength(1)); + expect(calls.single.method, 'present'); + expect( + calls.single.arguments, + containsPair('title', 'Remove from channel?'), + ); + expect(calls.single.arguments, containsPair('confirmLabel', 'Remove')); + expect(calls.single.arguments, containsPair('cancelLabel', 'Cancel')); + expect(find.byType(AlertDialog), findsNothing); + expect(actions.calls, isEmpty); + response.complete(confirmed); + await tester.pumpAndSettle(); + expect(actions.calls, confirmed ? ['test:alice:remove'] : isEmpty); + expect( + find.byType(ChannelMemberProfileActions), + confirmed ? findsNothing : findsOneWidget, + ); + } finally { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(bridge, null); + debugDefaultTargetPlatformOverride = null; + } + }); + } + testWidgets('successful removal closes the member sheet', (tester) async { final actions = _Actions(); await _pump(tester, actions: actions, inSheet: true); diff --git a/mobile/test/features/channels/ios_add_members_sheet_test.dart b/mobile/test/features/channels/ios_add_members_sheet_test.dart new file mode 100644 index 0000000000..6061e4d2ec --- /dev/null +++ b/mobile/test/features/channels/ios_add_members_sheet_test.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:buzz/features/channels/add_members_sheet.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final _alice = 'a' * 64; +final _bob = 'b' * 64; +final _existing = 'e' * 64; +final _avatar = + 'data:image/svg+xml,${Uri.encodeComponent('')}'; +const _bridge = MethodChannel('buzz/add_members_sheet'); + +class _Cache extends UserCacheNotifier { + @override + Map build() => const {}; + @override + Future preload(List pubkeys) async => true; +} + +class _Actions extends Fake implements ChannelActions { + final calls = >[]; + bool failBob = false; + @override + Future addMembers({ + required String channelId, + required List pubkeys, + String role = 'member', + }) async { + calls.add(pubkeys); + if (failBob) throw AddMembersException({_bob: 'Try again'}); + } +} + +Future _pump( + WidgetTester tester, + _Actions actions, { + List? directory, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith(_Cache.new), + channelActionsProvider.overrideWithValue(actions), + channelMembersProvider('channel').overrideWith((ref) async => []), + relayDirectoryUsersProvider.overrideWith( + (ref) async => + directory ?? + [ + DirectoryUser(pubkey: _alice, displayName: 'Alice'), + DirectoryUser( + pubkey: _bob, + displayName: 'Bob', + isAgent: true, + avatarUrl: _avatar, + ), + DirectoryUser(pubkey: _existing, displayName: 'Existing'), + ], + ), + relayDirectorySearchProvider('bob').overrideWith( + (ref) async => [ + DirectoryUser( + pubkey: _bob, + displayName: 'Bob', + isAgent: true, + avatarUrl: _avatar, + ), + ], + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showAddChannelMembersSheet( + context: context, + channelId: 'channel', + existingPubkeys: {_existing}, + ), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); +} + +Future _event( + WidgetTester tester, + String session, + String method, [ + Map values = const {}, +]) async { + final response = Completer(); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + _bridge.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall(method, {'session': session, ...values}), + ), + (_) => response.complete(), + ); + await response.future; + await tester.pumpAndSettle(); +} + +void main() { + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets( + 'same-name agents remain distinct in ${platform.name} member choices', + (tester) async { + debugDefaultTargetPlatformOverride = platform; + final states = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_bridge, (call) async { + if (call.method != 'dismiss') { + states.add(Map.from(call.arguments as Map)); + } + return null; + }); + try { + final actions = _Actions(); + await _pump( + tester, + actions, + directory: [ + for (final key in [_alice, _bob]) + DirectoryUser(pubkey: key, displayName: 'Scout', isAgent: true), + ], + ); + if (platform == TargetPlatform.iOS) { + final rows = states.last['users'] as List; + expect(rows.map((row) => row['name']).toSet(), hasLength(2)); + for (final row in rows) { + expect(row['name'], startsWith('Scout · ')); + } + final session = states.last['session'] as String; + await _event(tester, session, 'toggle', {'pubkey': _bob}); + final selected = (states.last['selected'] as List).single as Map; + expect(selected['pubkey'], _bob); + expect(selected['name'], (rows.last as Map)['name']); + await _event(tester, session, 'submit'); + } else { + final labels = tester + .widgetList(find.byType(Text)) + .map((text) => text.data) + .whereType() + .where((label) => label.startsWith('Scout · ')) + .toList(); + expect(labels.toSet(), hasLength(2)); + final semantics = tester.ensureSemantics(); + expect( + find.bySemanticsLabel(RegExp(RegExp.escape(labels.last))), + findsWidgets, + ); + semantics.dispose(); + await tester.tap(find.text(labels.last)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add member')); + await tester.pumpAndSettle(); + } + expect(actions.calls, [ + [_bob], + ]); + } finally { + await tester.pumpWidget(const SizedBox()); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_bridge, null); + debugDefaultTargetPlatformOverride = null; + } + }, + ); + } + + testWidgets( + 'iOS native search retains selections and retries only failed members', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final states = >[]; + final methods = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_bridge, (call) async { + methods.add(call.method); + if (call.method != 'dismiss') { + states.add(Map.from(call.arguments as Map)); + } + return null; + }); + try { + final actions = _Actions()..failBob = true; + await _pump(tester, actions); + expect(methods.first, 'present'); + expect(find.byType(TextField), findsNothing); + expect((states.last['users'] as List).map((row) => row['pubkey']), [ + _alice, + _bob, + ]); + final session = states.last['session'] as String; + await _event(tester, 'stale-session', 'toggle', {'pubkey': _alice}); + expect(states.last['selected'], isEmpty); + await _event(tester, session, 'toggle', {'pubkey': _alice}); + await _event(tester, session, 'toggle', {'pubkey': _bob}); + expect(states.last['selected'] as List, hasLength(2)); + await _event(tester, session, 'query', {'value': 'bob'}); + expect(states.last['loading'], isTrue); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(states.last['loading'], isFalse); + expect((states.last['users'] as List).map((row) => row['pubkey']), [ + _bob, + ]); + expect(states.last['selected'] as List, hasLength(2)); + await _event(tester, session, 'submit'); + expect(actions.calls, [ + [_alice, _bob], + ]); + expect((states.last['selected'] as List).map((row) => row['pubkey']), [ + _bob, + ]); + expect(states.last['error'], isNotNull); + await _event(tester, session, 'query', {'value': ''}); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect((states.last['users'] as List).map((row) => row['pubkey']), [ + _bob, + ]); + actions.failBob = false; + await _event(tester, session, 'submit'); + expect(actions.calls, [ + [_alice, _bob], + [_bob], + ]); + expect(find.text('Open picker'), findsOneWidget); + expect(methods.last, 'dismiss'); + } finally { + await tester.pumpWidget(const SizedBox()); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_bridge, null); + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'native picker renders member artwork and rejects stale avatar requests', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final states = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_bridge, (call) async { + if (call.method != 'dismiss') { + states.add(Map.from(call.arguments as Map)); + } + return null; + }); + try { + await _pump(tester, _Actions()); + final session = states.last['session'] as String; + final row = (states.last['users'] as List).last as Map; + Future request(String key, {String? requestSession}) async { + final response = Completer(); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + _bridge.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('avatar', { + 'session': requestSession ?? session, + 'pubkey': _bob, + 'avatarKey': key, + }), + ), + (data) => response.complete( + const StandardMethodCodec().decodeEnvelope(data!), + ), + ); + return response.future; + } + + final key = row['avatarKey'] as String; + await tester.runAsync(() async { + expect(await request('outdated-avatar'), isNull); + expect(await request(key, requestSession: 'old-session'), isNull); + final bytes = await request(key) as Uint8List; + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final pixels = (await frame.image.toByteData())!; + final center = (36 * frame.image.width + 36) * 4; + expect( + [for (var i = 0; i < 4; i++) pixels.getUint8(center + i)], + [0, 0, 255, 255], + ); + frame.image.dispose(); + codec.dispose(); + expect(await request(key), orderedEquals(bytes)); + }); + await _event(tester, session, 'toggle', {'pubkey': _bob}); + expect( + ((states.last['selected'] as List).single as Map)['avatarKey'], + key, + ); + await _event(tester, session, 'query', {'value': 'bob'}); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect( + ((states.last['users'] as List).single as Map)['avatarKey'], + key, + ); + await _event(tester, session, 'closed'); + } finally { + await tester.pumpWidget(const SizedBox()); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_bridge, null); + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets('iOS dismiss sends no member writes', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + String? session; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_bridge, (call) async { + session = (call.arguments as Map)['session'] as String; + return null; + }); + try { + final actions = _Actions(); + await _pump(tester, actions); + await _event(tester, session!, 'toggle', {'pubkey': _alice}); + await _event(tester, session!, 'closed'); + expect(actions.calls, isEmpty); + expect(find.byType(AddChannelMembersSheet), findsNothing); + } finally { + await tester.pumpWidget(const SizedBox()); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_bridge, null); + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('Android keeps the existing sheet and search field', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await _pump(tester, _Actions()); + expect(find.text('Add members'), findsOneWidget); + expect( + find.byKey(const ValueKey('add-channel-members-search')), + findsOneWidget, + ); + expect(find.text('Alice'), findsOneWidget); + } finally { + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/mobile/test/features/channels/mentions/mention_search_community_test.dart b/mobile/test/features/channels/mentions/mention_search_community_test.dart index 172bab1f2c..d74d983674 100644 --- a/mobile/test/features/channels/mentions/mention_search_community_test.dart +++ b/mobile/test/features/channels/mentions/mention_search_community_test.dart @@ -88,9 +88,11 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late _HeldSearchSession session; late ProviderContainer container; + var memberChoices = []; setUp(() async { session = _HeldSearchSession(); + memberChoices = []; container = ProviderContainer( retry: (_, _) => null, overrides: [ @@ -100,7 +102,7 @@ void main() { channelsProvider.overrideWith(_Channels.new), channelMembersProvider( _channelId, - ).overrideWith((ref) async => const []), + ).overrideWith((ref) async => memberChoices), agentDirectoryProvider.overrideWith((ref) async => const []), agentOwnersProvider.overrideWith((ref) async => const {}), ], @@ -137,6 +139,27 @@ void main() { candidate.pubkey, ]; + test('production mention chooser distinguishes same-name agents', () async { + memberChoices = [ + for (final key in [_alice, _bob]) + ChannelMember( + pubkey: key, + displayName: 'Albert', + role: 'bot', + joinedAt: DateTime(2026), + ), + ]; + container.invalidate(channelMembersProvider(_channelId)); + await container.read(channelMembersProvider(_channelId).future); + await container.pump(); + final choices = container.read(mentionCandidatesProvider(_args)); + expect(choices.map((choice) => choice.pubkey).toSet(), {_alice, _bob}); + expect(choices.map((choice) => choice.pickerLabel).toSet(), hasLength(2)); + for (final choice in choices) { + expect(choice.pickerLabel, startsWith('Albert · ')); + } + }); + test('a late search result from community A is not shown in B', () async { await passTypingPause(); expect(session.searches, hasLength(1)); diff --git a/mobile/test/features/settings/settings_overlay_test.dart b/mobile/test/features/settings/settings_overlay_test.dart new file mode 100644 index 0000000000..7dbae46aa0 --- /dev/null +++ b/mobile/test/features/settings/settings_overlay_test.dart @@ -0,0 +1,269 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:buzz/features/home/home_page.dart'; +import 'package:buzz/features/profile/profile_avatar.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/settings_profile_header.dart'; +import 'package:buzz/features/profile/user_status_provider.dart'; +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + testWidgets('Settings overlays are visible above Home on ${platform.name}', ( + tester, + ) async { + if (Platform.environment.containsKey('SETTINGS_SCREENSHOTS')) { + await tester.runAsync(() async { + for (final font in { + 'Inter': 'assets/fonts/InterVariable.ttf', + 'BuzzTabler': 'assets/fonts/TablerIcons.ttf', + }.entries) { + await (FontLoader( + font.key, + )..addFont(rootBundle.load(font.value))).load(); + } + }); + } + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(top: 47, bottom: 34); + tester.view.viewPadding = FakeViewPadding(top: 47, bottom: 34); + addTearDown(tester.view.reset); + PackageInfo.setMockInitialValues( + appName: 'Buzz', + packageName: 'xyz.block.buzz', + version: '0.16.0', + buildNumber: '432', + buildSignature: '', + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final presence = _Presence(); + final nativeChannels = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + (call) async { + if (call.method == 'create') { + final args = call.arguments as Map; + final channel = MethodChannel('${args['viewType']}/${args['id']}'); + nativeChannels.add(channel); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => null, + ); + } + return null; + }, + ); + addTearDown(() { + for (final channel in [ + SystemChannels.platform_views, + ...nativeChannels, + ]) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + } + }); + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(_Config.new), + appLifecycleProvider.overrideWith(_Lifecycle.new), + profileProvider.overrideWith(_Profile.new), + presenceProvider.overrideWith(() => presence), + userStatusProvider.overrideWith(_Status.new), + customEmojiListProvider.overrideWithValue(const []), + ], + child: MaterialApp( + theme: AppTheme.light(), + builder: (_, child) => RepaintBoundary( + key: const ValueKey('settings-test-capture'), + child: child!, + ), + home: HomePage( + hasUnreadInbox: false, + settingsPageBuilder: (_) => SettingsPage( + profileHeader: const SettingsProfileHeader(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + if (platform == TargetPlatform.iOS) { + // Invoke the same Flutter callback used by the native bar item. + // Native actions are owned by FrostedAppBar and are independently tested. + // Use that production callback without synthesizing a platform view tap. + final appBar = tester + .widgetList(find.byType(FrostedAppBar)) + .first; + appBar.nativeActions!.single.onPressed!(); + } else { + await tester.tap(find.byType(ProfileAvatar)); + } + await tester.pumpAndSettle(); + if (platform == TargetPlatform.iOS) { + expect(find.byType(HomePage), findsNothing); + expect(find.byType(HomePage, skipOffstage: false), findsOneWidget); + final menu = tester.widget( + find.byKey(const ValueKey('settings-presence-menu')), + ); + expect(menu.viewType, 'buzz/presence_menu'); + expect(menu.creationParams, containsPair('presence', 'offline')); + final nativeMenu = nativeChannels.singleWhere( + (channel) => channel.name.startsWith('buzz/presence_menu/'), + ); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + nativeMenu.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('selected', 'online'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + } else { + await tester.tap(find.byKey(const ValueKey('settings-presence-menu'))); + await tester.pumpAndSettle(); + final online = find.byKey(const ValueKey('settings-presence-online')); + expect(online.hitTestable(), findsOneWidget); + final fade = tester.widget( + find.byKey(const ValueKey('activity-popover-fade')), + ); + expect(fade.opacity.value, 1); + final menu = tester.getRect( + find.byKey(const ValueKey('settings-presence-popover')), + ); + final anchor = tester.getRect( + find.byKey(const ValueKey('settings-presence-target')), + ); + expect(menu.center.dx, closeTo(anchor.center.dx, 0.1)); + expect(menu.top, closeTo(anchor.bottom + Grid.half, 0.1)); + expect(menu.bottom, lessThanOrEqualTo(810)); + await _capture(tester, '${platform.name}-availability'); + await tester.tapAt(const Offset(20, 700)); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('settings-presence-popover')), + findsNothing, + ); + expect(presence.selected, isEmpty); + await tester.tap(find.byKey(const ValueKey('settings-presence-menu'))); + await tester.pumpAndSettle(); + await tester.tap(online); + await tester.pumpAndSettle(); + } + expect(presence.selected, ['online']); + await tester.tap(find.text('Copy public key (npub)')); + await tester.pumpAndSettle(); + expect(copied, startsWith('npub1')); + final snackbar = find.byType(SnackBar); + expect(snackbar, findsOneWidget); + final settings = find.byType(SettingsPage); + expect(find.descendant(of: settings, matching: snackbar), findsOneWidget); + final message = find.text('Public key (npub) copied'); + expect(message.hitTestable(), findsOneWidget); + final toastSurface = find + .ancestor(of: message, matching: find.byType(Material)) + .first; + expect(tester.getRect(toastSurface).bottom, lessThanOrEqualTo(810)); + await _capture(tester, '${platform.name}-copied'); + Navigator.of(tester.element(settings)).pop(); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + } +} + +class _Presence extends PresenceNotifier { + final selected = []; + @override + Future build() async => 'offline'; + @override + Future setPresence(String status) async { + selected.add(status); + state = AsyncData(status); + } +} + +class _Profile extends ProfileNotifier { + @override + Future build() async => + UserProfile(pubkey: 'aabb', displayName: 'Test'); +} + +class _Status extends UserStatusNotifier { + @override + Future build() async => null; +} + +class _Config extends RelayConfigNotifier { + @override + RelayConfig build() => RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Keys( + '1111111111111111111111111111111111111111111111111111111111111111', + ).nsec, + ); +} + +Future _capture(WidgetTester tester, String name) async { + final directory = Platform.environment['SETTINGS_SCREENSHOTS']; + if (directory == null) return; + final boundary = tester.renderObject( + find.byKey(const ValueKey('settings-test-capture')), + ); + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(directory).create(recursive: true); + await File( + '$directory/$name.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); +} + +class _Lifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 99db70f625..d587159e3d 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -75,10 +75,20 @@ void main() { ); expect(find.text('Invite to community'), findsNothing); await tester.pumpAndSettle(); + final version = find.text( + buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)', + ); + await tester.scrollUntilVisible(version, 200); + await tester.pumpAndSettle(); + expect(version.hitTestable(), findsOneWidget); expect( - find.text(buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)'), + find.ancestor(of: version, matching: find.byType(ListView)), findsOneWidget, ); + final beforeScroll = tester.getTopLeft(version).dy; + await tester.drag(find.byType(ListView), const Offset(0, 100)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(version).dy, greaterThan(beforeScroll)); expect(tester.takeException(), isNull); }); } diff --git a/mobile/test/shared/identity_names/identity_names_test.dart b/mobile/test/shared/identity_names/identity_names_test.dart index dd52f09b93..35e493eb8d 100644 --- a/mobile/test/shared/identity_names/identity_names_test.dart +++ b/mobile/test/shared/identity_names/identity_names_test.dart @@ -35,6 +35,69 @@ IdentityNameSources _sources() => IdentityNameSources( ); void main() { + for (final source in ['directory', 'channel role', 'profile owner']) { + test('agent suffixes from $source distinguish all identity labels', () { + final keys = [_mine, _wesAgent]; + final names = IdentityNameSources( + agentPubkeys: source == 'directory' ? keys.toSet() : {}, + profiles: { + for (final key in keys) + key: UserProfile( + pubkey: key, + displayName: 'Scout', + ownerPubkey: source == 'profile owner' ? _wes : null, + ), + }, + ).scope(keys, agentPubkeys: source == 'channel role' ? keys.toSet() : {}); + for (final key in keys) { + expect(names.labelFor(key.toUpperCase()), names.resolve(key)!.name); + final resolved = names.resolve(key)!; + expect(resolved.qualifier, isNotNull); + expect(names.labelFor(key), 'Scout · ${resolved.qualifier}'); + } + expect(names.resolve(_mine)!.name, isNot(names.resolve(_wesAgent)!.name)); + }); + } + + test('agent display keeps readable owners and literal name punctuation', () { + final names = IdentityNameSources( + viewer: _logan, + profiles: { + _wes: UserProfile(pubkey: _wes, displayName: 'Wes'), + for (final key in [_mine, _wesAgent]) + key: UserProfile( + pubkey: key, + displayName: 'Scout · 1234', + ownerPubkey: _wes, + ), + }, + ).scope([_mine, _wesAgent]); + for (final key in [_mine, _wesAgent]) { + expect(names.labelFor(key), startsWith('Wes’s Scout · 1234 · ')); + expect(names.resolve(key)!.qualifier, isNotNull); + } + expect( + IdentityNameSources( + agentDisplayNames: {_mine: 'Scout · 1234'}, + agentPubkeys: {_mine}, + ).scope([_mine]).labelFor(_mine), + 'Scout · 1234', + ); + }); + + test('human identity suffixes remain visible', () { + final names = IdentityNameSources( + profiles: { + for (final key in [_mine, _wesAgent]) + key: UserProfile(pubkey: key, displayName: 'Scout'), + }, + ).scope([_mine, _wesAgent]); + for (final key in [_mine, _wesAgent]) { + expect(names.resolve(key)!.qualifier, isNotNull); + expect(names.labelFor(key), names.resolve(key)!.name); + } + }); + test('channel context: human, then mine, then owner-qualified others', () { // Wes is not a member: his profile is only an owner lookup fact. final names = _sources().scope([_logan, _human, _mine, _wesAgent]); @@ -114,8 +177,7 @@ void main() { ]; final names = mentionPickerNames(_sources(), candidates); final labeled = [ - for (final c in candidates) - c.withContextLabel(names.resolve(c.pubkey)?.name), + for (final c in candidates) c.withContextLabel(names.labelFor(c.pubkey)), ]; // A query that matches only the agent still shows its contextual label. final ranked = rankMentionCandidates(labeled, 'hon'); @@ -154,6 +216,7 @@ void main() { expect(candidates.map((c) => c.pubkey), [scout, myScout]); final names = mentionPickerNames(sources, candidates); expect(names.labelFor(myScout), 'Scout'); + expect(names.labelFor(scout), isNot(names.labelFor(myScout))); expect(names.labelFor(scout), isNot('Scout')); // The owner is the viewer, whose profile is cached: nothing to load. expect(names.missingOwnerProfiles(), isEmpty); diff --git a/scripts/test-mobile-member-picker.sh b/scripts/test-mobile-member-picker.sh new file mode 100644 index 0000000000..74b13b4abc --- /dev/null +++ b/scripts/test-mobile-member-picker.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Exercise the production UIKit member picker on a booted iOS simulator. +set -euo pipefail +repo_root=$(cd "$(dirname "$0")/.." && pwd) +device=${1:?Pass a booted iOS simulator UDID} +flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig") +frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator" +fixture=$(mktemp -d) +trap 'rm -r "$fixture"' EXIT +xcrun --sdk iphonesimulator swiftc \ + -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ + -target "$(uname -m)-apple-ios16.0-simulator" \ + -F "$frameworks" -framework Flutter \ + -Xlinker -rpath -Xlinker "$frameworks" \ + "$repo_root/mobile/ios/Runner/NativeAddMembersSheet.swift" \ + "$repo_root/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift" \ + -o "$fixture/member-picker-tests" +xcrun simctl spawn "$device" "$fixture/member-picker-tests"