Attachment row view
This commit is contained in:
parent
5be80d8e68
commit
381f3ee737
|
@ -181,7 +181,7 @@ extension EnvironmentValues {
|
|||
}
|
||||
}
|
||||
|
||||
private struct GIFViewWrapper: UIViewRepresentable {
|
||||
struct GIFViewWrapper: UIViewRepresentable {
|
||||
typealias UIViewType = GIFImageView
|
||||
|
||||
@State var controller: GIFController
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue