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
|
typealias UIViewType = GIFImageView
|
||||||
|
|
||||||
@State var controller: GIFController
|
@State var controller: GIFController
|
||||||
|
|
|
@ -6,12 +6,174 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import InstanceFeatures
|
||||||
|
import Vision
|
||||||
|
|
||||||
struct AttachmentRowView: View {
|
struct AttachmentRowView: View {
|
||||||
@ObservedObject var attachment: DraftAttachment
|
@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 {
|
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.
|
// view from laying out, and leaving the intrinsic content size at zero too.
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 50)
|
||||||
.padding(.horizontal, -8)
|
.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
|
private let attachmentCell = UICollectionView.CellRegistration<UICollectionViewListCell, DraftAttachment> { cell, indexPath, item in
|
||||||
cell.contentConfiguration = UIHostingConfiguration {
|
cell.contentConfiguration = UIHostingConfiguration {
|
||||||
Text(item.id.uuidString)
|
AttachmentRowView(attachment: item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue