Attachment row view

This commit is contained in:
Shadowfacts 2024-10-14 22:57:39 -04:00
parent 5be80d8e68
commit 381f3ee737
4 changed files with 280 additions and 3 deletions

View File

@ -181,7 +181,7 @@ extension EnvironmentValues {
}
}
private struct GIFViewWrapper: UIViewRepresentable {
struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView
@State var controller: GIFController

View File

@ -6,12 +6,174 @@
//
import SwiftUI
import InstanceFeatures
import Vision
struct AttachmentRowView: View {
@ObservedObject var attachment: DraftAttachment
@State private var isRecognizingText = false
@State private var textRecognitionError: (any Error)?
private var thumbnailSize: CGFloat {
#if os(visionOS)
120
#else
80
#endif
}
var body: some View {
Text(attachment.id.uuidString)
HStack(alignment: .center, spacing: 4) {
thumbnailView
descriptionView
}
.alertWithData("Text Recognition Failed", data: $textRecognitionError) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
// TODO: attachments missing descriptions feature
private var thumbnailView: some View {
AttachmentThumbnailView(attachment: attachment)
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: thumbnailSize, height: thumbnailSize)
.contextMenu {
EditDrawingButton(attachment: attachment)
RecognizeTextButton(attachment: attachment, isRecognizingText: $isRecognizingText, error: $textRecognitionError)
DeleteButton(attachment: attachment)
} preview: {
// TODO: need to fix flash of preview changing size
AttachmentThumbnailView(attachment: attachment)
}
}
@ViewBuilder
private var descriptionView: some View {
if isRecognizingText {
ProgressView()
.progressViewStyle(.circular)
} else {
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
}
}
}
private struct EditDrawingButton: View {
@ObservedObject var attachment: DraftAttachment
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
if attachment.drawingData != nil {
Button(action: editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw")
}
}
}
private func editDrawing() {
if case .drawing(let drawing) = attachment.data {
presentDrawing?(drawing) {
attachment.drawing = $0
}
}
}
}
private struct RecognizeTextButton: View {
@ObservedObject var attachment: DraftAttachment
@Binding var isRecognizingText: Bool
@Binding var error: (any Error)?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
if attachment.type == .image {
Button {
Task {
await recognizeText()
}
} label: {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
}
}
private func recognizeText() async {
isRecognizingText = true
defer { isRecognizingText = false }
do {
let data = try await getAttachmentData()
let observations = try await runRecognizeTextRequest(data: data)
if let observations {
var text = ""
for observation in observations {
let result = observation.topCandidates(1).first!
text.append(result.string)
text.append("\n")
}
self.attachment.attachmentDescription = text
}
} catch let error as NSError where error.domain == VNErrorDomain && error.code == 1 {
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
return
} catch {
self.error = error
}
}
private func getAttachmentData() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
attachment.getData(features: instanceFeatures) { result in
switch result {
case .success(let (data, _)):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func runRecognizeTextRequest(data: Data) async throws -> [VNRecognizedTextObservation]? {
return try await withCheckedThrowingContinuation { continuation in
let handler = VNImageRequestHandler(data: data)
let request = VNRecognizeTextRequest { request, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: request.results as? [VNRecognizedTextObservation])
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
try? handler.perform([request])
}
}
}
}
private struct DeleteButton: View {
let attachment: DraftAttachment
var body: some View {
Button(role: .destructive, action: removeAttachment) {
Label("Delete", systemImage: "trash")
}
}
private func removeAttachment() {
let draft = attachment.draft
var array = draft.draftAttachments
guard let index = array.firstIndex(of: attachment) else {
return
}
array.remove(at: index)
draft.attachments = NSMutableOrderedSet(array: array)
}
}

View File

@ -0,0 +1,114 @@
//
// AttachmentThumbnailView.swift
// ComposeUI
//
// Created by Shadowfacts on 10/14/24.
//
import SwiftUI
import TuskerComponents
import AVFoundation
import Photos
struct AttachmentThumbnailView: View {
let attachment: DraftAttachment
let contentMode: ContentMode = .fit
@State private var mode: Mode = .empty
@EnvironmentObject private var composeController: ComposeController
var body: some View {
switch mode {
case .empty:
Image(systemName: "photo")
.task {
await loadThumbnail()
}
case .image(let image):
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: contentMode)
case .gifController(let controller):
GIFViewWrapper(controller: controller)
}
}
private func loadThumbnail() async {
switch attachment.data {
case .editing(_, let kind, let url):
switch kind {
case .image:
if let image = await composeController.fetchAttachment(url) {
self.mode = .image(image)
}
case .video, .gifv:
await loadVideoThumbnail(url: url)
case .audio, .unknown:
break
}
case .asset(let id):
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return
}
let isGIF = PHAssetResource.assetResources(for: asset).contains {
$0.uniformTypeIdentifier == UTType.gif.identifier
}
if isGIF {
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
guard let data else { return }
if typeIdentifier == UTType.gif.identifier {
self.mode = .gifController(GIFController(gifData: data))
} else if let image = UIImage(data: data) {
self.mode = .image(image)
}
}
} else {
let size = CGSize(width: 80, height: 80)
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in
if let image {
self.mode = .image(image)
}
}
}
case .drawing(let drawing):
self.mode = .image(drawing.imageInLightMode(from: drawing.bounds))
case .file(let url, let type):
if type.conforms(to: .movie) {
await loadVideoThumbnail(url: url)
} else if let data = try? Data(contentsOf: url) {
if type == .gif {
self.mode = .gifController(GIFController(gifData: data))
} else if type.conforms(to: .image) {
if let image = UIImage(data: data),
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
// crashing share extension. see FB12186346
let prepared = await image.byPreparingForDisplay() {
self.mode = .image(prepared)
}
}
}
case .none:
break
}
}
private func loadVideoThumbnail(url: URL) async {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
self.mode = .image(UIImage(cgImage: cgImage))
}
}
enum Mode {
case empty
case image(UIImage)
case gifController(GIFController)
}
}

View File

@ -33,6 +33,7 @@ struct AttachmentsListView: View {
// view from laying out, and leaving the intrinsic content size at zero too.
.frame(minHeight: 50)
.padding(.horizontal, -8)
.environmentObject(instanceFeatures)
}
}
@ -230,7 +231,7 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView
private let attachmentCell = UICollectionView.CellRegistration<UICollectionViewListCell, DraftAttachment> { cell, indexPath, item in
cell.contentConfiguration = UIHostingConfiguration {
Text(item.id.uuidString)
AttachmentRowView(attachment: item)
}
}