Files
buzz/mobile/ios/Runner/NativeAddMembersSheet.swift
Tom Brow 60b7fb8178 fix(mobile): keep shortcut taps and the member picker from wedging (#9388)
Follow-up to review notes on [#9360 Home Screen
shortcuts](https://github.com/block/buzz/pull/9360). These are fixes for
rare races, with no visible change.

- **The member picker can't wedge.** UIKit ignores a dismiss that
arrives mid-transition (the sheet still animating in, or during an
interactive swipe-down) and never runs its completion. The picker
coordinator queues every later present behind that completion, so New DM
and Add members then opened nothing for the rest of the session. Dismiss
now waits for the transition to finish, then dismisses whatever is still
presented.
- **A shortcut tap reaches Dart once.** The native bridge both buffered
a tap and sent it, so a tap during cold launch could arrive twice and
run again after Home had already handled it. Now a tap is buffered only
until Dart first takes the pending one, and after that it is sent
directly.
- **Signing out drops a waiting tap.** Removing Home clears a tap that
is still waiting (for example on a Bestie that is still loading), so it
can't run in the next account's Home.

Not included: carrying the Bestie's pubkey in the shortcut item, so a
stale "Message Alice" falls back to Agents rather than opening the
current Bestie. It's optional, and it would change the bridge's payload
shared with the lock screen widgets in [#9373 lock screen
widgets](https://github.com/block/buzz/pull/9373).

### Validation

- The Home shortcut journey gains a step: a tap waiting while the app is
inactive is cleared when Home goes away. The step fails with the
teardown guard removed.
- Automated simulator run (iPhone 17 Pro, iOS 27, signed in to a
Builderlab test community), driving the app icon's quick actions on the
Home Screen:
- New DM from a conversation opens the picker. New DM again, with the
picker already open, replaces it. New chat over the picker closes it and
opens a draft, and New DM after that opens the picker again (it doesn't
get stuck).
  - Message (Bestie) opens the Bestie conversation.
- Cold launch through New chat opens exactly one New chat screen. Back
returns to Messages.

buzz-review-completed

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:09:31 -08:00

474 lines
22 KiB
Swift

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?
/// Presents that arrived while the previous picker was still animating
/// closed. They run once it is gone, so a replacement picker can open.
private var afterClose: [() -> Void]?
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":
if afterClose != nil {
afterClose?.append { [weak self] in self?.handle(call, result: result) }
return
}
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) } }
else { result(nil) }
default:
result(FlutterMethodNotImplemented)
}
}
private func close(notify: Bool, completion: (() -> Void)? = nil) {
guard let request = session, let navigation else { completion?(); return }
session = nil
self.navigation = nil
picker = nil
afterClose = afterClose ?? []
Self.dismiss(navigation) { [weak self] in
if notify { self?.channel.invokeMethod("closed", arguments: ["session": request]) }
completion?()
let queued = self?.afterClose ?? []
self?.afterClose = nil
queued.forEach { $0() }
}
}
/// UIKit ignores a dismiss that arrives mid-transition (the sheet still
/// animating in, or an interactive swipe-down) and never runs its
/// completion, which would hold every later present forever. Waits for the
/// transition to end, then dismisses whatever is still presented.
private static func dismiss(_ navigation: UINavigationController, completion: @escaping () -> Void) {
if let transition = navigation.transitionCoordinator {
// The coordinator is still attached while its completion runs.
transition.animate(alongsideTransition: nil) { _ in
DispatchQueue.main.async { dismiss(navigation, completion: completion) }
}
return
}
guard navigation.presentingViewController != nil else {
completion()
return
}
navigation.dismiss(animated: !UIAccessibility.isReduceMotionEnabled, completion: completion)
}
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
/// Loaded avatars by key. Every state update rebuilds the cells, so a row
/// draws its known avatar at once instead of blinking to its initial while
/// the image comes back over the channel. Bounded like the Dart-side
/// cache, so browsing a large community doesn't grow it without limit.
private let avatars: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 64
return cache
}()
private let search = UISearchController(searchResultsController: nil)
private let pageColor: UIColor
private let rowColor: UIColor
/// The app theme's error color, which keeps 4.5:1 contrast on the sheet
/// where `.systemRed` does not.
private static let errorColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0xFF / 255, green: 0x95 / 255, blue: 0x92 / 255, alpha: 1)
: UIColor(red: 0xCE / 255, green: 0x2C / 255, blue: 0x31 / 255, alpha: 1)
}
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 checkmarkAction: Bool { state["confirmWithCheckmark"] as? Bool == true }
private var atLimit: Bool { selected.count >= (state["selectionLimit"] as? Int ?? Int.max) }
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 = state["title"] as? String ?? "Add members"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.backgroundColor = pageColor
tableView.sectionHeaderTopPadding = 0
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))
if checkmarkAction {
navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .plain, target: self, action: #selector(cancel))
navigationItem.leftBarButtonItem?.accessibilityLabel = "Close"
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "checkmark"), style: .done, target: self, action: #selector(add))
navigationItem.rightBarButtonItem?.accessibilityLabel = "Create message"
}
NavigationTopEdge.configure(tableView, item: navigationItem)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: Self.cellIdentifier)
rows = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellIdentifier, for: indexPath)
self?.configure(cell, item: item)
return cell
}
rows.defaultRowAnimation = .fade
applyState()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Activating in the same pass as the appearance is ignored, so focus
// the field on the next turn of the run loop.
DispatchQueue.main.async { [weak self] in
guard let self, self.view.window != nil else { return }
self.search.isActive = true
self.search.searchBar.searchTextField.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 }
// Flutter sends the whole state on every rebuild, often unchanged.
guard !NSDictionary(dictionary: data).isEqual(to: state) else { return }
state = data
if isViewLoaded { applyState() }
}
private func applyState() {
navigationController?.isModalInPresentation = busy
navigationItem.leftBarButtonItem?.isEnabled = !busy
navigationItem.rightBarButtonItem?.isEnabled = !busy && !selected.isEmpty
if checkmarkAction {
navigationItem.rightBarButtonItem?.accessibilityLabel = busy ? "Creating message" : "Create message"
} else {
navigationItem.rightBarButtonItem?.title = busy ? "Adding…" : selected.isEmpty ? "Add" : "Add (\(selected.count))"
}
search.searchBar.isUserInteractionEnabled = !busy
applySnapshot()
let error = state["error"] as? String ?? ""
if error.isEmpty {
tableView.tableFooterView = nil
} else {
let label = UILabel()
label.text = error
label.textColor = Self.errorColor
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
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", [:])
}
/// The status row after the results: the selection limit, a failed
/// search, or why there are no results. Results stay shown while a search
/// refreshes them.
private var status: String? {
if atLimit { return "DMs support up to nine people, including you." }
if loadError { return "Couldn't search people or agents. Tap to retry." }
if !users.isEmpty { return nil }
if loading { return "Searching…" }
if (state["query"] as? String ?? "").trimmingCharacters(in: .whitespaces).isEmpty,
let hint = state["emptyQueryHint"] as? String {
return hint
}
return "No matching people or agents."
}
private var shownUsers: [[String: Any]] { atLimit ? [] : users }
/// A row: a member by pubkey, or the status row after the results. A
/// member keeps its identity across sections, so a pick moves its row.
private enum Item: Hashable {
case member(String)
case status
}
private static let cellIdentifier = "member"
private var rows: UITableViewDiffableDataSource<Int, Item>!
/// What each shown row was last drawn from, to redraw only changed rows.
private var drawn: [Item: NSDictionary] = [:]
private func member(_ pubkey: String) -> [String: Any]? {
(selected + shownUsers).first { $0["pubkey"] as? String == pubkey }
}
private func drawing(_ item: Item) -> NSDictionary {
switch item {
case .member(let pubkey):
return ["user": member(pubkey) ?? [:], "busy": busy, "selected": selected.contains { $0["pubkey"] as? String == pubkey }]
case .status:
return ["text": status ?? "", "retry": loadError && !atLimit, "busy": busy]
}
}
/// Applies the state as a diff: unchanged rows keep their cells, changed
/// ones are redrawn in place, and a pick or removal animates its row
/// between the chosen and result sections.
private func applySnapshot() {
let chosen = selected.compactMap { $0["pubkey"] as? String }
var results: [Item] = shownUsers.compactMap { ($0["pubkey"] as? String).map(Item.member) }
if status != nil { results.append(.status) }
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
snapshot.appendSections([0, 1])
snapshot.appendItems(chosen.map(Item.member), toSection: 0)
snapshot.appendItems(results, toSection: 1)
let previous = rows.snapshot()
let wasChosen = previous.numberOfSections > 0 ? previous.itemIdentifiers(inSection: 0) : []
let next = Dictionary(uniqueKeysWithValues: snapshot.itemIdentifiers.map { ($0, drawing($0)) })
let changed = snapshot.itemIdentifiers.filter { item in
drawn[item].map { $0 != next[item] } ?? false
}
let shown = Set(previous.itemIdentifiers)
snapshot.reconfigureItems(changed.filter(shown.contains))
drawn = next
let animate = view.window != nil && wasChosen != chosen.map(Item.member)
&& !UIAccessibility.isReduceMotionEnabled
rows.apply(snapshot, animatingDifferences: animate)
}
// No section titles, and no grouped-table header bands: a small gap
// above the first shown card and between the two.
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
if section == 0 { return selected.isEmpty ? .leastNormalMagnitude : Self.cardGap }
return selected.isEmpty ? Self.cardGap : Self.cardGap * 2
}
override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
.leastNormalMagnitude
}
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { nil }
override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { nil }
private static let cardGap: CGFloat = 8
private func configure(_ cell: UITableViewCell, item: Item) {
cell.backgroundColor = rowColor
cell.accessoryType = .none
cell.selectionStyle = .default
cell.accessibilityTraits = .none
cell.isAccessibilityElement = false
cell.accessibilityLabel = nil
cell.accessibilityIdentifier = nil
cell.contentView.accessibilityElementsHidden = false
cell.isUserInteractionEnabled = !busy
var content = UIListContentConfiguration.subtitleCell()
guard case .member(let pubkey) = item else {
content.text = status
content.textProperties.color = loadError && !atLimit ? Self.errorColor : .secondaryLabel
content.textProperties.numberOfLines = 0
cell.selectionStyle = loadError && !atLimit ? .default : .none
if loadError && !atLimit { cell.accessibilityTraits.insert(.button) }
cell.contentConfiguration = content
return
}
guard let user = member(pubkey) else { preconditionFailure("A shown member row must be in the state") }
let chosen = selected.contains { $0["pubkey"] as? String == pubkey }
let key = user["avatarKey"] as? String
content.text = user["name"] as? String
content.secondaryText = user["detail"] as? String
content.textProperties.numberOfLines = 0
content.secondaryTextProperties.numberOfLines = 1
content.image = key.flatMap { avatars.object(forKey: $0 as NSString) } ?? fallbackAvatar(user)
content.imageProperties.maximumSize = CGSize(width: 40, height: 40)
content.imageProperties.reservedLayoutSize = CGSize(width: 40, height: 40)
cell.accessoryType = chosen ? .checkmark : .none
if user["unavailable"] as? Bool == true {
content.textProperties.color = .secondaryLabel
cell.selectionStyle = .none
cell.accessibilityTraits.insert(.notEnabled)
}
cell.accessibilityTraits.insert(.button)
if chosen { cell.accessibilityTraits.insert(.selected) }
cell.contentConfiguration = content
cell.isAccessibilityElement = true
cell.accessibilityLabel = [content.text, content.secondaryText]
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
cell.contentView.accessibilityElementsHidden = true
guard let key else { return }
cell.accessibilityIdentifier = key
guard avatars.object(forKey: key as NSString) == nil else { return }
loadAvatar(pubkey, key) { [weak self, weak cell] data in
guard let data, let image = UIImage(data: data) else { return }
let avatar = image.withRenderingMode(.alwaysOriginal)
self?.avatars.setObject(avatar, forKey: key as NSString)
guard let cell, cell.accessibilityIdentifier == key,
var configuration = cell.contentConfiguration as? UIListContentConfiguration else { return }
configuration.image = avatar
let label = cell.accessibilityLabel
cell.contentConfiguration = configuration
cell.accessibilityLabel = label
cell.contentView.accessibilityElementsHidden = true
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard !busy, let item = rows.itemIdentifier(for: indexPath) else { return }
guard case .member(let pubkey) = item else {
if loadError && !atLimit { send("retry", [:]) }
return
}
guard let user = member(pubkey) else { preconditionFailure("A shown member row must be in the state") }
if user["unavailable"] as? Bool == true { return }
send("toggle", ["pubkey": pubkey])
// Where asked (New DM), a pick ends its query, as an inserted mention
// does, so the next name starts fresh.
if indexPath.section == 1 && state["clearQueryOnPick"] as? Bool == true
&& !(search.searchBar.text ?? "").isEmpty {
search.searchBar.text = ""
state["query"] = ""
send("query", ["value": ""])
}
}
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()
let rect = CGRect(origin: .zero, size: size)
(user["agent"] as? Bool == true ? Self.agentSquircle(rect) : UIBezierPath(ovalIn: rect)).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)
}
/// Mirrors avatar_shape.dart and desktop's `ROUNDED_SQUIRCLE_PATH`
/// (desktop/src/shared/ui/AvatarClipPaths.tsx).
private static func agentSquircle(_ rect: CGRect) -> UIBezierPath {
func at(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
CGPoint(x: rect.minX + rect.width * x, y: rect.minY + rect.height * y)
}
let path = UIBezierPath()
path.move(to: at(0.5, 0))
path.addCurve(to: at(1, 0.5), controlPoint1: at(0.93, 0), controlPoint2: at(1, 0.07))
path.addCurve(to: at(0.5, 1), controlPoint1: at(1, 0.93), controlPoint2: at(0.93, 1))
path.addCurve(to: at(0, 0.5), controlPoint1: at(0.07, 1), controlPoint2: at(0, 0.93))
path.addCurve(to: at(0.5, 0), controlPoint1: at(0, 0.07), controlPoint2: at(0.07, 0))
path.close()
return path
}
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)
}
}