Files
buzz/mobile/ios/Runner/NativeProfileTextEditor.swift
klopez4212 b1d60f98e2 Bring synced Solo chats and projects to mobile (#8337)
## Summary
Bring desktop Solo chats and project organization to mobile, with synced
collapsible sections, shared unread highlights and tab badges, agent
selection, shared search, native iOS creation sheets, and inline typing
bubbles that transition into replies.

Resolve Solo placement for every chat entry route, retain encrypted
organization snapshots for offline reading, and let users resume or
discard unfinished chat and project setup without deleting existing
messages.

### Related issue
Related: #6565; expandable agent transcripts remain separate work.

### Testing
- Fresh Solo read state seeds each active chat from its captured initial
last-message timestamp before exposing row/section/tab unread state.
Baselines survive intervening channel updates and reset across
identity/community boundaries while surviving read-state readiness
changes. Regressions cover multiple chats, events before read-state
readiness and during/after seeding, and identity changes; removing
either the seed or the display gate fails them.
- Chat completion reconciles a retained Solo-save journal even when
placement is already confirmed. False/throwing cleanup regressions
ensure the outer intent remains until reconciliation and only one
placement is published. Typing terminal-close loops remain bounded; the
retry budget resets after an uninterrupted eight-second health interval
or transport reconnect.
- Typing retry budgets reset after sustained recovery; invalid Solo
updates retain cached rows with a retry warning that survives failed
refreshes. Project names use the editors’ 120-grapheme limit, including
emoji and combining characters. Regression coverage exercises repeated
recovery, invalid live/refresh heads, and character-limit boundaries.
- Rebased onto current main, preserving the newer Search controls,
Huddle timeline filtering, and message-media layout.
- Failed project journal writes reload storage before claiming recovery
and reopen the entered name for retry. Typing subscription rejections
are caught; terminal closures clear stale indicators and retry with a
bounded backoff, canceled on disposal. Regression tests cover both
false/throwing storage writes and initial/late subscription rejection.
- Explicit ordinary destinations skip optional Solo lookup waits.
Generic destinations perform one bounded lookup in the destination
(search no longer duplicates it); an unavailable classification falls
back to ordinary sending per the product owner's explicit choice.
Positively identified Solo destinations use Solo providers.
- Terminal Solo subscription closure preserves cached rows and exposes
live-update Retry. Page coverage verifies warning visibility, usable
rows during recovery, and a subsequent terminal close. Removing the
warning or the immediate ordinary-route guard fails its regression.
- Generic channel entries share an ordinary default when Solo
classification is unavailable, per the requested product behavior. Known
Solo placement takes precedence; callers can explicitly require
unresolved placement to remain blocked. Bounded initial lookup preserves
known Solo deep-link routing.
- Dirty newest snapshots and offline-save warnings survive transport
reconnects; Retry persists that same event even if the relay returns an
older head. Community changes replace the service, and agent-directory
reconnect refresh remains intact.
- Pending-save discard reloads failed removals. Repeated reconnect
testing confirms typing subscriptions are released on provider rebuild
and disposal; removing the existing cleanup fails the regression.
- Ordinary Home/create routes explicitly preserve private-channel
sending during Solo failures; identified Solo sends use the Solo sender
and explicit unresolved routes remain fail-closed. Solo unread replies
beyond the initial 200-event page are loaded through the oldest-unread
boundary.
- Chat/project/agent cleanup reloads failed removals, retains
same-process recovery, and protects replaced records. False/throw
regressions verify retries do not duplicate creation or membership.
Route, unread, and cleanup guard-removal mutations each fail their
regression.
- Ordinary channels, DMs, and forums remain sendable when optional Solo
sync fails; explicit unresolved placement stays blocked. Controlled
cache races retain the newest verified snapshot, keep usable chats
visible, and retry offline persistence without stale writes. Both
protection-removal regressions fail as expected.
- Confirmed journal cleanup checks false/throwing removal and
exact-record identity. Interrupted agent choices are visible
immediately, identify the saved agent, and cover Resume/Later/Discard
with accessible name/key-fallback controls.
- Notification deep links preserve the contiguous history cursor;
same-second messages infer the newest agent consistently. Failed
offline-cache writes surface Retry, with false/throwing storage failures
and offline restart covered.
- Composite history cursors cover 350 same-second events plus older
history; removing the ID cursor makes the regression fail. Agent changes
now journal before granting access, with restart/failure and
Resume/Later/Discard widget coverage. Signed malformed profiles remain
isolated.
- Native iOS avatar tests passed on the simulator: visible-row loading,
three-request concurrency, streamed byte limits, response validation,
downsampling/pixel limits, cancellation, and visible avatar updates.
- Solo and Home share observed-event/read-marker logic. Widget coverage
verifies unread rows, collapsed-section indicators, the tab badge before
first Solo visit, message/thread reads, forced unread, and filtering
archived/nonmember/unplaced chats.
- Chat intent is saved before ownership and organization preflight;
tests cover interrupted ownership lookup and a disconnect while the
native creation form is open.
- Mobile analysis, formatting, file-size checks, and full mobile tests
passed at `1d4c41354`.
- Project creation persists its encrypted name/UUID before connectivity
checks or relay reads. Restart tests cover Resume/Later/Discard, scope
isolation, stale-intent rejection, and lost acknowledgements. Removing
the pre-network save makes all four production-flow regressions fail.
- Regression coverage includes notification/Activity routing, inline
replies, unknown-placement send protection, restart recovery/discard,
and encrypted offline cache isolation/refresh.
- Earlier feature build exercised on a physical iPhone with existing
account/community data; review follow-ups have not been reinstalled on
the device. UIKit agent-row accessibility checks passed on an iOS
simulator.
- Earlier cross-platform `just ci` stopped on the existing `buzz-acp`
test `signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(process exit-code assertion); no Rust production changes are included
in this PR.

### Remaining review limitation
Agent discovery is bounded to 10 pages / 1,000 profiles with automatic
retries disabled. Owner-scoped discovery remains open because the
current relay ignores `#auth` filters; supporting that query requires a
relay change.

### Snapshots
Sample data, shared Flutter/Android surface. Native iOS uses platform
sheets and controls.

| Solo: projects and chats | New chat | Choose an agent |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png"
width="240" alt="Solo with collapsible projects and chats" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png"
width="240" alt="New chat form" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png"
width="240" alt="Agent selection list" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:07:23 -04:00

745 lines
28 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 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.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])
} 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 == 1 {
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: 1), 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 ? 2 : isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 }
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
guard isChatEditor && section == 1 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 == 1 && 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()
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)
}
}