mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## 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>
817 lines
32 KiB
Swift
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)
|
|
}
|
|
}
|