mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
869 lines
34 KiB
Swift
869 lines
34 KiB
Swift
import AVFAudio
|
||
import HapiClient
|
||
import HapiProtocol
|
||
import HapiUI
|
||
import PhotosUI
|
||
import SwiftUI
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
|
||
/// The chat input card (A-M3a, extended in A-M3f): attachments and the
|
||
/// multiline text field grow above a fixed bottom action row. The trailing
|
||
/// primary action is stateful: Stop while a turn runs with an empty draft,
|
||
/// otherwise Send (long-press offers "Send & steer" during a turn). The card
|
||
/// also hosts dictation and attachment controls, and a `/` command list
|
||
/// above the card while the input is a lone slash token.
|
||
struct ChatComposerView: View {
|
||
@Environment(\.hapiTypography) private var typography
|
||
let interactor: ChatInteractor
|
||
/// nil ⇒ dictation unavailable (no controller wired) — mic button hidden.
|
||
var dictation: DictationController?
|
||
/// Folded iPhone Duo: the short outer screen keeps more transcript by
|
||
/// letting the composer sit closer to the home indicator.
|
||
var hugsBottomEdge = false
|
||
var onOpenScratchlist: (ScratchlistEntry?, Bool) -> Void = { _, _ in }
|
||
|
||
// Attachment pickers (the launchers live here; policy + upload live in
|
||
// HapiKit — `AttachmentPreparer` / `ComposerAttachments`).
|
||
@State private var attachDialogOpen = false
|
||
@State private var photosPickerOpen = false
|
||
@State private var photoSelection: [PhotosPickerItem] = []
|
||
@State private var cameraOpen = false
|
||
@State private var filePickerOpen = false
|
||
@FocusState private var textFocused: Bool
|
||
@State private var preparingAttachment = false
|
||
@State private var schedulePickerOpen = false
|
||
|
||
private var scratchlistMode: Bool { interactor.composerDestination == .scratchlist }
|
||
private var composerPrompt: LocalizedStringKey {
|
||
scratchlistMode ? "Keep an instruction for later…" : "Message the agent…"
|
||
}
|
||
|
||
private var text: Binding<String> {
|
||
Binding(
|
||
get: { interactor.composerText },
|
||
set: { interactor.setComposerText($0) }
|
||
)
|
||
}
|
||
|
||
/// Elevated iOS surface: white in light mode, lifted charcoal in dark
|
||
/// mode. `secondarySystemBackground` made the whole composer read like a
|
||
/// grouped form cell instead of a floating iOS control.
|
||
private var composerSurfaceColor: Color {
|
||
Color(uiColor: UIColor { traits in
|
||
traits.userInterfaceStyle == .dark
|
||
? UIColor(white: 0.12, alpha: 1)
|
||
: .white
|
||
})
|
||
}
|
||
|
||
var body: some View {
|
||
let composer = interactor.composer
|
||
let attachments = interactor.attachments.items
|
||
let slashSuggestions = interactor.slashSuggestions
|
||
VStack(spacing: 0) {
|
||
if !slashSuggestions.isEmpty {
|
||
SlashCommandList(commands: slashSuggestions, surface: composerSurfaceColor) { command in
|
||
interactor.selectSlashCommand(command)
|
||
}
|
||
.padding(.bottom, 6)
|
||
}
|
||
VStack(spacing: 0) {
|
||
if scratchlistMode, let store = interactor.scratchlist {
|
||
ScratchlistDrawerView(store: store, sessionId: interactor.sessionId, interactor: interactor,
|
||
keyboardFocused: textFocused, onOpen: onOpenScratchlist)
|
||
}
|
||
if let error = interactor.scratchlistError {
|
||
ScratchlistErrorBanner(message: error,
|
||
actionTitle: interactor.scratchlistErrorDestination == nil ? "Dismiss" : "Retry",
|
||
retry: interactor.retryScratchlistComposerOperation)
|
||
.padding(.horizontal, 12)
|
||
}
|
||
if !attachments.isEmpty {
|
||
attachmentsRow(attachments)
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 8)
|
||
}
|
||
if !scratchlistMode, let schedule = interactor.pendingSchedule {
|
||
ScheduledSendChip(schedule: schedule) {
|
||
interactor.setPendingSchedule(nil)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 8)
|
||
}
|
||
if let dictation, case .recording(let startedAtMs) = dictation.state {
|
||
RecordingChipView(startedAtMs: startedAtMs) {
|
||
dictation.cancel()
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 8)
|
||
}
|
||
TextField(composerPrompt, text: text, prompt: Text(composerPrompt).foregroundColor(.secondary), axis: .vertical)
|
||
.focused($textFocused)
|
||
.font(typography.bodyFont)
|
||
.lineLimit(1...6)
|
||
.textFieldStyle(.plain)
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 4)
|
||
HStack(spacing: 2) {
|
||
addAttachmentButton
|
||
if interactor.scratchlist != nil { scratchlistButton }
|
||
if !scratchlistMode { scheduleButton(hasAttachments: !attachments.isEmpty) }
|
||
Spacer()
|
||
if scratchlistMode, composer.canSteer {
|
||
Button { interactor.abortSession() } label: {
|
||
Image(systemName: "stop.fill").foregroundStyle(.red).frame(width: 44, height: 44)
|
||
}.accessibilityLabel("Stop the current turn")
|
||
}
|
||
if let dictation, dictation.isAvailable {
|
||
micButton(dictation)
|
||
}
|
||
if scratchlistMode { parkButton }
|
||
else { primaryActionButton(composer, attachments: attachments) }
|
||
}
|
||
// 44 pt touch slot with a centered 38 pt circle:
|
||
// 9 + 3 = the shared 12 pt visual inset.
|
||
.padding(.horizontal, 9)
|
||
.padding(.bottom, 4)
|
||
}
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(composerSurfaceColor)
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
|
||
}
|
||
.hapiReadingColumn()
|
||
.padding(.top, 8)
|
||
// Overlap the home-indicator safe area only while the software
|
||
// keyboard is down; above a keyboard the normal gap applies.
|
||
.padding(.bottom, hugsBottomEdge && !textFocused ? -8 : 8)
|
||
.animation(.snappy, value: hugsBottomEdge && !textFocused)
|
||
.onChange(of: interactor.composerFocusRequest) {
|
||
textFocused = true
|
||
}
|
||
.sheet(isPresented: $schedulePickerOpen) {
|
||
ScheduleTimeSheet { date in
|
||
interactor.setPendingSchedule(.at(Int(date.timeIntervalSince1970 * 1000)))
|
||
}
|
||
}
|
||
.task(id: dictation.map { ObjectIdentifier($0) }) {
|
||
await dictation?.refreshAvailability()
|
||
}
|
||
.photosPicker(
|
||
isPresented: $photosPickerOpen,
|
||
selection: $photoSelection,
|
||
matching: .any(of: [.images, .videos])
|
||
)
|
||
.onChange(of: photoSelection) {
|
||
let items = photoSelection
|
||
guard !items.isEmpty else { return }
|
||
photoSelection = []
|
||
Task {
|
||
for item in items {
|
||
await ingestPhotoItem(item)
|
||
}
|
||
}
|
||
}
|
||
.fullScreenCover(isPresented: $cameraOpen) {
|
||
CameraCaptureView { jpeg in
|
||
Task {
|
||
await handle(AttachmentPreparer.prepare(
|
||
data: jpeg,
|
||
filename: AttachmentPreparer.cameraFilename(),
|
||
mimeType: "image/jpeg"
|
||
))
|
||
}
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
.fileImporter(
|
||
isPresented: $filePickerOpen,
|
||
allowedContentTypes: [.item],
|
||
allowsMultipleSelection: true
|
||
) { outcome in
|
||
guard case .success(let urls) = outcome else { return }
|
||
Task {
|
||
for url in urls {
|
||
await handle(AttachmentPreparer.prepare(fileURL: url))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Attachment ingestion
|
||
|
||
private func ingestPhotoItem(_ item: PhotosPickerItem) async {
|
||
let type = item.supportedContentTypes.first
|
||
if type?.conforms(to: .audiovisualContent) == true {
|
||
// Videos import as a temp file (capped read in the preparer) so a
|
||
// multi-GB pick never has to fit in memory.
|
||
guard let movie = try? await item.loadTransferable(type: PickedMovie.self) else {
|
||
interactor.postNotice(String(localized: "Couldn't read the selected video"))
|
||
return
|
||
}
|
||
let result = await AttachmentPreparer.prepare(fileURL: movie.url)
|
||
try? FileManager.default.removeItem(at: movie.url)
|
||
await handle(result)
|
||
return
|
||
}
|
||
let naming = AttachmentPreparer.photoFilename(for: type)
|
||
guard let data = try? await item.loadTransferable(type: Data.self) else {
|
||
interactor.postNotice(String(format: String(localized: "Couldn't read %@"), naming.filename))
|
||
return
|
||
}
|
||
await handle(AttachmentPreparer.prepare(
|
||
data: data,
|
||
filename: naming.filename,
|
||
mimeType: naming.mimeType
|
||
))
|
||
}
|
||
|
||
private func handle(_ result: PrepareResult) async {
|
||
switch result {
|
||
case .ready(let prepared):
|
||
if scratchlistMode, let store = interactor.scratchlist {
|
||
preparingAttachment = true
|
||
defer { preparingAttachment = false }
|
||
let limits = await store.limits(sessionId: interactor.sessionId)
|
||
let existing = interactor.attachments.scratchlistBudget
|
||
let outcome = await Task.detached(priority: .userInitiated) {
|
||
ScratchlistAttachmentImport.prepare(data: prepared.bytes, filename: prepared.filename,
|
||
mimeType: prepared.mimeType, existing: existing, limits: limits)
|
||
}.value
|
||
switch outcome {
|
||
case .ready(let attachment):
|
||
interactor.attachments.add(PreparedAttachment(filename: attachment.filename, mimeType: attachment.mimeType,
|
||
bytes: attachment.data, previewBytes: prepared.previewBytes), toScratchlist: true)
|
||
case .rejected(let message): interactor.reportScratchlistError(message)
|
||
}
|
||
} else { interactor.attachments.add(prepared) }
|
||
case .tooLarge(let filename, _):
|
||
interactor.postNotice(String(format: String(localized: "%@ is over the 50 MB upload limit"), filename))
|
||
case .unreadable(let filename):
|
||
interactor.postNotice(String(format: String(localized: "Couldn't read %@"), filename))
|
||
}
|
||
}
|
||
|
||
// MARK: - Attachment chips
|
||
|
||
private func attachmentsRow(_ attachments: [ComposerAttachmentUI]) -> some View {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 6) {
|
||
ForEach(attachments) { attachment in
|
||
ComposerAttachmentChipView(
|
||
attachment: attachment,
|
||
onRetry: { interactor.attachments.retry(attachment.id) },
|
||
onRemove: { interactor.attachments.remove(attachment.id) }
|
||
)
|
||
.disabled(interactor.scratchlistBusy || interactor.isSending)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Web clock: relative presets (resolved at Send) or a specific time
|
||
/// within 7 days. Unavailable with attachments — the hub rejects them.
|
||
private func scheduleButton(hasAttachments: Bool) -> some View {
|
||
let active = interactor.pendingSchedule != nil
|
||
return Menu {
|
||
Section("Schedule send") {
|
||
ForEach(PendingSchedule.presets, id: \.self) { preset in
|
||
Button(ScheduledSendChip.presetLabel(preset)) {
|
||
interactor.setPendingSchedule(preset)
|
||
}
|
||
}
|
||
Button("Pick a Time…", systemImage: "calendar") {
|
||
schedulePickerOpen = true
|
||
}
|
||
}
|
||
if active {
|
||
Button("Don't Schedule", systemImage: "xmark") {
|
||
interactor.setPendingSchedule(nil)
|
||
}
|
||
}
|
||
} label: {
|
||
actionCircle(
|
||
background: active ? AnyShapeStyle(.tint.opacity(0.18)) : AnyShapeStyle(.fill.tertiary),
|
||
foreground: active ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)
|
||
) {
|
||
Image(systemName: "clock")
|
||
.font(.system(size: 15, weight: .medium))
|
||
}
|
||
}
|
||
.disabled(hasAttachments || interactor.scratchlistBusy)
|
||
.accessibilityLabel("Schedule send")
|
||
.accessibilityIdentifier("chat.schedule-send")
|
||
}
|
||
|
||
private var addAttachmentButton: some View {
|
||
Button {
|
||
attachDialogOpen = true
|
||
} label: {
|
||
actionCircle(
|
||
background: AnyShapeStyle(.fill.tertiary),
|
||
foreground: AnyShapeStyle(.secondary)
|
||
) {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 15, weight: .medium))
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(preparingAttachment || interactor.scratchlistBusy)
|
||
.accessibilityLabel("Add attachment")
|
||
.accessibilityIdentifier("chat.add-attachment")
|
||
.confirmationDialog("Attach", isPresented: $attachDialogOpen, titleVisibility: .visible) {
|
||
Button("Photo library") {
|
||
photosPickerOpen = true
|
||
}
|
||
if CameraCaptureView.isAvailable {
|
||
Button("Camera") {
|
||
cameraOpen = true
|
||
}
|
||
}
|
||
Button("Files") {
|
||
filePickerOpen = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private var scratchlistButton: some View {
|
||
Button {
|
||
interactor.setComposerDestination(scratchlistMode ? .chat : .scratchlist)
|
||
} label: {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "tray")
|
||
if interactor.scratchlistCount > 0 {
|
||
Text(verbatim: interactor.scratchlistCount > 99 ? "99+" : "\(interactor.scratchlistCount)")
|
||
.font(.caption.weight(.medium)).monospacedDigit()
|
||
}
|
||
}
|
||
.foregroundStyle(scratchlistMode ? AnyShapeStyle(.orange) : AnyShapeStyle(.secondary))
|
||
.padding(.horizontal, 6)
|
||
.frame(minWidth: 44, minHeight: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(interactor.scratchlistBusy || interactor.isSending || preparingAttachment)
|
||
.accessibilityLabel(String(format: String(localized: "Scratchlist (%lld)"), Int64(interactor.scratchlistCount)))
|
||
.accessibilityValue(scratchlistMode ? String(localized: "Held — not sent") : String(localized: "Chat"))
|
||
.accessibilityIdentifier("scratchlist.toggle")
|
||
}
|
||
|
||
private var parkButton: some View {
|
||
Button { interactor.parkComposerDraft() } label: {
|
||
HStack(spacing: 5) {
|
||
if interactor.scratchlistBusy { ProgressView().controlSize(.small) }
|
||
else { Image(systemName: "tray.and.arrow.down") }
|
||
Text("Save draft").font(.subheadline.weight(.semibold))
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.frame(minHeight: 38)
|
||
.background(.orange.opacity(0.16), in: Capsule())
|
||
.frame(minHeight: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!interactor.hasComposerDraft || interactor.scratchlistBusy || interactor.isSending
|
||
|| interactor.attachments.hasUnsettled || preparingAttachment)
|
||
.accessibilityIdentifier("scratchlist.park")
|
||
}
|
||
|
||
// MARK: - Dictation
|
||
|
||
private func micButton(_ dictation: DictationController) -> some View {
|
||
let recording: Bool = {
|
||
if case .recording = dictation.state { return true }
|
||
return false
|
||
}()
|
||
let busy = dictation.state == .starting || dictation.state == .transcribing
|
||
return Button {
|
||
toggleDictation(dictation)
|
||
} label: {
|
||
actionCircle(
|
||
background: recording
|
||
? AnyShapeStyle(.red.opacity(0.15))
|
||
: AnyShapeStyle(.fill.tertiary),
|
||
foreground: recording ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)
|
||
) {
|
||
if busy {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
} else {
|
||
Image(systemName: recording ? "stop.fill" : "mic.fill")
|
||
.font(.system(size: 15))
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(busy)
|
||
.accessibilityLabel(recording ? String(localized: "Stop dictation") : String(localized: "Dictate"))
|
||
}
|
||
|
||
private func toggleDictation(_ dictation: DictationController) {
|
||
// Stopping never needs the permission; starting checks + requests it.
|
||
guard dictation.state == .idle else {
|
||
dictation.toggle()
|
||
return
|
||
}
|
||
switch AVAudioApplication.shared.recordPermission {
|
||
case .granted:
|
||
dictation.toggle()
|
||
case .denied:
|
||
interactor.postNotice(String(localized: "Microphone access is disabled — allow it in Settings to dictate"))
|
||
case .undetermined:
|
||
Task {
|
||
let granted = await AVAudioApplication.requestRecordPermission()
|
||
if granted {
|
||
dictation.toggle()
|
||
} else {
|
||
interactor.postNotice(String(localized: "Microphone permission is needed for dictation"))
|
||
}
|
||
}
|
||
@unknown default:
|
||
dictation.toggle()
|
||
}
|
||
}
|
||
|
||
// MARK: - Buttons
|
||
|
||
@ViewBuilder
|
||
private func primaryActionButton(_ composer: ComposerState, attachments: [ComposerAttachmentUI]) -> some View {
|
||
let hasText = !composer.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
let hasDraft = hasText || !attachments.isEmpty
|
||
let scheduled = interactor.pendingSchedule != nil
|
||
// Attachments gate the send like the web: every chip must settle
|
||
// Ready (uploading waits, failed must be retried or removed); a ready
|
||
// tray allows an attachments-only send (wire: text or attachments).
|
||
// A scheduled send is text-only and never steers.
|
||
let attachmentsBusy = attachments.contains { $0.status != .ready }
|
||
let attachmentsReady = !attachments.isEmpty && !attachmentsBusy
|
||
let canSubmit = scheduled
|
||
? hasText && attachments.isEmpty && !composer.isSending
|
||
: (hasText || attachmentsReady) && !attachmentsBusy && !composer.isSending
|
||
let action: ComposerPrimaryAction = if composer.isSending {
|
||
.sending
|
||
} else if composer.canSteer && !hasDraft {
|
||
.stop
|
||
} else {
|
||
.send(enabled: canSubmit)
|
||
}
|
||
|
||
switch action {
|
||
case .sending:
|
||
Button {} label: {
|
||
actionCircle(
|
||
background: AnyShapeStyle(.fill.tertiary),
|
||
foreground: AnyShapeStyle(.secondary)
|
||
) {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(true)
|
||
.accessibilityLabel("Send")
|
||
case .stop:
|
||
Button {
|
||
interactor.abortSession()
|
||
} label: {
|
||
actionCircle(
|
||
background: AnyShapeStyle(.red),
|
||
foreground: AnyShapeStyle(.white)
|
||
) {
|
||
Image(systemName: "stop.fill")
|
||
.font(.system(size: 15))
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Stop the current turn")
|
||
case .send(let enabled):
|
||
let label = sendLabel(enabled: enabled)
|
||
if scheduled {
|
||
Button {
|
||
interactor.sendMessage()
|
||
} label: {
|
||
label
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!enabled)
|
||
.accessibilityLabel("Schedule send")
|
||
} else if composer.canSteer && enabled {
|
||
// Tap sends (queue); long-press opens the deliberate steer intent.
|
||
Menu {
|
||
Button("Send & steer into current turn") {
|
||
interactor.sendMessage(steer: true)
|
||
}
|
||
} label: {
|
||
label
|
||
} primaryAction: {
|
||
interactor.sendMessage()
|
||
}
|
||
.accessibilityLabel("Send — long-press to steer")
|
||
} else {
|
||
Button {
|
||
interactor.sendMessage()
|
||
} label: {
|
||
label
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!enabled)
|
||
.accessibilityLabel("Send")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sendLabel(enabled: Bool) -> some View {
|
||
actionCircle(
|
||
background: enabled ? AnyShapeStyle(.tint) : AnyShapeStyle(.fill.tertiary),
|
||
foreground: enabled ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)
|
||
) {
|
||
Image(systemName: "arrow.up")
|
||
.font(.system(size: 15, weight: .semibold))
|
||
}
|
||
}
|
||
|
||
// Icon-only controls keep their 38pt visuals / 44pt hit areas. Their
|
||
// symbols use fixed sizes; editable text remains fully Dynamic Type.
|
||
private func actionCircle<Content: View>(
|
||
background: AnyShapeStyle,
|
||
foreground: AnyShapeStyle,
|
||
@ViewBuilder content: () -> Content
|
||
) -> some View {
|
||
content()
|
||
.frame(width: 38, height: 38)
|
||
.background(background, in: Circle())
|
||
.foregroundStyle(foreground)
|
||
.frame(width: 44, height: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
private enum ComposerPrimaryAction {
|
||
case sending
|
||
case stop
|
||
case send(enabled: Bool)
|
||
}
|
||
|
||
// MARK: - Attachment chip
|
||
|
||
/// One tray chip: 36 pt thumb (image preview / MIME glyph, spinner while
|
||
/// uploading), filename + status line, ✕ to remove. A failed chip tints red
|
||
/// and taps to retry.
|
||
/// Tap inserts `/name ` for arguments. Short lists size to their rows;
|
||
/// longer ones scroll inside a bounded card.
|
||
private struct SlashCommandList: View {
|
||
let commands: [SlashCommand]
|
||
let surface: Color
|
||
let onSelect: (SlashCommand) -> Void
|
||
|
||
var body: some View {
|
||
Group {
|
||
if commands.count > 4 {
|
||
ScrollView {
|
||
rows
|
||
}
|
||
.frame(height: 220)
|
||
} else {
|
||
rows
|
||
}
|
||
}
|
||
.background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(surface))
|
||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
|
||
.accessibilityIdentifier("composer.slash-commands")
|
||
}
|
||
|
||
private var rows: some View {
|
||
LazyVStack(alignment: .leading, spacing: 0) {
|
||
ForEach(Array(commands.enumerated()), id: \.element.name) { index, command in
|
||
if index > 0 {
|
||
Divider().padding(.leading, 12)
|
||
}
|
||
Button {
|
||
onSelect(command)
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(verbatim: "/\(command.name)")
|
||
.font(.body.monospaced())
|
||
.foregroundStyle(.primary)
|
||
if let description = command.description, !description.isEmpty {
|
||
Text(verbatim: description)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(2)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 4)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityIdentifier("composer.slash.\(command.name)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The pending send time above the input, with × to send normally again.
|
||
private struct ScheduledSendChip: View {
|
||
let schedule: PendingSchedule
|
||
let onClear: () -> Void
|
||
|
||
static func presetLabel(_ schedule: PendingSchedule) -> String {
|
||
switch schedule {
|
||
case .delay(let minutes) where minutes % 60 == 0:
|
||
let hours = minutes / 60
|
||
return hours == 1
|
||
? String(localized: "In 1 hour")
|
||
: String(format: String(localized: "In %lld hours"), Int64(hours))
|
||
case .delay(let minutes):
|
||
return String(format: String(localized: "In %lld minutes"), Int64(minutes))
|
||
case .at(let ms):
|
||
return Date(timeIntervalSince1970: Double(ms) / 1000)
|
||
.formatted(date: .abbreviated, time: .shortened)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "clock")
|
||
Text(String(format: String(localized: "Scheduled · %@"), Self.presetLabel(schedule)))
|
||
.lineLimit(1)
|
||
Spacer(minLength: 4)
|
||
Button(action: onClear) {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundStyle(.secondary)
|
||
.frame(minWidth: 44, minHeight: 32)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Don't Schedule")
|
||
}
|
||
.font(.footnote)
|
||
.foregroundStyle(.tint)
|
||
.padding(.leading, 10)
|
||
.background(.tint.opacity(0.1), in: Capsule())
|
||
.accessibilityElement(children: .contain)
|
||
.accessibilityIdentifier("chat.scheduled-send")
|
||
}
|
||
}
|
||
|
||
/// Specific send time: now through the hub's 7-day limit.
|
||
private struct ScheduleTimeSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
let onSchedule: (Date) -> Void
|
||
@State private var date = Date().addingTimeInterval(30 * 60)
|
||
|
||
var body: some View {
|
||
let now = Date()
|
||
NavigationStack {
|
||
Form {
|
||
Section {
|
||
DatePicker(
|
||
"Send at",
|
||
selection: $date,
|
||
in: now...now.addingTimeInterval(TimeInterval(PendingSchedule.maxDays * 24 * 60 * 60)),
|
||
displayedComponents: [.date, .hourAndMinute]
|
||
)
|
||
.datePickerStyle(.graphical)
|
||
} footer: {
|
||
Text("Max 7 days. Requires the hub running; the CLI catches up the next time it connects after that time.")
|
||
}
|
||
}
|
||
.navigationTitle("Schedule send")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("Cancel") { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Schedule") {
|
||
onSchedule(date)
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.large])
|
||
}
|
||
}
|
||
|
||
private struct ComposerAttachmentChipView: View {
|
||
let attachment: ComposerAttachmentUI
|
||
let onRetry: () -> Void
|
||
let onRemove: () -> Void
|
||
@State private var showsUploadError = false
|
||
|
||
private var failed: Bool { attachment.status == .failed }
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
if failed {
|
||
Button(action: onRetry) {
|
||
attachmentLabel
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
} else {
|
||
attachmentLabel
|
||
}
|
||
if failed, attachment.errorMessage != nil {
|
||
Button {
|
||
showsUploadError = true
|
||
} label: {
|
||
Image(systemName: "info.circle")
|
||
.foregroundStyle(.red)
|
||
.frame(minWidth: 44, minHeight: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Details")
|
||
.accessibilityIdentifier("attachment.error.\(attachment.id)")
|
||
}
|
||
Button {
|
||
onRemove()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
.padding(6)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Remove \(attachment.filename)")
|
||
}
|
||
.padding(6)
|
||
.background(
|
||
failed ? AnyShapeStyle(.red.opacity(0.12)) : AnyShapeStyle(.fill.tertiary),
|
||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
)
|
||
.alert("Error", isPresented: $showsUploadError) {
|
||
Button("Retry", action: onRetry)
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: {
|
||
Text(verbatim: "\(attachment.filename)\n\n\(attachment.errorMessage ?? "")")
|
||
}
|
||
}
|
||
|
||
private var attachmentLabel: some View {
|
||
HStack(spacing: 8) {
|
||
thumb
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(attachment.filename)
|
||
.font(.caption.weight(.medium))
|
||
.lineLimit(1)
|
||
Text(statusLine)
|
||
.font(.caption2)
|
||
.foregroundStyle(failed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
|
||
.lineLimit(1)
|
||
}
|
||
.frame(maxWidth: 132, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
private var statusLine: String {
|
||
switch attachment.status {
|
||
case .uploading:
|
||
return String(localized: "Uploading…")
|
||
case .failed:
|
||
return String(localized: "Failed — tap to retry")
|
||
case .ready:
|
||
return formatChipSize(attachment.sizeBytes)
|
||
}
|
||
}
|
||
|
||
private var thumb: some View {
|
||
ZStack {
|
||
// Preparer-made ≤ 512 px JPEGs — cheap enough to decode inline.
|
||
if let previewBytes = attachment.previewBytes,
|
||
let image = UIImage(data: previewBytes) {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFill()
|
||
.frame(width: 36, height: 36)
|
||
.clipped()
|
||
.opacity(attachment.status == .uploading ? 0.4 : 1)
|
||
} else {
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.fill(.fill.secondary)
|
||
.frame(width: 36, height: 36)
|
||
Image(systemName: attachment.mimeType.hasPrefix("image/") ? "photo" : "doc")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
if attachment.status == .uploading {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
.frame(width: 36, height: 36)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
}
|
||
}
|
||
|
||
/// `12.3 MB` / `456 KB` / `789 B` chip size label (Android `formatChipSize`).
|
||
func formatChipSize(_ bytes: Int) -> String {
|
||
if bytes >= 1024 * 1024 {
|
||
return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0))
|
||
}
|
||
if bytes >= 1024 {
|
||
return String(format: "%.0f KB", Double(bytes) / 1024.0)
|
||
}
|
||
return "\(bytes) B"
|
||
}
|
||
|
||
// MARK: - Recording chip
|
||
|
||
/// Elapsed-time recording chip with a cancel affordance (discards the take).
|
||
private struct RecordingChipView: View {
|
||
let startedAtMs: Int
|
||
let onCancel: () -> Void
|
||
|
||
var body: some View {
|
||
TimelineView(.periodic(from: .now, by: 0.25)) { context in
|
||
HStack(spacing: 8) {
|
||
Text("● Recording… \(formatElapsed(elapsedSeconds(at: context.date)))")
|
||
.font(.caption.weight(.medium))
|
||
Spacer(minLength: 8)
|
||
Button("Cancel") {
|
||
onCancel()
|
||
}
|
||
.font(.caption.weight(.semibold))
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
|
||
private func elapsedSeconds(at date: Date) -> Int {
|
||
max(0, (Int(date.timeIntervalSince1970 * 1000) - startedAtMs) / 1000)
|
||
}
|
||
}
|
||
|
||
/// `m:ss` elapsed-time label for the recording chip.
|
||
func formatElapsed(_ totalSeconds: Int) -> String {
|
||
String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
|
||
}
|
||
|
||
// MARK: - Video import
|
||
|
||
/// Photo-picker videos land as a temp file (`FileRepresentation`) so the
|
||
/// preparer's capped read — not a full in-memory `Data` — bounds them.
|
||
private struct PickedMovie: Transferable {
|
||
let url: URL
|
||
|
||
static var transferRepresentation: some TransferRepresentation {
|
||
FileRepresentation(contentType: .movie) { movie in
|
||
SentTransferredFile(movie.url)
|
||
} importing: { received in
|
||
let ext = received.file.pathExtension.isEmpty ? "mov" : received.file.pathExtension
|
||
let destination = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("video-\(UUID().uuidString.prefix(8)).\(ext)")
|
||
try FileManager.default.copyItem(at: received.file, to: destination)
|
||
return PickedMovie(url: destination)
|
||
}
|
||
}
|
||
}
|