mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
iOS showed scheduled messages but could not create them. A clock button now sits next to the composer's attachment button. It offers the web presets (in 5 min, 30 min, 1 h, 4 h) or a specific time up to 7 days ahead. A chip above the input shows the pending time; its × sends normally again. Presets resolve when the user taps Send, so "in 5 minutes" counts from the send. Validation follows the web: a 30 s grace period for past times and a 7-day maximum. A scheduled send is text-only and queued. The hub rejects scheduledAt with attachments or steer, so the clock is disabled while attachments are present and the steer menu is hidden. The schedule clears once the send dispatches. Sending reuses the existing optimistic path, and a retry keeps the row's scheduledAt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
840 lines
33 KiB
Swift
840 lines
33 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
|
||
|
||
private var failed: Bool { attachment.status == .failed }
|
||
|
||
var body: 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)
|
||
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)
|
||
)
|
||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
.onTapGesture {
|
||
if failed { onRetry() }
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|