Files
hapi/ios/Hapi/Features/Chat/Composer/ChatComposerView.swift
T

869 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}