Composer / Share sheet: add video upload support close #154

This commit is contained in:
Thomas Ricouard 2023-01-22 09:09:35 +01:00
parent fd28864063
commit eec5637c1c
10 changed files with 264 additions and 50 deletions

View file

@ -12,6 +12,8 @@
<integer>4</integer> <integer>4</integer>
<key>NSExtensionActivationSupportsText</key> <key>NSExtensionActivationSupportsText</key>
<true/> <true/>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key> <key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer> <integer>1</integer>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key> <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>

View file

@ -1,5 +1,5 @@
import Foundation import Foundation
public struct ServerError: Decodable { public struct ServerError: Decodable, Error {
public let error: String? public let error: String?
} }

View file

@ -144,7 +144,14 @@ public class Client: ObservableObject, Equatable {
let request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method) let request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method)
let (data, httpResponse) = try await urlSession.data(for: request) let (data, httpResponse) = try await urlSession.data(for: request)
logResponseOnError(httpResponse: httpResponse, data: data) logResponseOnError(httpResponse: httpResponse, data: data)
do {
return try decoder.decode(Entity.self, from: data) return try decoder.decode(Entity.self, from: data)
} catch let error {
if let serverError = try? decoder.decode(ServerError.self, from: data) {
throw serverError
}
throw error
}
} }
public func oauthURL() async throws -> URL { public func oauthURL() async throws -> URL {

View file

@ -23,7 +23,7 @@ struct StatusEditorAccessoryView: View {
Divider() Divider()
HStack(alignment: .center, spacing: 16) { HStack(alignment: .center, spacing: 16) {
PhotosPicker(selection: $viewModel.selectedMedias, PhotosPicker(selection: $viewModel.selectedMedias,
matching: .images) { matching: .any(of: [.images, .videos])) {
Image(systemName: "photo.fill.on.rectangle.fill") Image(systemName: "photo.fill.on.rectangle.fill")
} }
.disabled(viewModel.showPoll) .disabled(viewModel.showPoll)

View file

@ -0,0 +1,13 @@
import Foundation
import UIKit
import Models
import PhotosUI
import SwiftUI
struct StatusEditorMediaContainer: Identifiable {
let id = UUID().uuidString
let image: UIImage?
let movieTransferable: MovieFileTranseferable?
let mediaAttachment: MediaAttachment?
let error: Error?
}

View file

@ -7,7 +7,7 @@ struct StatusEditorMediaEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ObservedObject var viewModel: StatusEditorViewModel @ObservedObject var viewModel: StatusEditorViewModel
let container: StatusEditorViewModel.ImageContainer let container: StatusEditorMediaContainer
@State private var imageDescription: String = "" @State private var imageDescription: String = ""

View file

@ -3,11 +3,12 @@ import Env
import Models import Models
import NukeUI import NukeUI
import SwiftUI import SwiftUI
import AVKit
struct StatusEditorMediaView: View { struct StatusEditorMediaView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ObservedObject var viewModel: StatusEditorViewModel @ObservedObject var viewModel: StatusEditorViewModel
@State private var editingContainer: StatusEditorViewModel.ImageContainer? @State private var editingContainer: StatusEditorMediaContainer?
var body: some View { var body: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@ -17,10 +18,12 @@ struct StatusEditorMediaView: View {
makeImageMenu(container: container) makeImageMenu(container: container)
} label: { } label: {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
if container.image != nil { if let attachement = container.mediaAttachment {
makeLazyImage(mediaAttachement: attachement)
} else if container.image != nil {
makeLocalImage(container: container) makeLocalImage(container: container)
} else if let url = container.mediaAttachment?.url ?? container.mediaAttachment?.previewUrl { } else if container.movieTransferable != nil {
makeLazyImage(url: url) makeVideoAttachement(container: container)
} }
if container.mediaAttachment?.description?.isEmpty == false { if container.mediaAttachment?.description?.isEmpty == false {
altMarker altMarker
@ -37,7 +40,18 @@ struct StatusEditorMediaView: View {
} }
} }
private func makeLocalImage(container: StatusEditorViewModel.ImageContainer) -> some View { private func makeVideoAttachement(container: StatusEditorMediaContainer) -> some View {
ZStack(alignment: .center) {
placeholderView
if container.mediaAttachment == nil {
ProgressView()
}
}
.cornerRadius(8)
.frame(width: 150, height: 150)
}
private func makeLocalImage(container: StatusEditorMediaContainer) -> some View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
Image(uiImage: container.image!) Image(uiImage: container.image!)
.resizable() .resizable()
@ -75,15 +89,29 @@ struct StatusEditorMediaView: View {
} }
} }
private func makeLazyImage(url: URL?) -> some View { private func makeLazyImage(mediaAttachement: MediaAttachment) -> some View {
ZStack(alignment: .center) {
if let url = mediaAttachement.url ?? mediaAttachement.previewUrl {
LazyImage(url: url) { state in LazyImage(url: url) { state in
if let image = state.image { if let image = state.image {
image image
.resizingMode(.aspectFill) .resizingMode(.aspectFill)
.frame(width: 150, height: 150) .frame(width: 150, height: 150)
} else { } else {
Rectangle() placeholderView
.frame(width: 150, height: 150) }
}
} else {
placeholderView
}
if mediaAttachement.url == nil {
ProgressView()
}
if mediaAttachement.url != nil,
mediaAttachement.supportedType == .video || mediaAttachement.supportedType == .gifv {
Image(systemName: "play.fill")
.font(.headline)
.tint(.white)
} }
} }
.frame(width: 150, height: 150) .frame(width: 150, height: 150)
@ -91,7 +119,7 @@ struct StatusEditorMediaView: View {
} }
@ViewBuilder @ViewBuilder
private func makeImageMenu(container: StatusEditorViewModel.ImageContainer) -> some View { private func makeImageMenu(container: StatusEditorMediaContainer) -> some View {
if !viewModel.mode.isEditing { if !viewModel.mode.isEditing {
Button { Button {
editingContainer = container editingContainer = container
@ -119,4 +147,10 @@ struct StatusEditorMediaView: View {
.background(.thinMaterial) .background(.thinMaterial)
.cornerRadius(8) .cornerRadius(8)
} }
private var placeholderView: some View {
Rectangle()
.foregroundColor(theme.secondaryBackgroundColor)
.frame(width: 150, height: 150)
}
} }

View file

@ -1,6 +1,8 @@
import Foundation import Foundation
import UIKit import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import SwiftUI
import PhotosUI
@MainActor @MainActor
enum StatusEditorUTTypeSupported: String, CaseIterable { enum StatusEditorUTTypeSupported: String, CaseIterable {
@ -11,12 +13,29 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
case jpeg = "public.jpeg" case jpeg = "public.jpeg"
case png = "public.png" case png = "public.png"
case video = "public.video"
case movie = "public.movie"
case mp4 = "public.mpeg-4"
case gif = "public.gif"
static func types() -> [UTType] { static func types() -> [UTType] {
[.url, .text, .plainText, .image, .jpeg, .png] [.url, .text, .plainText, .image, .jpeg, .png, .video, .mpeg4Movie, .gif, .movie]
}
var isVideo: Bool {
switch self {
case .video, .movie, .mp4, .gif:
return true
default:
return false
}
} }
func loadItemContent(item: NSItemProvider) async throws -> Any? { func loadItemContent(item: NSItemProvider) async throws -> Any? {
let result = try await item.loadItem(forTypeIdentifier: rawValue) let result = try await item.loadItem(forTypeIdentifier: rawValue)
if isVideo, let transferable = await getVideoTransferable(item: item) {
return transferable
}
if self == .jpeg || self == .png, if self == .jpeg || self == .png,
let imageURL = result as? URL, let imageURL = result as? URL,
let data = try? Data(contentsOf: imageURL), let data = try? Data(contentsOf: imageURL),
@ -34,4 +53,59 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
return nil return nil
} }
} }
private func getVideoTransferable(item: NSItemProvider) async -> MovieFileTranseferable? {
return await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: MovieFileTranseferable.self) { result in
switch result {
case .success(let success):
continuation.resume(with: .success(success))
case .failure:
continuation.resume(with: .success(nil))
}
}
}
}
}
struct MovieFileTranseferable: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .movie) { movie in
SentTransferredFile(movie.url)
} importing: { received in
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
try FileManager.default.copyItem(at: received.file, to: copy)
return Self.init(url: copy)
}
}
}
struct ImageFileTranseferable: Transferable {
let url: URL
lazy var data: Data? = try? Data(contentsOf: url)
lazy var compressedData: Data? = image?.jpegData(compressionQuality: 0.90)
lazy var image: UIImage? = UIImage(data: data ?? Data())
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .image) { image in
SentTransferredFile(image.url)
} importing: { received in
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
try FileManager.default.copyItem(at: received.file, to: copy)
return Self.init(url: copy)
}
}
}
extension URL {
public func mimeType() -> String {
if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType {
return mimeType
} else {
return "application/octet-stream"
}
}
} }

View file

@ -91,6 +91,13 @@ public struct StatusEditorView: View {
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
.navigationTitle(viewModel.mode.title) .navigationTitle(viewModel.mode.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.alert("Error while posting",
isPresented: $viewModel.showPostingErrorAlert,
actions: {
Button("Ok") { }
}, message: {
Text(viewModel.postingError ?? "")
})
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
AIMenu AIMenu

View file

@ -7,13 +7,6 @@ import SwiftUI
@MainActor @MainActor
public class StatusEditorViewModel: ObservableObject { public class StatusEditorViewModel: ObservableObject {
struct ImageContainer: Identifiable {
let id = UUID().uuidString
let image: UIImage?
let mediaAttachment: MediaAttachment?
let error: Error?
}
var mode: Mode var mode: Mode
let generator = UINotificationFeedbackGenerator() let generator = UINotificationFeedbackGenerator()
@ -50,12 +43,15 @@ public class StatusEditorViewModel: ObservableObject {
} }
} }
@Published var mediasImages: [ImageContainer] = [] @Published var mediasImages: [StatusEditorMediaContainer] = []
@Published var replyToStatus: Status? @Published var replyToStatus: Status?
@Published var embeddedStatus: Status? @Published var embeddedStatus: Status?
@Published var customEmojis: [Emoji] = [] @Published var customEmojis: [Emoji] = []
@Published var postingError: String?
@Published var showPostingErrorAlert: Bool = false
var canPost: Bool { var canPost: Bool {
statusText.length > 0 || !mediasImages.isEmpty statusText.length > 0 || !mediasImages.isEmpty
} }
@ -119,7 +115,11 @@ public class StatusEditorViewModel: ObservableObject {
generator.notificationOccurred(.success) generator.notificationOccurred(.success)
isPosting = false isPosting = false
return postStatus return postStatus
} catch { } catch let error {
if let error = error as? Models.ServerError {
postingError = error.error
showPostingErrorAlert = true
}
isPosting = false isPosting = false
generator.notificationOccurred(.error) generator.notificationOccurred(.error)
return nil return nil
@ -185,7 +185,10 @@ public class StatusEditorViewModel: ObservableObject {
spoilerOn = !status.spoilerText.asRawText.isEmpty spoilerOn = !status.spoilerText.asRawText.isEmpty
spoilerText = status.spoilerText.asRawText spoilerText = status.spoilerText.asRawText
visibility = status.visibility visibility = status.visibility
mediasImages = status.mediaAttachments.map { .init(image: nil, mediaAttachment: $0, error: nil) } mediasImages = status.mediaAttachments.map { .init(image: nil,
movieTransferable: nil,
mediaAttachment: $0,
error: nil) }
case let .quote(status): case let .quote(status):
embeddedStatus = status embeddedStatus = status
if let url = embeddedStatusURL { if let url = embeddedStatusURL {
@ -247,7 +250,10 @@ public class StatusEditorViewModel: ObservableObject {
var mediaAdded = false var mediaAdded = false
statusText.enumerateAttribute(.attachment, in: range) { attachment, range, _ in statusText.enumerateAttribute(.attachment, in: range) { attachment, range, _ in
if let attachment = attachment as? NSTextAttachment, let image = attachment.image { if let attachment = attachment as? NSTextAttachment, let image = attachment.image {
mediasImages.append(.init(image: image, mediaAttachment: nil, error: nil)) mediasImages.append(.init(image: image,
movieTransferable: nil,
mediaAttachment: nil,
error: nil))
statusText.removeAttribute(.attachment, range: range) statusText.removeAttribute(.attachment, range: range)
statusText.mutableString.deleteCharacters(in: range) statusText.mutableString.deleteCharacters(in: range)
mediaAdded = true mediaAdded = true
@ -274,7 +280,15 @@ public class StatusEditorViewModel: ObservableObject {
if let text = content as? String { if let text = content as? String {
initialText += "\(text) " initialText += "\(text) "
} else if let image = content as? UIImage { } else if let image = content as? UIImage {
mediasImages.append(.init(image: image, mediaAttachment: nil, error: nil)) mediasImages.append(.init(image: image,
movieTransferable: nil,
mediaAttachment: nil,
error: nil))
} else if let video = content as? MovieFileTranseferable {
mediasImages.append(.init(image: nil,
movieTransferable: video,
mediaAttachment: nil,
error: nil))
} }
} catch {} } catch {}
} }
@ -380,7 +394,7 @@ public class StatusEditorViewModel: ObservableObject {
// MARK: - Media related function // MARK: - Media related function
private func indexOf(container: ImageContainer) -> Int? { private func indexOf(container: StatusEditorMediaContainer) -> Int? {
mediasImages.firstIndex(where: { $0.id == container.id }) mediasImages.firstIndex(where: { $0.id == container.id })
} }
@ -388,18 +402,35 @@ public class StatusEditorViewModel: ObservableObject {
mediasImages = [] mediasImages = []
Task { Task {
var medias: [ImageContainer] = [] var medias: [StatusEditorMediaContainer] = []
for media in selectedMedias { for media in selectedMedias {
var file: (any Transferable)?
do { do {
if let data = try await media.loadTransferable(type: Data.self), file = try await media.loadTransferable(type: ImageFileTranseferable.self)
let image = UIImage(data: data) if file == nil {
{ file = try await media.loadTransferable(type: MovieFileTranseferable.self)
medias.append(.init(image: image, mediaAttachment: nil, error: nil))
} }
} catch { } catch {
medias.append(.init(image: nil, mediaAttachment: nil, error: error)) medias.append(.init(image: nil,
movieTransferable: nil,
mediaAttachment: nil,
error: error))
}
if var imageFile = file as? ImageFileTranseferable,
let image = imageFile.image {
medias.append(.init(image: image,
movieTransferable: nil,
mediaAttachment: nil,
error: nil))
} else if let videoFile = file as? MovieFileTranseferable {
medias.append(.init(image: nil,
movieTransferable: videoFile,
mediaAttachment: nil,
error: nil))
} }
} }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.mediasImages = medias self?.mediasImages = medias
self?.processMediasToUpload() self?.processMediasToUpload()
@ -419,45 +450,91 @@ public class StatusEditorViewModel: ObservableObject {
} }
} }
func upload(container: ImageContainer) async { func upload(container: StatusEditorMediaContainer) async {
if let index = indexOf(container: container) { if let index = indexOf(container: container) {
let originalContainer = mediasImages[index] let originalContainer = mediasImages[index]
let newContainer = ImageContainer(image: originalContainer.image, mediaAttachment: nil, error: nil) let newContainer = StatusEditorMediaContainer(image: originalContainer.image,
movieTransferable: originalContainer.movieTransferable,
mediaAttachment: nil,
error: nil)
mediasImages[index] = newContainer mediasImages[index] = newContainer
do { do {
if let data = originalContainer.image?.jpegData(compressionQuality: 0.90) {
let uploadedMedia = try await uploadMedia(data: data)
if let index = indexOf(container: newContainer) { if let index = indexOf(container: newContainer) {
if let image = originalContainer.image,
let data = image.jpegData(compressionQuality: 0.90) {
let uploadedMedia = try await uploadMedia(data: data, mimeType: "image/jpeg")
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil, mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil,
movieTransferable: nil,
mediaAttachment: uploadedMedia, mediaAttachment: uploadedMedia,
error: nil) error: nil)
} else if let videoURL = originalContainer.movieTransferable?.url,
let data = try? Data(contentsOf: videoURL) {
let uploadedMedia = try await uploadMedia(data: data, mimeType: videoURL.mimeType())
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil,
movieTransferable: originalContainer.movieTransferable,
mediaAttachment: uploadedMedia,
error: nil)
if let uploadedMedia, uploadedMedia.url == nil {
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
}
} }
} }
} catch { } catch {
if let index = indexOf(container: newContainer) { if let index = indexOf(container: newContainer) {
mediasImages[index] = .init(image: originalContainer.image, mediaAttachment: nil, error: error) mediasImages[index] = .init(image: originalContainer.image,
movieTransferable: nil,
mediaAttachment: nil,
error: error)
} }
} }
} }
} }
func addDescription(container: ImageContainer, description: String) async { private func scheduleAsyncMediaRefresh(mediaAttachement: MediaAttachment) {
Task {
repeat {
if let client,
let index = mediasImages.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id }) {
guard mediasImages[index].mediaAttachment?.url == nil else {
return
}
do {
let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id,
description: nil))
if newAttachement.url != nil {
let oldContainer = mediasImages[index]
mediasImages[index] = .init(image: oldContainer.image,
movieTransferable: oldContainer.movieTransferable,
mediaAttachment: newAttachement,
error: nil)
}
} catch { }
}
try? await Task.sleep(for: .seconds(5))
} while (!Task.isCancelled)
}
}
func addDescription(container: StatusEditorMediaContainer, description: String) async {
guard let client, let attachment = container.mediaAttachment else { return } guard let client, let attachment = container.mediaAttachment else { return }
if let index = indexOf(container: container) { if let index = indexOf(container: container) {
do { do {
let media: MediaAttachment = try await client.put(endpoint: Media.media(id: attachment.id, let media: MediaAttachment = try await client.put(endpoint: Media.media(id: attachment.id,
description: description)) description: description))
mediasImages[index] = .init(image: nil, mediaAttachment: media, error: nil) mediasImages[index] = .init(image: nil,
movieTransferable: nil,
mediaAttachment: media,
error: nil)
} catch {} } catch {}
} }
} }
private func uploadMedia(data: Data) async throws -> MediaAttachment? { private func uploadMedia(data: Data, mimeType: String) async throws -> MediaAttachment? {
guard let client else { return nil } guard let client else { return nil }
return try await client.mediaUpload(endpoint: Media.medias, return try await client.mediaUpload(endpoint: Media.medias,
version: .v2, version: .v2,
method: "POST", method: "POST",
mimeType: "image/jpeg", mimeType: mimeType,
filename: "file", filename: "file",
data: data) data: data)
} }