mirror of
https://github.com/block/buzz.git
synced 2026-11-11 11:51:13 +00:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Signed-off-by: Tom Brow <tomb@squareup.com>
444 lines
20 KiB
Swift
444 lines
20 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?
|
|
|
|
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) } }
|
|
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
|
|
navigation.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in
|
|
if notify { self?.channel.invokeMethod("closed", arguments: ["session": request]) }
|
|
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)
|
|
}
|
|
}
|