Files
hapi/ios/Hapi/Features/Chat/Attachments/AttachmentPreviewView.swift
T
weishu 05d063cc33 feat(ios): composer attachments + dictation (A-M3f)
Attachments (Android B-M3f semantics ported verbatim):
- HapiClient/Attachments/AttachmentPolicy — pure plan matrix (>4 MB
  recompressible image -> 2048 px JPEG q85 with .jpg rename, 50 MB hard
  reject, 192 MB image read cap, 512 px q80 previewUrl data-URL thumbs,
  data-URL parse/round-trip).
- HapiClient/Attachments/ComposerAttachments — upload-on-pick tray over an
  AttachmentUploading seam (APIClient conforms): uploading/ready/failed
  chips, retained payload for retry, remove -> best-effort delete,
  mid-upload removal deletes the orphan on completion, consume() ->
  AttachmentMetadata with JPEG data-URL previewUrl, discardAllDetached +
  deinit orphan cleanup (Android onCleared analogue).
- ChatInteractor: tray ownership, unsettled chips refuse the send with a
  notice, attachments-only sends post empty text, optimistic rows carry
  the metadata, appendDictatedText/postNotice/discardAttachments.
- App: AttachmentPreparer (capped security-scoped reads, ImageIO
  downscale/encode with EXIF transform, HEIC-undecodable fallback),
  PhotosPicker multi (videos via FileRepresentation temp files),
  UIImagePickerController camera capture, fileImporter; composer chip row
  (thumb/spinner/tap-to-retry/remove) with attachment-aware send gating;
  user bubbles upgrade chips to off-main-decoded previewUrl thumbnails
  (web-sent attachments included).

Dictation (Android B-M3ce port):
- HapiProtocol/Models/VoiceApi — TranscriptionResponse,
  TranscriptionProvidersResponse, TranscriptionProviderInfo.
- HapiClient/Endpoints/VoiceEndpoints — GET
  /api/voice/transcription/providers + multipart POST
  /api/voice/transcription (file/provider/mode/language, Android part
  order) over MultipartFormData; DictationTranscribing conformance.
- HapiClient/Voice/DictationController — idle/starting/recording/
  transcribing, transcribed/noProvider/error events, provider memoized
  (first standard-capable entry), appendTranscript port.
- App: AVAudioRecorderDictation (m4a/AAC mono 44.1 kHz 96 kbps, session
  activate/deactivate), mic button + recording chip (elapsed + cancel),
  record-permission request via AVAudioApplication.

Info.plist gains NSMicrophoneUsageDescription; the camera string now
covers attachment capture (modern PhotosPicker needs no photo-library
permission). Tests: policy matrix, tray over the real client with exact
base64 upload bodies + gated in-flight scenarios, dictation controller
suite with fake recorder/transport, voice endpoint request shapes, and
the interactor attachment-send matrix transcribed from the Android VM
tests (wire bodies byte-for-byte).
2026-08-18 11:27:28 +08:00

94 lines
3.2 KiB
Swift

import CoreGraphics
import HapiClient
import HapiProtocol
import SwiftUI
/// One user-bubble attachment: image mimes with a decodable `previewUrl`
/// data URL render a thumbnail (web `MessageAttachments` split); everything
/// else — plus decode failures — falls back to the filename chip. Port of
/// the Android `AttachmentView`/`rememberPreviewImage`.
///
/// Mobile-authored previews are ≤ 512 px JPEGs, but web-authored ones embed
/// the full original (up to 5 MB), so the decode downsamples to 512 px and
/// runs off the main actor.
struct AttachmentPreviewView: View {
let attachment: AttachmentMetadata
private enum Phase: Equatable {
/// Decode still running — render a sized neutral placeholder.
case loading
case ready(Image)
/// Not a data URL / undecodable — fall back to the filename chip.
case unavailable
}
@State private var phase: Phase = .loading
var body: some View {
if !attachment.mimeType.hasPrefix("image/") || attachment.previewUrl == nil {
AttachmentChipView(attachment: attachment)
} else {
switch phase {
case .ready(let image):
image
.resizable()
.scaledToFit()
.frame(maxHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
case .loading:
// Sized placeholder while decoding keeps the bubble from
// jumping.
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.background.opacity(0.4))
.frame(width: 120, height: 90)
.task(id: attachment.previewUrl) {
await decode()
}
case .unavailable:
AttachmentChipView(attachment: attachment)
}
}
}
private func decode() async {
guard let previewUrl = attachment.previewUrl else {
phase = .unavailable
return
}
// Nonisolated async helper — runs off the main actor.
let decoded = await Self.decodeImage(previewUrl: previewUrl)
if let decoded {
phase = .ready(Image(decoded, scale: 1, label: Text(attachment.filename)))
} else {
phase = .unavailable
}
}
private nonisolated static func decodeImage(previewUrl: String) async -> CGImage? {
guard let bytes = AttachmentPolicy.bytesFromDataUrl(previewUrl) else { return nil }
return AttachmentPreparer.decodeDownsampled(
bytes,
maxDimension: AttachmentPolicy.previewMaxDimension
)
}
}
/// Filename chip fallback (also the non-image rendering).
struct AttachmentChipView: View {
let attachment: AttachmentMetadata
var body: some View {
Label(
attachment.filename,
systemImage: attachment.mimeType.hasPrefix("image/") ? "photo" : "paperclip"
)
.font(.caption)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.background.opacity(0.6), in: RoundedRectangle(cornerRadius: 8))
}
}