mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
New Solo chats can optionally choose a project in the Flutter form and native iOS sheet. Project appears above the agent picker with matching neutral text, and the choice survives retries and restarts. Validation: full Flutter suite passed; analysis and iOS builds passed. Installed in Pairing Preview on iPhone Air and visually approved, preserving saved preferences. Successful live chat creation remains unverified because no agents were available. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
780 lines
30 KiB
Swift
780 lines
30 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
|
|
) {
|
|
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 let 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]] ?? []
|
|
}
|
|
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()
|
|
}
|
|
|
|
private func updateNavigation() {
|
|
navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges
|
|
&& (!isChannelEditor || !canonicalChannelName.isEmpty)
|
|
&& (!isChatEditor || (
|
|
!currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
&& currentValue.count <= 120
|
|
&& (isProjectEditor || assistants.contains { $0["id"] == selectedAssistant })
|
|
))
|
|
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,
|
|
assistants.contains(where: { $0["id"] == selectedAssistant }),
|
|
!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) { [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 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 selected = assistants.first { $0["id"] == selectedAssistant }
|
|
content.text = selected?["name"] ?? "Choose an agent"
|
|
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)
|
|
}
|
|
}
|