fix(mobile): polish channel management and iOS Settings (#8171)

## Summary

Use native iOS sheets for channel editing and Add Members, with profile
avatars, matching search, and destructive removal dialogs. Canvas
content becomes a tappable preview with Add/Remove actions.

Fix the Settings availability menu and clipped copy confirmation. Align
channel action messages with chat styling and keep agent identities
distinguishable across selection, attribution, and profile surfaces.

### Related issue

No duplicate found in the existing mobile Settings/channel issues and
PRs.

### Testing

- Flutter analysis, formatting, file-size checks, focused collision
regressions, and the full mobile test suite passed.
- Production UIKit picker simulator check passed: distinct
visible/accessibility labels, avatar updates, selected rows, and pubkey
callbacks. Wired into Mobile Swift CI.
- Original UI was built and tested on the physical iPhone with author
confirmation; profile/community data preserved.
- Review fixes await renewed author device confirmation before restoring
`buzz-review-completed` and ready-for-review status.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-08 14:32:55 -04:00
committed by GitHub
parent 1ac0a1271b
commit 7e8aac4403
41 changed files with 3479 additions and 828 deletions

View File

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

View File

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

View File

@@ -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 = "<group>"; };
C0AF00000000000000000022 /* NativePresenceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePresenceMenu.swift; sourceTree = "<group>"; };
C0AF00000000000000000012 /* NativeAddMembersSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAddMembersSheet.swift; sourceTree = "<group>"; };
BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = "<group>"; };
@@ -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 */,

View File

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

View File

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

View File

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

View File

@@ -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 }
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 {
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([

View File

@@ -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<bool?> showAddChannelMembersSheet({
required BuildContext context,
required String channelId,
required Set<String> existingPubkeys,
}) async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
if (_nativePickerOpen) return null;
_nativePickerOpen = true;
try {
return await showGeneralDialog<bool>(
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<bool>(
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<String> existingPubkeys;
final bool nativePresentation;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -31,6 +82,7 @@ class AddChannelMembersSheet extends HookConsumerWidget {
final selectedUsers = useState<List<DirectoryUser>>([]);
final isSubmitting = useState(false);
final submitError = useState<String?>(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(
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<String, Object?> 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,

View File

@@ -216,25 +216,13 @@ class ChannelActionsSheet extends HookConsumerWidget {
leading: const Icon(BuzzIcons.settings),
title: const Text('Manage channel'),
onTap: () async {
final shouldClose =
await showBuzzModalBottomSheet<bool>(
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: currentChannel,
canEditDetails:
canManageLifecycle &&
!currentChannel.isArchived,
canManageLifecycle && !currentChannel.isArchived,
onChannelUpdated: (updated) =>
displayedChannel.value = updated,
),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop(true);

View File

@@ -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';

View File

@@ -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 => <InlineSpan>[
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: [

View File

@@ -212,41 +212,21 @@ class ChannelDetailsPage extends HookConsumerWidget {
);
Future<void> openAddMembers() async {
final mediaQuery = MediaQuery.of(context);
await showBuzzModalBottomSheet<bool>(
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(),
},
),
);
}
Future<void> openManageChannel() async {
final shouldClose = await showBuzzModalBottomSheet<bool>(
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,
),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop(true);

View File

@@ -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<bool>(
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

View File

@@ -585,11 +585,14 @@ class _SettingsPageRoute extends PageRouteBuilder<void> {
_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),

View File

@@ -40,7 +40,7 @@ class _CommunitySwitcherRoute extends _SettingsPageRoute {
_CommunitySwitcherRoute({
required super.builder,
required super.onTransitionProgress,
});
}) : super(opaque: false);
bool flying = false;

View File

@@ -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<String, Object?> state;
final Future<Uint8List?> Function(String pubkey, String avatarKey) onAvatar;
final ValueChanged<String> onQuery;
final ValueChanged<String> onToggle;
final VoidCallback onSubmit;
final VoidCallback onRetry;
final VoidCallback onClose;
@override
State<IosAddMembersSheet> createState() => _IosAddMembersSheetState();
}
class _IosAddMembersSheetState extends State<IosAddMembersSheet> {
static const _channel = MethodChannel('buzz/add_members_sheet');
final _session = UniqueKey().toString();
bool _presented = false;
bool _closed = false;
final _avatars = <String, Uint8List>{};
final _pendingAvatars = <String, Future<Uint8List?>>{};
final _avatarQueue =
Queue<
({
String key,
Future<Uint8List?> Function() load,
Completer<Uint8List?> result,
})
>();
int _activeAvatars = 0;
Future<Uint8List?> _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<Uint8List?>();
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<Object?, Object?>.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<String, Object?> get _state => {'session': _session, ...widget.state};
Future<void> _present() async {
if (!mounted) return;
final theme = utilitySurfaceThemeData(Theme.of(context));
try {
await _channel.invokeMethod<void>('present', {
..._state,
'dark': theme.brightness == Brightness.dark,
'pageColor': theme.colorScheme.surface.toARGB32(),
'rowColor': theme.colorScheme.surfaceContainerHighest.toARGB32(),
});
_presented = true;
if (!mounted) {
await _channel.invokeMethod<void>('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<void> _update() async {
if (!_presented || !mounted || _closed) return;
try {
await _channel.invokeMethod<void>('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<void>('dismiss', {'session': _session}).catchError((
Object _,
) {
// The engine may already have disposed its native presentation.
}),
);
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}

View File

@@ -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<bool?> showManageChannelSheet({
required BuildContext context,
required Channel channel,
required bool canEditDetails,
ValueChanged<Channel>? 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<String, dynamic>? result;
try {
result = await bridge
.invokeMapMethod<String, dynamic>('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<bool>(
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<Channel> _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<String?> _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<Channel>? 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<String?>(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<String?>(null);
final canvas = ref.watch(channelCanvasProvider(channel.id));
final savedCanvas = useState<String?>(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<void> saveDetails() async {
if (!canSaveDetails) return;
isSavingDetails.value = true;
actionError.value = null;
Future<void> 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<void> saveCanvas() async {
if (isSavingCanvas.value) return;
isSavingCanvas.value = true;
actionError.value = null;
Future<void> editCanvas() async {
if (busy) return;
openingCanvas.value = true;
error.value = null;
try {
await ref
.read(channelActionsProvider)
.setCanvas(
channelId: channel.id,
content: canvasController.text.trim(),
final saved = await _editCanvas(
context,
ProviderScope.containerOf(context, listen: false),
actions,
channel.id,
initialContent: canvasLoaded ? canvasContent : null,
);
if (context.mounted) isEditingCanvas.value = false;
} catch (error) {
actionError.value = error.toString();
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<void> removeCanvas() async {
if (busy || !canEditCanvas || !hasCanvas) return;
final confirmed = await showDialog<bool>(
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,
),
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 (canonicalName.isEmpty) ...[
const SizedBox(height: Grid.half),
Text(
'Channel name is required.',
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(
child: 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,
),
),
],
],
),
),
);
}
}
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,
),
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.card),
borderRadius: BorderRadius.circular(Radii.container),
clipBehavior: Clip.antiAlias,
child: Padding(padding: const EdgeInsets.all(Grid.xs), child: child),
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,
),
),
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 || 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),
FilledButton(
key: const ValueKey('manage-channel-save-details'),
onPressed:
canEditDetails && changed && canonicalName.isNotEmpty && !busy
? save
: null,
child: Text(saving.value ? 'Saving…' : 'Save'),
),
],
),
);
}
}

View File

@@ -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<bool>(
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

View File

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

View File

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

View File

@@ -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),
),
],
),
);
}
}

View File

@@ -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';

View File

@@ -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<String> onSelected;
@override
State<IosPresenceMenu> createState() => _IosPresenceMenuState();
}
class _IosPresenceMenuState extends State<IosPresenceMenu> {
MethodChannel? _channel;
Map<String, Object> _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<void>('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<void>('update', _configuration(context)),
);
},
),
);
}
}

View File

@@ -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<String?> 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<String>('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<void> presentUntilSaved({
required String title,
required String initialValue,
required String placeholder,
required bool multiline,
required Brightness brightness,
required Color pageBackgroundColor,
required Color containerBackgroundColor,
required Future<void> 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;

View File

@@ -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<void> _showProfileTextEditor({
required String hintText,
required Future<void> 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<void>(
}) => showTextEditor(
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<void> Function(String value) onSave;
@override
Widget build(BuildContext context) {
final controller = useTextEditingController(text: initialValue);
useListenable(controller);
final isSaving = useState(false);
final error = useState<String?>(null);
final hasChanges = controller.text.trim() != initialValue.trim();
Future<void> 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'),
),
],
),
),
);
}
}
multiline: multiline,
keyPrefix: 'profile-field',
shouldRetryOnError: (error) => error is! ProfileCommunityChangedException,
);

View File

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

View File

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

View File

@@ -71,7 +71,9 @@ class SettingsPage extends HookConsumerWidget {
bottomHeight: Grid.xxs,
);
return FrostedScaffold(
// Keep feedback local even while Home is mounted during the transition.
return ScaffoldMessenger(
child: FrostedScaffold(
useUtilitySurfaceTheme: true,
appBar: FrostedAppBar(
nativeTitle: 'Settings',
@@ -110,10 +112,7 @@ class SettingsPage extends HookConsumerWidget {
bottomHeight: Grid.xxs,
bottom: const SizedBox.expand(),
),
body: Column(
children: [
Expanded(
child: ListView(
body: ListView(
padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs),
children: [
profileHeader,
@@ -127,9 +126,6 @@ class SettingsPage extends HookConsumerWidget {
_ConnectionSection(
identityRecoveryPageBuilder: identityRecoveryPageBuilder,
),
],
),
),
if (packageInfo.hasData)
_VersionFooter(
version: packageInfo.data!.version,
@@ -137,6 +133,7 @@ class SettingsPage extends HookConsumerWidget {
),
],
),
),
);
}
}

View File

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

View File

@@ -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<String?> 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<String>('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<void> presentUntilSaved({
required String title,
required String initialValue,
required String placeholder,
required bool multiline,
required Brightness brightness,
required Color pageBackgroundColor,
required Color containerBackgroundColor,
required Future<void> 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();
}
}
}
}

View File

@@ -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<void> showTextEditor({
required BuildContext context,
required String title,
required String initialValue,
required String hintText,
required Future<void> 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<void>(
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<void> 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<String?>(null);
final hasChanges = controller.text.trim() != initialValue.trim();
Future<void> 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'),
),
],
),
),
);
}
}

View File

@@ -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<List<ChannelMember>> Function() loadMembers,
required ChannelActions Function(Ref ref) createChannelActions,
String? canvasContent,
Future<ChannelCanvas>? pendingCanvas,
}) => ProviderScope(
overrides: [
currentPubkeyProvider.overrideWith((ref) => _currentPubkey),
@@ -72,8 +76,10 @@ Widget _modalApp({
const AsyncValue.data(<String, String>{}),
),
channelCanvasProvider(channel.id).overrideWith(
(ref) async => const ChannelCanvas(
content: null,
(ref) =>
pendingCanvas ??
ChannelCanvas(
content: canvasContent,
updatedAt: null,
authorPubkey: null,
),
@@ -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<TextField>(
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 = <Map<Object?, Object?>>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(bridge, (call) async {
presentations.add(
Map<Object?, Object?>.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<ChannelCanvas>();
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<Object?, Object?>.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<TextField>(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<TextField>(
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<Object?, Object?>.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<FilledButton>(editCanvas).onPressed, isNull);
});
@@ -466,15 +806,7 @@ void main() {
expect(
tester
.widget<FilledButton>(
find.byKey(const ValueKey('manage-channel-save-details')),
)
.onPressed,
isNull,
);
expect(
tester
.widget<FilledButton>(
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<void> 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<void> setCanvas({
required String channelId,
required String content,
}) async {
await onSetCanvas?.call(content);
}
String? unarchivedChannelId;
@override

View File

@@ -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<TextField>(
find.byKey(const ValueKey('manage-channel-name')),
);
final descriptionField = tester.widget<TextField>(
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,
);
});

View File

@@ -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: <String, dynamic>{'type': 'member_removed', 'target': 'bob'},
action: 'removed Bob from the channel',
),
(
payload: <String, dynamic>{'type': 'member_removed'},
action: 'removed Someone from the channel',
),
for (final field in ['topic', 'purpose']) ...[
(
payload: <String, dynamic>{
'type': '${field}_changed',
field: ' Release planning ',
},
action: 'changed the $field to "Release planning"',
),
for (final blank in [null, '', ' \n\t '])
(
payload: <String, dynamic>{'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<Text>(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<UserProfileSheet>(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<UserProfileSheet>(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<MessageMentionPill>(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<Text>(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<UserProfileSheet>(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<Text>(header).data, names.labelFor(first));
expect(
tester
.widget<Text>(
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<UserProfileSheet>(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);
});
}
});
}

View File

@@ -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<ProviderContainer> _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 = <String>[];
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<AlertDialog>(
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<bool>();
final calls = <MethodCall>[];
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);

View File

@@ -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('<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72"><rect width="72" height="72" fill="#0000ff"/></svg>')}';
const _bridge = MethodChannel('buzz/add_members_sheet');
class _Cache extends UserCacheNotifier {
@override
Map<String, UserProfile> build() => const {};
@override
Future<bool> preload(List<String> pubkeys) async => true;
}
class _Actions extends Fake implements ChannelActions {
final calls = <List<String>>[];
bool failBob = false;
@override
Future<void> addMembers({
required String channelId,
required List<String> pubkeys,
String role = 'member',
}) async {
calls.add(pubkeys);
if (failBob) throw AddMembersException({_bob: 'Try again'});
}
}
Future<void> _pump(
WidgetTester tester,
_Actions actions, {
List<DirectoryUser>? 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<void> _event(
WidgetTester tester,
String session,
String method, [
Map<String, Object?> values = const {},
]) async {
final response = Completer<void>();
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 = <Map<Object?, Object?>>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_bridge, (call) async {
if (call.method != 'dismiss') {
states.add(Map<Object?, Object?>.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<Text>(find.byType(Text))
.map((text) => text.data)
.whereType<String>()
.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 = <Map<Object?, Object?>>[];
final methods = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_bridge, (call) async {
methods.add(call.method);
if (call.method != 'dismiss') {
states.add(Map<Object?, Object?>.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 = <Map<Object?, Object?>>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_bridge, (call) async {
if (call.method != 'dismiss') {
states.add(Map<Object?, Object?>.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<Object?> request(String key, {String? requestSession}) async {
final response = Completer<Object?>();
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;
}
});
}

View File

@@ -88,9 +88,11 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _HeldSearchSession session;
late ProviderContainer container;
var memberChoices = <ChannelMember>[];
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));

View File

@@ -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 = <MethodChannel>[];
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<FrostedAppBar>(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<UiKitView>(
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<FadeTransition>(
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 = <String>[];
@override
Future<String> build() async => 'offline';
@override
Future<void> setPresence(String status) async {
selected.add(status);
state = AsyncData(status);
}
}
class _Profile extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _Status extends UserStatusNotifier {
@override
Future<UserStatus?> build() async => null;
}
class _Config extends RelayConfigNotifier {
@override
RelayConfig build() => RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Keys(
'1111111111111111111111111111111111111111111111111111111111111111',
).nsec,
);
}
Future<void> _capture(WidgetTester tester, String name) async {
final directory = Platform.environment['SETTINGS_SCREENSHOTS'];
if (directory == null) return;
final boundary = tester.renderObject<RenderRepaintBoundary>(
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;
}

View File

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

View File

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

View File

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