mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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).
94 lines
3.2 KiB
Swift
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))
|
|
}
|
|
}
|