diff --git a/Localization/app.json b/Localization/app.json index a965b23ae..650aff30e 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -374,7 +374,11 @@ "video": "video", "attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.", "description_photo": "Describe the photo for the visually-impaired...", - "description_video": "Describe the video for the visually-impaired..." + "description_video": "Describe the video for the visually-impaired...", + "load_failed": "Load Failed", + "upload_failed": "Upload Failed", + "can_not_recognize_this_media_attachment": "Can not regonize this media attachment", + "attachment_too_large": "Attachment too large" }, "poll": { "duration_time": "Duration: %s", diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/indicator.button.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/indicator.button.background.colorset/Contents.json new file mode 100644 index 000000000..7a1c8d9e2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/indicator.button.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.400", + "green" : "0.275", + "red" : "0.275" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.400", + "green" : "0.275", + "red" : "0.275" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Arrow Clockwise.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Arrow Clockwise.pdf new file mode 100644 index 000000000..a15c522d8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Arrow Clockwise.pdf @@ -0,0 +1,91 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 2.750000 2.750000 cm +0.000000 0.000000 0.000000 scn +9.250000 16.500000 m +5.245935 16.500000 2.000000 13.254065 2.000000 9.250000 c +2.000000 5.245935 5.245935 2.000000 9.250000 2.000000 c +13.254065 2.000000 16.500000 5.245935 16.500000 9.250000 c +16.500000 9.535608 16.483484 9.817360 16.451357 10.094351 c +16.383255 10.681498 16.809317 11.250000 17.400400 11.250000 c +17.916018 11.250000 18.369314 10.891933 18.431660 10.380100 c +18.476776 10.009713 18.500000 9.632568 18.500000 9.250000 c +18.500000 4.141366 14.358634 0.000000 9.250000 0.000000 c +4.141366 0.000000 0.000000 4.141366 0.000000 9.250000 c +0.000000 14.358634 4.141366 18.500000 9.250000 18.500000 c +11.423139 18.500000 13.421247 17.750608 15.000000 16.496151 c +15.000000 17.000000 l +15.000000 17.552284 15.447716 18.000000 16.000000 18.000000 c +16.552284 18.000000 17.000000 17.552284 17.000000 17.000000 c +17.000000 14.301708 l +17.011232 14.284512 17.022409 14.267276 17.033529 14.250000 c +17.000000 14.250000 l +17.000000 14.000000 l +17.000000 13.447716 16.552284 13.000000 16.000000 13.000000 c +13.000000 13.000000 l +12.447715 13.000000 12.000000 13.447716 12.000000 14.000000 c +12.000000 14.552284 12.447715 15.000000 13.000000 15.000000 c +13.666476 15.000000 l +12.443584 15.940684 10.912110 16.500000 9.250000 16.500000 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1365 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001455 00000 n +0000001478 00000 n +0000001651 00000 n +0000001725 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1784 +%%EOF \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Contents.json new file mode 100644 index 000000000..92bff3aca --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Arrow Clockwise.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Contents.json new file mode 100644 index 000000000..b2b588d4d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Dismiss.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Dismiss.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Dismiss.pdf new file mode 100644 index 000000000..0616f6275 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Dismiss.pdf @@ -0,0 +1,89 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 4.000000 3.804749 cm +0.000000 0.000000 0.000000 scn +0.209704 15.808150 m +0.292893 15.902358 l +0.653377 16.262842 1.220608 16.290571 1.612899 15.985547 c +1.707107 15.902358 l +8.000000 9.610251 l +14.292892 15.902358 l +14.683416 16.292883 15.316584 16.292883 15.707108 15.902358 c +16.097631 15.511834 16.097631 14.878669 15.707108 14.488145 c +9.415000 8.195251 l +15.707108 1.902359 l +16.067591 1.541875 16.095320 0.974643 15.790295 0.582352 c +15.707108 0.488144 l +15.346623 0.127661 14.779391 0.099932 14.387100 0.404957 c +14.292892 0.488144 l +8.000000 6.780252 l +1.707107 0.488144 l +1.316582 0.097620 0.683418 0.097620 0.292893 0.488144 c +-0.097631 0.878668 -0.097631 1.511835 0.292893 1.902359 c +6.585000 8.195251 l +0.292893 14.488145 l +-0.067591 14.848629 -0.095320 15.415859 0.209704 15.808150 c +0.292893 15.902358 l +0.209704 15.808150 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 914 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001004 00000 n +0000001026 00000 n +0000001199 00000 n +0000001273 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1332 +%%EOF \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index 5cd0059d8..fc47acdfd 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -130,6 +130,11 @@ public enum Asset { } public enum Scene { public enum Compose { + public enum Attachment { + public static let indicatorButtonBackground = ColorAsset(name: "Scene/Compose/Attachment/indicator.button.background") + public static let retry = ImageAsset(name: "Scene/Compose/Attachment/retry") + public static let stop = ImageAsset(name: "Scene/Compose/Attachment/stop") + } public static let earth = ImageAsset(name: "Scene/Compose/Earth") public static let mention = ImageAsset(name: "Scene/Compose/Mention") public static let more = ImageAsset(name: "Scene/Compose/More") diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift index 4f8ac71d5..6c905438c 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift @@ -43,6 +43,20 @@ extension Mastodon.API.V2.Media { request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment let serialStream = query.serialStream request.httpBodyStream = serialStream.boundStreams.input + + // total unit count in bytes count + // will small than actally count due to multipart protocol meta + serialStream.progress.totalUnitCount = { + var size = 0 + size += query.file?.sizeInByte ?? 0 + size += query.thumbnail?.sizeInByte ?? 0 + return Int64(size) + }() + query.progress.addChild( + serialStream.progress, + withPendingUnitCount: query.progress.totalUnitCount + ) + return session.dataTaskPublisher(for: request) .tryMap { data, response in let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) diff --git a/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift index f1fdac8bb..05639964e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift @@ -54,7 +54,7 @@ extension Mastodon.Query.MediaAttachment { return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() } } - var sizeInByte: Int? { + public var sizeInByte: Int? { switch self { case .jpeg(let data), .gif(let data), .png(let data): return data?.count diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift index f67745849..c3ca6fc67 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift @@ -10,12 +10,17 @@ import UIKit import SwiftUI import Introspect import AVKit +import MastodonAsset public struct AttachmentView: View { @ObservedObject var viewModel: AttachmentViewModel let action: (Action) -> Void + + var blurEffect: UIBlurEffect { + UIBlurEffect(style: .systemUltraThinMaterialDark) + } public var body: some View { ZStack { @@ -23,223 +28,81 @@ public struct AttachmentView: View { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) + + // loading… + if viewModel.output == nil, viewModel.error == nil { + ProgressView() + .progressViewStyle(.circular) + } + + // load failed + // cannot re-entry + if viewModel.output == nil, let error = viewModel.error { + VisualEffectView(effect: blurEffect) + VStack { + Text("Load Failed") // TODO: i18n + .font(.system(size: 13, weight: .semibold)) + Text(error.localizedDescription) + .font(.system(size: 12, weight: .regular)) + } + } + + // loaded + // uploading… or upload failed + // could retry upload when error emit + if viewModel.output != nil { + VisualEffectView(effect: blurEffect) + VStack { + let image: UIImage = { + if let _ = viewModel.error { + return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate) + } else { + return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate) + } + }() + Image(uiImage: image) + .foregroundColor(.white) + .padding() + .background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color)) + .clipShape(Circle()) + .padding() + let title: String = { + if let _ = viewModel.error { + return "Upload Failed" // TODO: i18n + } else { + let total = ByteCountFormatter.string(fromByteCount: Int64(viewModel.outputSizeInByte), countStyle: .memory) + return "…/\(total)" + } + }() + let subtitle: String = { + if let error = viewModel.error { + return error.localizedDescription + } else { + return "… remaining" + } + }() + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal) + Text(subtitle) + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.white) + .padding(.horizontal) + } + } + } // end ZStack + .onChange(of: viewModel.progress) { progress in + // not works… + print(progress.completedUnitCount) } -// Menu { -// menu -// } label: { -// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) -// Image(uiImage: image) -// .resizable() -// .aspectRatio(contentMode: .fill) -// .frame(width: AttachmentView.size.width, height: AttachmentView.size.height) -// .overlay { -// ZStack { -// // spinner -// if viewModel.output == nil { -// Color.clear -// .background(.ultraThinMaterial) -// ProgressView() -// .progressViewStyle(CircularProgressViewStyle()) -// .foregroundStyle(.regularMaterial) -// } -// // border -// RoundedRectangle(cornerRadius: AttachmentView.cornerRadius) -// .stroke(Color.black.opacity(0.05)) -// } -// .transition(.opacity) -// } -// .overlay(alignment: .bottom) { -// HStack(alignment: .bottom) { -// // alt -// VStack(spacing: 2) { -// switch viewModel.output { -// case .video: -// Image(uiImage: Asset.Media.playerRectangle.image) -// .resizable() -// .frame(width: 16, height: 12) -// default: -// EmptyView() -// } -// if !viewModel.caption.isEmpty { -// Image(uiImage: Asset.Media.altRectangle.image) -// .resizable() -// .frame(width: 16, height: 12) -// } -// } -// Spacer() -// // option -// Image(systemName: "ellipsis") -// .resizable() -// .frame(width: 12, height: 12) -// .symbolVariant(.circle) -// .symbolVariant(.fill) -// .symbolRenderingMode(.palette) -// .foregroundStyle(.white, .black) -// } -// .padding(6) -// } -// .cornerRadius(AttachmentView.cornerRadius) -// } // end Menu -// .sheet(isPresented: $isCaptionEditorPresented) { -// captionSheet -// } // end caption sheet -// .sheet(isPresented: $viewModel.isPreviewPresented) { -// previewSheet -// } // end preview sheet - } // end body -// var menu: some View { -// Group { -// Button( -// action: { -// action(.preview) -// }, -// label: { -// Label(L10n.Scene.Compose.Media.preview, systemImage: "photo") -// } -// ) -// // caption -// let canAddCaption: Bool = { -// switch viewModel.output { -// case .image: return true -// case .video: return false -// case .none: return false -// } -// }() -// if canAddCaption { -// Button( -// action: { -// action(.caption) -// caption = viewModel.caption -// isCaptionEditorPresented.toggle() -// }, -// label: { -// let title = viewModel.caption.isEmpty ? L10n.Scene.Compose.Media.Caption.add : L10n.Scene.Compose.Media.Caption.update -// Label(title, systemImage: "text.bubble") -// // FIXME: https://stackoverflow.com/questions/72318730/how-to-customize-swiftui-menu -// // add caption subtitle -// } -// ) -// } -// Divider() -// // remove -// Button( -// role: .destructive, -// action: { -// action(.remove) -// }, -// label: { -// Label(L10n.Scene.Compose.Media.remove, systemImage: "minus.circle") -// } -// ) -// } -// } - -// var captionSheet: some View { -// NavigationView { -// ScrollView(.vertical) { -// VStack { -// // preview -// switch viewModel.output { -// case .image: -// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) -// Image(uiImage: image) -// .resizable() -// .aspectRatio(contentMode: .fill) -// case .video(let url, _): -// let player = AVPlayer(url: url) -// VideoPlayer(player: player) -// .frame(height: 300) -// case .none: -// EmptyView() -// } -// // caption textField -// TextField( -// text: $caption, -// prompt: Text(L10n.Scene.Compose.Media.Caption.addADescriptionForThisImage) -// ) { -// Text(L10n.Scene.Compose.Media.Caption.update) -// } -// .padding() -// .introspectTextField { textField in -// textField.becomeFirstResponder() -// } -// } -// } -// .navigationTitle(L10n.Scene.Compose.Media.Caption.update) -// .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem(placement: .navigationBarLeading) { -// Button { -// isCaptionEditorPresented.toggle() -// } label: { -// Image(systemName: "xmark.circle.fill") -// .resizable() -// .frame(width: 30, height: 30, alignment: .center) -// .symbolRenderingMode(.hierarchical) -// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel)) -// } -// } -// ToolbarItem(placement: .navigationBarTrailing) { -// Button { -// viewModel.caption = caption.trimmingCharacters(in: .whitespacesAndNewlines) -// isCaptionEditorPresented.toggle() -// } label: { -// Text(L10n.Common.Controls.Actions.save) -// } -// } -// } -// } // end NavigationView -// } - - // design for share extension - // preferred UIKit preview in app -// var previewSheet: some View { -// NavigationView { -// ScrollView(.vertical) { -// VStack { -// // preview -// switch viewModel.output { -// case .image: -// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) -// Image(uiImage: image) -// .resizable() -// .aspectRatio(contentMode: .fill) -// case .video(let url, _): -// let player = AVPlayer(url: url) -// VideoPlayer(player: player) -// .frame(height: 300) -// case .none: -// EmptyView() -// } -// Spacer() -// } -// } -// .navigationTitle(L10n.Scene.Compose.Media.preview) -// .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem(placement: .navigationBarLeading) { -// Button { -// viewModel.isPreviewPresented.toggle() -// } label: { -// Image(systemName: "xmark.circle.fill") -// .resizable() -// .frame(width: 30, height: 30, alignment: .center) -// .symbolRenderingMode(.hierarchical) -// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel)) -// } -// } -// } -// } // end NavigationView -// } - } extension AttachmentView { public enum Action: Hashable { - case preview - case caption case remove + case retry } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift new file mode 100644 index 000000000..269b836bc --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift @@ -0,0 +1,144 @@ +// +// AttachmentViewModel+DragAndDrop.swift +// +// +// Created by MainasuK on 2022/11/8. +// + +import os.log +import UIKit +import Combine +import UniformTypeIdentifiers + +// MARK: - TypeIdentifiedItemProvider +extension AttachmentViewModel: TypeIdentifiedItemProvider { + public static var typeIdentifier: String { + // must in UTI format + // https://developer.apple.com/library/archive/qa/qa1796/_index.html + return "org.joinmastodon.app.AttachmentViewModel" + } +} + +// MARK: - NSItemProviderWriting +extension AttachmentViewModel: NSItemProviderWriting { + + + /// Attachment uniform type idendifiers + /// + /// The latest one for in-app drag and drop. + /// And use generic `image` and `movie` type to + /// allows transformable media in different formats + public static var writableTypeIdentifiersForItemProvider: [String] { + return [ + UTType.image.identifier, + UTType.movie.identifier, + AttachmentViewModel.typeIdentifier, + ] + } + + public var writableTypeIdentifiersForItemProvider: [String] { + // should append elements in priority order from high to low + var typeIdentifiers: [String] = [] + + // FIXME: check jpg or png + switch input { + case .image: + typeIdentifiers.append(UTType.png.identifier) + case .url(let url): + let _uti = UTType(filenameExtension: url.pathExtension) + if let uti = _uti { + if uti.conforms(to: .image) { + typeIdentifiers.append(UTType.png.identifier) + } else if uti.conforms(to: .movie) { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + } + case .pickerResult(let item): + if item.itemProvider.isImage() { + typeIdentifiers.append(UTType.png.identifier) + } else if item.itemProvider.isMovie() { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + case .itemProvider(let itemProvider): + if itemProvider.isImage() { + typeIdentifiers.append(UTType.png.identifier) + } else if itemProvider.isMovie() { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + } + + typeIdentifiers.append(AttachmentViewModel.typeIdentifier) + + return typeIdentifiers + } + + public func loadData( + withTypeIdentifier typeIdentifier: String, + forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void + ) -> Progress? { + switch typeIdentifier { + case AttachmentViewModel.typeIdentifier: + do { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + let data = archiver.encodedData + completionHandler(data, nil) + } catch { + assertionFailure() + completionHandler(nil, nil) + } + default: + break + } + + let loadingProgress = Progress(totalUnitCount: 100) + + Publishers.CombineLatest( + $output, + $error + ) + .sink { [weak self] output, error in + guard let self = self else { return } + + // continue when load completed + guard output != nil || error != nil else { return } + + switch output { + case .image(let data, _): + switch typeIdentifier { + case UTType.png.identifier: + loadingProgress.completedUnitCount = 100 + completionHandler(data, nil) + default: + completionHandler(nil, nil) + } + case .video(let url, _): + switch typeIdentifier { + case UTType.png.identifier: + let _image = AttachmentViewModel.createThumbnailForVideo(url: url) + let _data = _image?.pngData() + loadingProgress.completedUnitCount = 100 + completionHandler(_data, nil) + case UTType.mpeg4Movie.identifier: + let task = URLSession.shared.dataTask(with: url) { data, response, error in + completionHandler(data, error) + } + task.progress.observe(\.fractionCompleted) { progress, change in + loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted) + } + .store(in: &self.observations) + task.resume() + default: + completionHandler(nil, nil) + } + case nil: + completionHandler(nil, error) + } + } + .store(in: &disposeBag) + + return loadingProgress + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift new file mode 100644 index 000000000..a259485f1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift @@ -0,0 +1,148 @@ +// +// AttachmentViewModel+Load.swift +// +// +// Created by MainasuK on 2022/11/8. +// + +import os.log +import UIKit +import AVKit +import UniformTypeIdentifiers + +extension AttachmentViewModel { + + @MainActor + func load(input: Input) async throws -> Output { + switch input { + case .image(let image): + guard let data = image.pngData() else { + throw AttachmentError.invalidAttachmentType + } + return .image(data, imageKind: .png) + case .url(let url): + do { + let output = try await AttachmentViewModel.load(url: url) + return output + } catch { + throw error + } + case .pickerResult(let pickerResult): + do { + let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider) + return output + } catch { + throw error + } + case .itemProvider(let itemProvider): + do { + let output = try await AttachmentViewModel.load(itemProvider: itemProvider) + return output + } catch { + throw error + } + } + } + + private static func load(url: URL) async throws -> Output { + guard let uti = UTType(filenameExtension: url.pathExtension) else { + throw AttachmentError.invalidAttachmentType + } + + if uti.conforms(to: .image) { + guard url.startAccessingSecurityScopedResource() else { + throw AttachmentError.invalidAttachmentType + } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg) + } else if uti.conforms(to: .movie) { + guard url.startAccessingSecurityScopedResource() else { + throw AttachmentError.invalidAttachmentType + } + defer { url.stopAccessingSecurityScopedResource() } + + let fileName = UUID().uuidString + let tempDirectoryURL = FileManager.default.temporaryDirectory + let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension) + try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.copyItem(at: url, to: fileURL) + return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4") + } else { + throw AttachmentError.invalidAttachmentType + } + } + + private static func load(itemProvider: NSItemProvider) async throws -> Output { + if itemProvider.isImage() { + guard let result = try await itemProvider.loadImageData() else { + throw AttachmentError.invalidAttachmentType + } + let imageKind: Output.ImageKind = { + if let type = result.type { + if type == UTType.png { + return .png + } + if type == UTType.jpeg { + return .jpg + } + } + + let imageData = result.data + + if imageData.kf.imageFormat == .PNG { + return .png + } + if imageData.kf.imageFormat == .JPEG { + return .jpg + } + + assertionFailure("unknown image kind") + return .jpg + }() + return .image(result.data, imageKind: imageKind) + } else if itemProvider.isMovie() { + guard let result = try await itemProvider.loadVideoData() else { + throw AttachmentError.invalidAttachmentType + } + return .video(result.url, mimeType: "video/mp4") + } else { + assertionFailure() + throw AttachmentError.invalidAttachmentType + } + } + +} + +extension AttachmentViewModel { + static func createThumbnailForVideo(url: URL) -> UIImage? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let asset = AVURLAsset(url: url) + let assetImageGenerator = AVAssetImageGenerator(asset: asset) + assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation + do { + let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) + let image = UIImage(cgImage: cgImage) + return image + } catch { + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)") + return nil + } + } +} + +extension NSItemProvider { + func isImage() -> Bool { + return hasRepresentationConforming( + toTypeIdentifier: UTType.image.identifier, + fileOptions: [] + ) + } + + func isMovie() -> Bool { + return hasRepresentationConforming( + toTypeIdentifier: UTType.movie.identifier, + fileOptions: [] + ) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift index 0a4aadec3..fcb30d954 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift @@ -52,153 +52,25 @@ extension Data { } } -// Twitter Only -//extension AttachmentViewModel { -// class SliceResult { -// -// let fileURL: URL -// let chunks: Chunked -// let chunkCount: Int -// let type: UTType -// let sizeInBytes: UInt64 -// -// public init?( -// url: URL, -// type: UTType -// ) { -// guard let chunks = try? FileHandle(forReadingFrom: url).bytes.chunked else { return nil } -// let _sizeInBytes: UInt64? = { -// let attribute = try? FileManager.default.attributesOfItem(atPath: url.path) -// return attribute?[.size] as? UInt64 -// }() -// guard let sizeInBytes = _sizeInBytes else { return nil } -// -// self.fileURL = url -// self.chunks = chunks -// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes) -// self.type = type -// self.sizeInBytes = sizeInBytes -// } -// -// public init?( -// imageData: Data, -// type: UTType -// ) { -// let _fileURL = try? FileManager.default.createTemporaryFileURL( -// filename: UUID().uuidString, -// pathExtension: imageData.kf.imageFormat == .PNG ? "png" : "jpeg" -// ) -// guard let fileURL = _fileURL else { return nil } -// -// do { -// try imageData.write(to: fileURL) -// } catch { -// return nil -// } -// -// guard let chunks = try? FileHandle(forReadingFrom: fileURL).bytes.chunked else { -// return nil -// } -// let sizeInBytes = UInt64(imageData.count) -// -// self.fileURL = fileURL -// self.chunks = chunks -// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes) -// self.type = type -// self.sizeInBytes = sizeInBytes -// } -// -// static func chunkCount(chunkSize: UInt64, sizeInBytes: UInt64) -> Int { -// guard sizeInBytes > 0 else { return 0 } -// let count = sizeInBytes / chunkSize -// let remains = sizeInBytes % chunkSize -// let result = remains > 0 ? count + 1 : count -// return Int(result) -// } -// -// } -// -// static func slice(output: Output, sizeLimit: SizeLimit) -> SliceResult? { -// // needs execute in background -// assert(!Thread.isMainThread) -// -// // try png then use JPEG compress with Q=0.8 -// // then slice into 1MiB chunks -// switch output { -// case .image(let data, _): -// let maxPayloadSizeInBytes = sizeLimit.image -// -// // use processed imageData to remove EXIF -// guard let image = UIImage(data: data), -// var imageData = image.pngData() -// else { return nil } -// -// var didRemoveEXIF = false -// repeat { -// guard let image = KFCrossPlatformImage(data: imageData) else { return nil } -// if imageData.kf.imageFormat == .PNG { -// // A. png image -// guard let pngData = image.pngData() else { return nil } -// didRemoveEXIF = true -// if pngData.count > maxPayloadSizeInBytes { -// guard let compressedJpegData = image.jpegData(compressionQuality: 0.8) else { return nil } -// os_log("%{public}s[%{public}ld], %{public}s: compress png %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024) -// imageData = compressedJpegData -// } else { -// os_log("%{public}s[%{public}ld], %{public}s: png %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(pngData.count) / 1024 / 1024) -// imageData = pngData -// } -// } else { -// // B. other image -// if !didRemoveEXIF { -// guard let jpegData = image.jpegData(compressionQuality: 0.8) else { return nil } -// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(jpegData.count) / 1024 / 1024) -// imageData = jpegData -// didRemoveEXIF = true -// } else { -// let targetSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8) -// let scaledImage = image.af.imageScaled(to: targetSize) -// guard let compressedJpegData = scaledImage.jpegData(compressionQuality: 0.8) else { return nil } -// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024) -// imageData = compressedJpegData -// } -// } -// } while (imageData.count > maxPayloadSizeInBytes) -// -// return SliceResult( -// imageData: imageData, -// type: imageData.kf.imageFormat == .PNG ? UTType.png : UTType.jpeg -// ) -// -//// case .gif(let url): -//// fatalError() -// case .video(let url, _): -// return SliceResult( -// url: url, -// type: .movie -// ) -// } -// } -//} - extension AttachmentViewModel { struct UploadContext { let apiService: APIService let authContext: AuthContext } - enum UploadResult { - case mastodon(Mastodon.Response.Content) - } + public typealias UploadResult = Mastodon.Entity.Attachment } extension AttachmentViewModel { - func upload(context: UploadContext) async throws -> UploadResult { + func upload(context: UploadContext) async throws -> UploadResult { return try await uploadMastodonMedia( context: context ) } + + // MainActor is required here to trigger stream upload task + @MainActor private func uploadMastodonMedia( context: UploadContext ) async throws -> UploadResult { @@ -283,7 +155,7 @@ extension AttachmentViewModel { // escape here progress.completedUnitCount = progress.totalUnitCount - return .mastodon(attachmentStatusResponse) + return attachmentStatusResponse.value } else { AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)") @@ -296,7 +168,7 @@ extension AttachmentViewModel { } else { AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success: \(attachmentUploadResponse.value.url ?? "")") - return .mastodon(attachmentUploadResponse) + return attachmentUploadResponse.value } } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift index f2c7e76e7..20e8186ad 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift @@ -15,6 +15,7 @@ import MastodonCore final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable { static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel") + let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel") public let id = UUID() @@ -22,32 +23,52 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable var observations = Set() // input + public let api: APIService public let authContext: AuthContext public let input: Input @Published var caption = "" @Published var sizeLimit = SizeLimit() - @Published public var isPreviewPresented = false // output @Published public private(set) var output: Output? @Published public private(set) var thumbnail: UIImage? // original size image thumbnail + @Published public private(set) var outputSizeInByte: Int = 0 + + @Published public var uploadResult: UploadResult? @Published var error: Error? + let progress = Progress() // upload progress public init( + api: APIService, authContext: AuthContext, input: Input ) { + self.api = api self.authContext = authContext self.input = input super.init() // end init - - defer { - Task { - await load(input: input) + + progress + .observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)") + DispatchQueue.main.async { + self.objectWillChange.send() + } } - } + .store(in: &observations) + + progress + .observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)") + DispatchQueue.main.async { + self.objectWillChange.send() + } + } + .store(in: &observations) $output .map { output -> UIImage? in @@ -62,6 +83,23 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable } .receive(on: DispatchQueue.main) .assign(to: &$thumbnail) + + defer { + Task { @MainActor in + do { + let output = try await load(input: input) + self.output = output + self.outputSizeInByte = output.asAttachment.sizeInByte ?? 0 + let uploadResult = try await self.upload(context: .init( + apiService: self.api, + authContext: self.authContext + )) + self.uploadResult = uploadResult + } catch { + self.error = error + } + } // end Task + } } deinit { @@ -112,280 +150,23 @@ extension AttachmentViewModel { } } - public enum AttachmentError: Error { + public enum AttachmentError: Error, LocalizedError { case invalidAttachmentType case attachmentTooLarge - } - -} - -extension AttachmentViewModel { - - @MainActor - private func load(input: Input) async { - switch input { - case .image(let image): - guard let data = image.pngData() else { - error = AttachmentError.invalidAttachmentType - return - } - output = .image(data, imageKind: .png) - case .url(let url): - do { - let output = try await AttachmentViewModel.load(url: url) - self.output = output - } catch { - self.error = error - } - case .pickerResult(let pickerResult): - do { - let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider) - self.output = output - } catch { - self.error = error - } - case .itemProvider(let itemProvider): - do { - let output = try await AttachmentViewModel.load(itemProvider: itemProvider) - self.output = output - } catch { - self.error = error - } - } - } - - private static func load(url: URL) async throws -> Output { - guard let uti = UTType(filenameExtension: url.pathExtension) else { - throw AttachmentError.invalidAttachmentType - } - if uti.conforms(to: .image) { - guard url.startAccessingSecurityScopedResource() else { - throw AttachmentError.invalidAttachmentType + public var errorDescription: String? { + switch self { + case .invalidAttachmentType: + return "Can not regonize this media attachment" // TODO: i18n + case .attachmentTooLarge: + return "Attachment too large" } - defer { url.stopAccessingSecurityScopedResource() } - let imageData = try Data(contentsOf: url) - return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg) - } else if uti.conforms(to: .movie) { - guard url.startAccessingSecurityScopedResource() else { - throw AttachmentError.invalidAttachmentType - } - defer { url.stopAccessingSecurityScopedResource() } - - let fileName = UUID().uuidString - let tempDirectoryURL = FileManager.default.temporaryDirectory - let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension) - try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil) - try FileManager.default.copyItem(at: url, to: fileURL) - return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4") - } else { - throw AttachmentError.invalidAttachmentType - } - } - - private static func load(itemProvider: NSItemProvider) async throws -> Output { - if itemProvider.isImage() { - guard let result = try await itemProvider.loadImageData() else { - throw AttachmentError.invalidAttachmentType - } - let imageKind: Output.ImageKind = { - if let type = result.type { - if type == UTType.png { - return .png - } - if type == UTType.jpeg { - return .jpg - } - } - - let imageData = result.data - - if imageData.kf.imageFormat == .PNG { - return .png - } - if imageData.kf.imageFormat == .JPEG { - return .jpg - } - - assertionFailure("unknown image kind") - return .jpg - }() - return .image(result.data, imageKind: imageKind) - } else if itemProvider.isMovie() { - guard let result = try await itemProvider.loadVideoData() else { - throw AttachmentError.invalidAttachmentType - } - return .video(result.url, mimeType: "video/mp4") - } else { - assertionFailure() - throw AttachmentError.invalidAttachmentType } } } -extension AttachmentViewModel { - static func createThumbnailForVideo(url: URL) -> UIImage? { - guard FileManager.default.fileExists(atPath: url.path) else { return nil } - let asset = AVURLAsset(url: url) - let assetImageGenerator = AVAssetImageGenerator(asset: asset) - assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation - do { - let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) - let image = UIImage(cgImage: cgImage) - return image - } catch { - AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)") - return nil - } - } -} -// MARK: - TypeIdentifiedItemProvider -extension AttachmentViewModel: TypeIdentifiedItemProvider { - public static var typeIdentifier: String { - // must in UTI format - // https://developer.apple.com/library/archive/qa/qa1796/_index.html - return "com.twidere.AttachmentViewModel" - } -} -// MARK: - NSItemProviderWriting -extension AttachmentViewModel: NSItemProviderWriting { - - - /// Attachment uniform type idendifiers - /// - /// The latest one for in-app drag and drop. - /// And use generic `image` and `movie` type to - /// allows transformable media in different formats - public static var writableTypeIdentifiersForItemProvider: [String] { - return [ - UTType.image.identifier, - UTType.movie.identifier, - AttachmentViewModel.typeIdentifier, - ] - } - - public var writableTypeIdentifiersForItemProvider: [String] { - // should append elements in priority order from high to low - var typeIdentifiers: [String] = [] - - // FIXME: check jpg or png - switch input { - case .image: - typeIdentifiers.append(UTType.png.identifier) - case .url(let url): - let _uti = UTType(filenameExtension: url.pathExtension) - if let uti = _uti { - if uti.conforms(to: .image) { - typeIdentifiers.append(UTType.png.identifier) - } else if uti.conforms(to: .movie) { - typeIdentifiers.append(UTType.mpeg4Movie.identifier) - } - } - case .pickerResult(let item): - if item.itemProvider.isImage() { - typeIdentifiers.append(UTType.png.identifier) - } else if item.itemProvider.isMovie() { - typeIdentifiers.append(UTType.mpeg4Movie.identifier) - } - case .itemProvider(let itemProvider): - if itemProvider.isImage() { - typeIdentifiers.append(UTType.png.identifier) - } else if itemProvider.isMovie() { - typeIdentifiers.append(UTType.mpeg4Movie.identifier) - } - } - - typeIdentifiers.append(AttachmentViewModel.typeIdentifier) - - return typeIdentifiers - } - - public func loadData( - withTypeIdentifier typeIdentifier: String, - forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void - ) -> Progress? { - switch typeIdentifier { - case AttachmentViewModel.typeIdentifier: - do { - let archiver = NSKeyedArchiver(requiringSecureCoding: false) - try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey) - archiver.finishEncoding() - let data = archiver.encodedData - completionHandler(data, nil) - } catch { - assertionFailure() - completionHandler(nil, nil) - } - default: - break - } - - let loadingProgress = Progress(totalUnitCount: 100) - - Publishers.CombineLatest( - $output, - $error - ) - .sink { [weak self] output, error in - guard let self = self else { return } - - // continue when load completed - guard output != nil || error != nil else { return } - - switch output { - case .image(let data, _): - switch typeIdentifier { - case UTType.png.identifier: - loadingProgress.completedUnitCount = 100 - completionHandler(data, nil) - default: - completionHandler(nil, nil) - } - case .video(let url, _): - switch typeIdentifier { - case UTType.png.identifier: - let _image = AttachmentViewModel.createThumbnailForVideo(url: url) - let _data = _image?.pngData() - loadingProgress.completedUnitCount = 100 - completionHandler(_data, nil) - case UTType.mpeg4Movie.identifier: - let task = URLSession.shared.dataTask(with: url) { data, response, error in - completionHandler(data, error) - } - task.progress.observe(\.fractionCompleted) { progress, change in - loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted) - } - .store(in: &self.observations) - task.resume() - default: - completionHandler(nil, nil) - } - case nil: - completionHandler(nil, error) - } - } - .store(in: &disposeBag) - - return loadingProgress - } - -} -extension NSItemProvider { - fileprivate func isImage() -> Bool { - return hasRepresentationConforming( - toTypeIdentifier: UTType.image.identifier, - fileOptions: [] - ) - } - - fileprivate func isMovie() -> Bool { - return hasRepresentationConforming( - toTypeIdentifier: UTType.movie.identifier, - fileOptions: [] - ) - } -} + diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index 38efe8feb..b9fbab856 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -326,7 +326,11 @@ extension ComposeContentViewController: PHPickerViewControllerDelegate { picker.dismiss(animated: true, completion: nil) let attachmentViewModels: [AttachmentViewModel] = results.map { result in - AttachmentViewModel(authContext: viewModel.authContext, input: .pickerResult(result)) + AttachmentViewModel( + api: viewModel.context.apiService, + authContext: viewModel.authContext, + input: .pickerResult(result) + ) } viewModel.attachmentViewModels += attachmentViewModels } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift index ea3be18a8..31568552c 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift @@ -119,13 +119,16 @@ extension MastodonStatusPublisher: StatusPublisher { progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight) // upload media do { - let result = try await attachmentViewModel.upload(context: uploadContext) - guard case let .mastodon(response) = result else { - assertionFailure() - continue + guard let attachment = attachmentViewModel.uploadResult else { + // precondition: all media uploaded + throw AppError.badRequest } - let attachmentID = response.value.id - attachmentIDs.append(attachmentID) + attachmentIDs.append(attachment.id) + + // TODO: allow background upload + // let attachment = try await attachmentViewModel.upload(context: uploadContext) + // let attachmentID = attachment.id + // attachmentIDs.append(attachmentID) } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)") _state = .failure(error) diff --git a/MastodonSDK/Sources/MastodonUI/Vendor/VisualEffectView.swift b/MastodonSDK/Sources/MastodonUI/Vendor/VisualEffectView.swift new file mode 100644 index 000000000..fe89b0457 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Vendor/VisualEffectView.swift @@ -0,0 +1,15 @@ +// +// VisualEffectView.swift +// +// +// Created by MainasuK on 2022/11/8. +// + +import SwiftUI + +// ref: https://stackoverflow.com/a/59111492/3797903 +public struct VisualEffectView: UIViewRepresentable { + public var effect: UIVisualEffect? + public func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } + public func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } +}