Files
buzz/mobile/ios/Runner/NativeProfileTextEditor.swift
klopez4212 b14884980e Speed up mobile Solo chat creation and agent discovery (#8547)
## Summary

Solo compose now opens before agent discovery finishes, and chat
creation avoids full channel-list reloads while overlapping independent
reads. Durable retries, ownership checks, and relay confirmation remain
intact.

The agent picker uses the owner's saved inventory, excludes known
archived identities, verifies profiles off the UI thread, and reuses
avatar artwork.

### Related issue

No matching issue found. Related: #8546 adds project selection to the
same compose surface.

### Testing

- Full mobile suite: 3,755 passed, 19 skipped; four additional artifact
tests passed.
- Analysis, formatting, file-size checks, and iOS Release build passed.
- Regression tests fail when the old blocking paths are restored.
- Installed on a physical iPhone with existing profile/community
settings preserved. Owner tested the earlier creation/picker
improvements; final compose timing awaits device feedback. No timing
recording captured.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-27 15:09:22 +00:00

817 lines
32 KiB
Swift

import Flutter
import UIKit
final class NativeProfileTextEditorCoordinator: NSObject,
UIAdaptivePresentationControllerDelegate
{
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private weak var presentedController: UIViewController?
private var pendingResult: FlutterResult?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
channel = FlutterMethodChannel(
name: "buzz/profile_text_editor",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
}
private func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
if call.method == "updateChatAssistants" {
DispatchQueue.main.async { [weak self] in
if let arguments = call.arguments as? [String: Any],
let navigation = self?.presentedController as? UINavigationController,
let editor = navigation.viewControllers.first as? NativeProfileTextEditorViewController {
editor.updateChatAssistants(arguments)
}
result(nil)
}
return
}
guard call.method == "present" || call.method == "presentChannel" || call.method == "presentChat" || call.method == "presentProject" else {
result(FlutterMethodNotImplemented)
return
}
guard
let arguments = call.arguments as? [String: Any],
let title = arguments["title"] as? String,
let initialValue = arguments["initialValue"] as? String,
let placeholder = arguments["placeholder"] as? String,
let multiline = arguments["multiline"] as? Bool,
let brightness = arguments["brightness"] as? String,
let pageBackgroundArgb = arguments["pageBackgroundArgb"] as? NSNumber,
let containerBackgroundArgb = arguments["containerBackgroundArgb"] as? NSNumber,
let containerCornerRadius = arguments["containerCornerRadius"] as? NSNumber
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected profile text editor configuration.",
details: nil
)
)
return
}
let allowUnchangedSubmission =
arguments["allowUnchangedSubmission"] as? Bool ?? false
DispatchQueue.main.async { [weak self] in
self?.present(
title: title,
initialValue: initialValue,
placeholder: placeholder,
multiline: multiline,
brightness: brightness,
pageBackgroundColor: UIColor(argb: pageBackgroundArgb.uint32Value),
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,
chatOptions: (call.method == "presentChat" || call.method == "presentProject")
? arguments.merging(["projectOnly": call.method == "presentProject"]) { _, new in new } : nil,
result: result
)
}
}
@MainActor
private func present(
title: String,
initialValue: String,
placeholder: String,
multiline: Bool,
brightness: String,
pageBackgroundColor: UIColor,
containerBackgroundColor: UIColor,
containerCornerRadius: CGFloat,
allowUnchangedSubmission: Bool,
channelDescription: String?,
originalName: String?,
originalDescription: String?,
canEditDetails: Bool,
canEditCanvas: Bool,
canvasContent: String,
canvasLoaded: Bool,
chatOptions: [String: Any]?,
result: @escaping FlutterResult
) {
guard presentedController == nil else {
result(
FlutterError(
code: "already_presented",
message: "A profile editor is already open.",
details: nil
)
)
return
}
guard
let presenter = topViewController(
from: parentViewController ?? activeWindowRootViewController()
)
else {
result(
FlutterError(
code: "presentation_failed",
message: "Unable to find a view controller for the profile editor.",
details: nil
)
)
return
}
let editor = NativeProfileTextEditorViewController(
title: title,
initialValue: initialValue,
placeholder: placeholder,
multiline: multiline,
pageBackgroundColor: pageBackgroundColor,
containerBackgroundColor: containerBackgroundColor,
containerCornerRadius: containerCornerRadius,
allowUnchangedSubmission: allowUnchangedSubmission,
channelDescription: channelDescription,
originalName: originalName,
originalDescription: originalDescription,
canEditDetails: canEditDetails,
canEditCanvas: canEditCanvas,
canvasContent: canvasContent,
canvasLoaded: canvasLoaded,
chatOptions: chatOptions,
onCancel: { [weak self] in self?.finish(value: nil) },
onSet: { [weak self] value in self?.finish(value: value) }
)
let navigationController = UINavigationController(rootViewController: editor)
navigationController.overrideUserInterfaceStyle = brightness == "dark"
? .dark
: .light
if UIDevice.current.userInterfaceIdiom == .pad {
navigationController.modalPresentationStyle = .formSheet
}
if channelDescription != nil {
navigationController.sheetPresentationController?.detents = [.large()]
}
pendingResult = result
presentedController = navigationController
presenter.present(navigationController, animated: true) { [weak self] in
navigationController.presentationController?.delegate = self
}
}
@MainActor
private func finish(value: Any?) {
guard let controller = presentedController else {
resolve(value: value)
return
}
controller.dismiss(animated: true) { [weak self] in
self?.resolve(value: value)
}
}
func presentationControllerDidDismiss(
_ presentationController: UIPresentationController
) {
resolve(value: nil)
}
@MainActor
private func resolve(value: Any?) {
let result = pendingResult
pendingResult = nil
presentedController = nil
result?(value)
}
@MainActor
private func activeWindowRootViewController() -> UIViewController? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.rootViewController
}
@MainActor
private func topViewController(
from viewController: UIViewController?
) -> UIViewController? {
if let navigationController = viewController as? UINavigationController {
return topViewController(from: navigationController.visibleViewController)
}
if let tabBarController = viewController as? UITabBarController {
return topViewController(from: tabBarController.selectedViewController)
}
if let presentedViewController = viewController?.presentedViewController {
return topViewController(from: presentedViewController)
}
return viewController
}
}
private final class NativeProfileTextEditorViewController:
UITableViewController,
UITextFieldDelegate,
UITextViewDelegate
{
private let initialValue: String
private let placeholder: String
private let multiline: Bool
private let pageBackgroundColor: UIColor
private let containerBackgroundColor: UIColor
private let containerCornerRadius: CGFloat
private let allowUnchangedSubmission: Bool
private let onCancel: () -> 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 chatOptions: [String: Any]?
private var hasFocusedEditor = false
private var selectedAssistantAvatar: UIImage?
private var selectedAssistant: String?
private var selectedProject: String?
private var projects: [[String: String]] {
chatOptions?["projects"] as? [[String: String]] ?? []
}
private var isChatEditor: Bool { chatOptions != nil }
private var isProjectEditor: Bool { chatOptions?["projectOnly"] as? Bool ?? false }
private var assistants: [[String: String]] {
chatOptions?["assistants"] as? [[String: String]] ?? []
}
// Keep decoded artwork while switching between the form and its agent list.
private let assistantAvatars = MeAvatarLoader()
private var fieldsLocked: Bool { chatOptions?["fieldsLocked"] as? Bool ?? false }
private var isChannelEditor: Bool { channelDescription != nil }
private lazy var textField: UITextField = {
let field = UITextField()
field.translatesAutoresizingMaskIntoConstraints = false
field.placeholder = placeholder
field.text = initialValue
field.font = .preferredFont(forTextStyle: .body)
field.adjustsFontForContentSizeCategory = true
field.clearButtonMode = .whileEditing
field.returnKeyType = isChannelEditor ? .next : .done
field.isEnabled = canEditDetails && !fieldsLocked
field.accessibilityLabel = placeholder
field.autocapitalizationType = .sentences
field.delegate = self
field.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
return field
}()
private lazy var textView: UITextView = {
let view = UITextView()
view.translatesAutoresizingMaskIntoConstraints = false
view.text = channelDescription ?? initialValue
view.isEditable = canEditDetails
view.accessibilityLabel = isChannelEditor ? "Description" : placeholder
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.backgroundColor = .clear
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
view.autocapitalizationType = .sentences
view.delegate = self
return view
}()
private lazy var placeholderLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = isChannelEditor ? "Description" : placeholder
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.textColor = .placeholderText
label.numberOfLines = 0
return label
}()
init(
title: String,
initialValue: String,
placeholder: String,
multiline: Bool,
pageBackgroundColor: UIColor,
containerBackgroundColor: UIColor,
containerCornerRadius: CGFloat,
allowUnchangedSubmission: Bool,
channelDescription: String?,
originalName: String?,
originalDescription: String?,
canEditDetails: Bool,
canEditCanvas: Bool,
canvasContent: String,
canvasLoaded: Bool,
chatOptions: [String: Any]?,
onCancel: @escaping () -> Void,
onSet: @escaping (Any) -> Void
) {
self.initialValue = initialValue
self.placeholder = placeholder
self.multiline = multiline
self.pageBackgroundColor = pageBackgroundColor
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.chatOptions = chatOptions
self.selectedAssistant = chatOptions?["selectedAssistant"] as? String
self.selectedProject = chatOptions?["selectedProject"] as? String
self.onCancel = onCancel
self.onSet = onSet
super.init(style: .insetGrouped)
self.title = title
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = pageBackgroundColor
tableView.backgroundColor = pageBackgroundColor
tableView.keyboardDismissMode = .interactive
tableView.alwaysBounceVertical = false
let navigationAppearance = UINavigationBarAppearance()
navigationAppearance.configureWithOpaqueBackground()
navigationAppearance.backgroundColor = pageBackgroundColor
navigationAppearance.shadowColor = UIColor.separator.withAlphaComponent(0.2)
let scrollEdgeAppearance = UINavigationBarAppearance()
scrollEdgeAppearance.configureWithOpaqueBackground()
scrollEdgeAppearance.backgroundColor = pageBackgroundColor
scrollEdgeAppearance.shadowColor = .clear
navigationItem.standardAppearance = navigationAppearance
navigationItem.scrollEdgeAppearance = scrollEdgeAppearance
navigationItem.compactAppearance = navigationAppearance
navigationItem.compactScrollEdgeAppearance = scrollEdgeAppearance
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel,
target: self,
action: #selector(cancelTapped)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: isChatEditor ? (fieldsLocked ? "Retry" : "Create") : (isChannelEditor ? "Save" : "Set"),
style: .done,
target: self,
action: #selector(setTapped)
)
updateNavigation()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard canEditDetails && !hasFocusedEditor else { return }
hasFocusedEditor = true
if multiline {
textView.becomeFirstResponder()
} else {
textField.becomeFirstResponder()
}
}
private var currentValue: String {
multiline ? textView.text : (textField.text ?? "")
}
private var hasUnsavedChanges: Bool {
if isChatEditor {
return !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| selectedAssistant != nil
}
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)
}
override var isModalInPresentation: Bool {
get { hasUnsavedChanges }
set {}
}
@objc private func textDidChange() {
updateNavigation()
}
func textViewDidChange(_ textView: UITextView) {
updateNavigation()
}
func updateChatAssistants(_ arguments: [String: Any]) {
guard let token = arguments["presentationId"] as? String,
token == chatOptions?["presentationId"] as? String else { return }
chatOptions?["assistants"] = arguments["assistants"]
chatOptions?["assistantsLoading"] = false
chatOptions?["assistantError"] = arguments["assistantError"]
tableView.reloadSections(IndexSet(integer: 2), with: .none)
updateNavigation()
}
// A durable retry keeps its saved recipient even if discovery hides it.
// Flutter revalidates ownership before any creation writes.
private var canSubmitAssistant: Bool {
guard let selectedAssistant else { return false }
return fieldsLocked || assistants.contains { $0["id"] == selectedAssistant }
}
private func updateNavigation() {
navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges
&& (!isChannelEditor || !canonicalChannelName.isEmpty)
&& (!isChatEditor || (
!currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& currentValue.count <= 120
&& (isProjectEditor || canSubmitAssistant)
))
navigationController?.isModalInPresentation = hasUnsavedChanges
placeholderLabel.isHidden = !textView.text.isEmpty
}
@objc private func cancelTapped() {
guard hasUnsavedChanges else {
onCancel()
return
}
let alert = UIAlertController(
title: "Discard changes?",
message: nil,
preferredStyle: .actionSheet
)
alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
alert.addAction(
UIAlertAction(title: "Discard", style: .destructive) { [weak self] _ in
self?.onCancel()
}
)
if let popover = alert.popoverPresentationController {
popover.barButtonItem = navigationItem.leftBarButtonItem
}
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 canEditDetails && hasUnsavedChanges else { return }
if isProjectEditor {
let name = currentValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, currentValue.count <= 120 else { return }
onSet(name)
} else if isChatEditor {
guard let selectedAssistant,
canSubmitAssistant,
!currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
currentValue.count <= 120 else { return }
onSet(["name": currentValue, "assistant": selectedAssistant, "project": selectedProject ?? ""])
} else 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)
if isChatEditor && indexPath.section == 2 {
guard !fieldsLocked && !assistants.isEmpty else { return }
view.endEditing(true)
let picker = MeAssistantListController(assistants: assistants, selected: selectedAssistant, pageColor: pageBackgroundColor, rowColor: containerBackgroundColor, avatars: assistantAvatars) { [weak self] id, avatar in
self?.selectedAssistantAvatar = avatar
self?.selectedAssistant = id
self?.tableView.reloadSections(IndexSet(integer: 2), with: .none)
self?.updateNavigation()
}
navigationController?.pushViewController(picker, animated: true)
return
}
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 { isProjectEditor ? 1 : isChatEditor ? 3 : isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 }
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
guard isChatEditor && section == 2 else { return nil }
if chatOptions?["assistantsLoading"] as? Bool == true { return "Loading agents…" }
if let error = chatOptions?["assistantError"] as? String { return error }
if let error = chatOptions?["error"] as? String { return error }
return assistants.isEmpty
? "No agents are available for this account. Connect an agent on desktop, then try again."
: nil
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int { 1 }
override func tableView(
_ tableView: UITableView,
heightForRowAt indexPath: IndexPath
) -> CGFloat {
if isChatEditor && indexPath.section == 2 && selectedAssistant != nil { return 64 }
if isChannelEditor && indexPath.section >= 2 { return UITableView.automaticDimension }
return (isChannelEditor ? indexPath.section == 1 : multiline) ? 132 : 52
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
cell.selectionStyle = .none
cell.backgroundColor = containerBackgroundColor
cell.layer.cornerRadius = containerCornerRadius
cell.layer.cornerCurve = .continuous
cell.clipsToBounds = true
if isChatEditor && indexPath.section == 1 {
var content = cell.defaultContentConfiguration()
content.text = "Project"
content.textProperties.font = .preferredFont(forTextStyle: .body)
cell.contentConfiguration = content
let selected = projects.first { $0["id"] == selectedProject }
let name = selected?["name"] ?? (selectedProject == nil ? "No project" : "Unavailable project")
let button = UIButton(type: .system)
button.setTitle(name, for: .normal)
button.setTitleColor(.label, for: .normal)
button.setTitleColor(.secondaryLabel, for: .disabled)
button.titleLabel?.font = .preferredFont(forTextStyle: .body)
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.accessibilityLabel = "Project"
button.accessibilityValue = name
button.isEnabled = !fieldsLocked
button.showsMenuAsPrimaryAction = true
let choices = [["id": "", "name": "No project"]] + projects
button.menu = UIMenu(children: choices.map { project in
let id = project["id"] ?? ""
return UIAction(title: project["name"] ?? "Project", state: id == (selectedProject ?? "") ? .on : .off) { [weak self] _ in
self?.view.endEditing(true)
self?.selectedProject = id.isEmpty ? nil : id
self?.tableView.reloadSections(IndexSet(integer: 1), with: .none)
}
})
button.titleLabel?.lineBreakMode = .byTruncatingTail
button.sizeToFit()
button.frame.size.width = min(button.frame.width, tableView.bounds.width * 0.55)
cell.accessoryView = button
} else if isChatEditor && indexPath.section == 2 {
var content = cell.defaultContentConfiguration()
let saved = chatOptions?["savedAssistant"] as? [String: String]
let fallback = fieldsLocked ? selectedAssistant.map {
saved.flatMap { $0["id"] == selectedAssistant ? $0 : nil } ?? ["id": $0, "name": $0]
} : nil
let selected = assistants.first { $0["id"] == selectedAssistant } ?? fallback
content.text = selected?["name"] ?? "Choose an agent"
content.textProperties.numberOfLines = 0
if let selected {
content.image = selectedAssistantAvatar ?? MeAssistantListController.avatarImage(selected, photo: nil)
}
content.textProperties.font = .preferredFont(forTextStyle: .body)
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = fieldsLocked || assistants.isEmpty ? .none : .default
cell.accessibilityTraits = fieldsLocked || assistants.isEmpty ? .notEnabled : .button
} else 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([
textView.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
textView.topAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.topAnchor),
textView.bottomAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.bottomAnchor),
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
placeholderLabel.trailingAnchor.constraint(equalTo: textView.trailingAnchor),
placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor),
])
placeholderLabel.isHidden = !textView.text.isEmpty
} else {
cell.contentView.addSubview(textField)
NSLayoutConstraint.activate([
textField.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
textField.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
textField.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
textField.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
])
}
return cell
}
}
private extension UIColor {
convenience init(argb: UInt32) {
self.init(
red: CGFloat((argb >> 16) & 0xff) / 255,
green: CGFloat((argb >> 8) & 0xff) / 255,
blue: CGFloat(argb & 0xff) / 255,
alpha: CGFloat((argb >> 24) & 0xff) / 255
)
}
}
/// A member-style list inside the New chat sheet, rather than an action menu.
private final class MeAssistantListController: UITableViewController {
private let assistants: [[String: String]]
private let selected: String?
private let onSelect: (String, UIImage) -> Void
private let pageColor: UIColor
private let rowColor: UIColor
private let avatars: MeAvatarLoader
private var requests: [IndexPath: UUID] = [:]
init(assistants: [[String: String]], selected: String?, pageColor: UIColor, rowColor: UIColor, avatars: MeAvatarLoader = MeAvatarLoader(), onSelect: @escaping (String, UIImage) -> Void) {
self.assistants = assistants
self.selected = selected
self.onSelect = onSelect
self.pageColor = pageColor
self.rowColor = rowColor
self.avatars = avatars
super.init(style: .insetGrouped)
title = "Agent"
}
required init?(coder: NSCoder) { nil }
deinit { avatars.cancelAll() }
override func viewDidLoad() {
super.viewDidLoad()
tableView.backgroundColor = pageColor
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 64
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.reloadData()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
avatars.cancelAll()
requests.removeAll()
}
private func avatarURL(_ assistant: [String: String]) -> URL? {
assistant["avatarUrl"].flatMap(URL.init(string:))
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let assistant = assistants[indexPath.row]
guard let url = avatarURL(assistant) else { return }
requests[indexPath] = avatars.load(url) { [weak self, weak cell] image in
guard self != nil, let cell, tableView.indexPath(for: cell) == indexPath else { return }
var content = cell.defaultContentConfiguration()
content.text = assistant["name"] ?? "Agent"
content.image = Self.avatarImage(assistant, photo: image)
cell.contentConfiguration = content
}
}
override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if let request = requests.removeValue(forKey: indexPath) { avatars.cancel(request) }
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
assistants.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let assistant = assistants[indexPath.row]
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
let name = assistant["name"] ?? "Agent"
content.text = name
content.image = Self.avatarImage(assistant, photo: avatars.image(for: avatarURL(assistant)))
cell.backgroundColor = rowColor
cell.contentConfiguration = content
cell.accessoryType = assistant["id"] == selected ? .checkmark : .none
cell.accessibilityTraits = assistant["id"] == selected ? [.button, .selected] : .button
return cell
}
static func avatarImage(_ assistant: [String: String], photo: UIImage?) -> UIImage {
let name = assistant["name"] ?? "Agent"
return UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { _ in
let bounds = CGRect(x: 0, y: 0, width: 40, height: 40)
UIBezierPath(roundedRect: bounds, cornerRadius: 12).addClip()
UIColor.secondarySystemFill.setFill()
UIRectFill(bounds)
if let photo {
let artworkScale = CGFloat(Double(assistant["artworkScale"] ?? "1") ?? 1)
let scale = max(40 / photo.size.width, 40 / photo.size.height) * artworkScale
let size = CGSize(width: photo.size.width * scale, height: photo.size.height * scale)
photo.draw(in: CGRect(x: (40 - size.width) / 2, y: (40 - size.height) / 2, width: size.width, height: size.height))
} else {
let initial = String(name.prefix(1)).uppercased() as NSString
let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 18, weight: .medium), .foregroundColor: UIColor.label]
let size = initial.size(withAttributes: attributes)
initial.draw(at: CGPoint(x: (40 - size.width) / 2, y: (40 - size.height) / 2), withAttributes: attributes)
}
}.withRenderingMode(.alwaysOriginal)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let id = assistants[indexPath.row]["id"] else { return }
onSelect(id, Self.avatarImage(assistants[indexPath.row], photo: avatars.image(for: avatarURL(assistants[indexPath.row]))))
navigationController?.popViewController(animated: true)
}
}