Improve media selection on the status editor. (#1722)

* show menu buttons on media item

* fix media preparing logic
- not removing photo pickers when removing media on the post editor
- pickers don't have identifiers after being selected
- preparing tasks (creating containers, uploading media) don't run in parallel
- re-preparing the whole media list every time adding new ones

* remove measurement code

* rename variables

* fix MainActor mutation
This commit is contained in:
Thai D. V 2023-12-07 12:39:34 +07:00 committed by GitHub
parent 9fe5994bb2
commit 774ba834bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 252 additions and 165 deletions

View file

@ -57,9 +57,11 @@ struct StatusEditorAccessoryView: View {
} }
} }
.photosPicker(isPresented: $isPhotosPickerPresented, .photosPicker(isPresented: $isPhotosPickerPresented,
selection: $viewModel.selectedMedias, selection: $viewModel.mediaPickers,
maxSelectionCount: 4, maxSelectionCount: 4,
matching: .any(of: [.images, .videos])) matching: .any(of: [.images, .videos]),
photoLibrary: .shared()
)
.fileImporter(isPresented: $isFileImporterPresented, .fileImporter(isPresented: $isFileImporterPresented,
allowedContentTypes: [.image, .video], allowedContentTypes: [.image, .video],
allowsMultipleSelection: true) allowsMultipleSelection: true)

View file

@ -5,7 +5,7 @@ import SwiftUI
import UIKit import UIKit
struct StatusEditorMediaContainer: Identifiable { struct StatusEditorMediaContainer: Identifiable {
let id = UUID().uuidString let id: String
let image: UIImage? let image: UIImage?
let movieTransferable: MovieFileTranseferable? let movieTransferable: MovieFileTranseferable?
let gifTransferable: GifFileTranseferable? let gifTransferable: GifFileTranseferable?

View file

@ -17,11 +17,10 @@ struct StatusEditorMediaView: View {
var body: some View { var body: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(viewModel.mediasImages) { container in ForEach(viewModel.mediaContainers) { container in
Menu { Menu {
makeImageMenu(container: container) makeImageMenu(container: container)
} label: { } label: {
ZStack(alignment: .bottomTrailing) {
if let attachement = container.mediaAttachment { if let attachement = container.mediaAttachment {
makeLazyImage(mediaAttachement: attachement) makeLazyImage(mediaAttachement: attachement)
} else if container.image != nil { } else if container.image != nil {
@ -31,10 +30,12 @@ struct StatusEditorMediaView: View {
} else if let error = container.error as? ServerError { } else if let error = container.error as? ServerError {
makeErrorView(error: error) makeErrorView(error: error)
} }
if container.mediaAttachment?.description?.isEmpty == false {
altMarker
} }
.overlay(alignment: .bottomTrailing) {
makeAltMarker(container: container)
} }
.overlay(alignment: .topTrailing) {
makeDiscardMarker(container: container)
} }
} }
} }
@ -122,7 +123,13 @@ struct StatusEditorMediaView: View {
Button(role: .destructive) { Button(role: .destructive) {
withAnimation { withAnimation {
viewModel.mediasImages.removeAll(where: { $0.id == container.id }) viewModel.mediaPickers.removeAll(where: {
if let id = $0.itemIdentifier {
return id == container.id
}
return false
})
} }
} label: { } label: {
Label("action.delete", systemImage: "trash") Label("action.delete", systemImage: "trash")
@ -141,14 +148,37 @@ struct StatusEditorMediaView: View {
} }
} }
private var altMarker: some View { private func makeAltMarker(container: StatusEditorMediaContainer) -> some View {
Button {} label: { Button {
editingContainer = container
} label: {
Text("status.image.alt-text.abbreviation") Text("status.image.alt-text.abbreviation")
.font(.caption2) .font(.caption2)
} }
.padding(4) .padding(4)
.background(.thinMaterial) .background(.thinMaterial)
.cornerRadius(8) .cornerRadius(8)
.padding(4)
}
private func makeDiscardMarker(container: StatusEditorMediaContainer) -> some View {
Button(role: .destructive) {
withAnimation {
viewModel.mediaPickers.removeAll(where: {
if let id = $0.itemIdentifier {
return id == container.id
}
return false
})
}
} label: {
Image(systemName: "xmark")
.font(.caption2)
.foregroundStyle(.tint)
.padding(4)
.background(Circle().fill(.thinMaterial))
}
.padding(4)
} }
private var placeholderView: some View { private var placeholderView: some View {

View file

@ -15,7 +15,7 @@ import SwiftUI
var currentAccount: Account? { var currentAccount: Account? {
didSet { didSet {
if let itemsProvider { if let itemsProvider {
mediasImages = [] mediaContainers = []
processItemsProvider(items: itemsProvider) processItemsProvider(items: itemsProvider)
} }
} }
@ -84,19 +84,30 @@ import SwiftUI
var spoilerText: String = "" var spoilerText: String = ""
var isPosting: Bool = false var isPosting: Bool = false
var selectedMedias: [PhotosPickerItem] = [] { var mediaPickers: [PhotosPickerItem] = [] {
didSet { didSet {
if selectedMedias.count > 4 { if mediaPickers.count > 4 {
selectedMedias = selectedMedias.prefix(4).map { $0 } mediaPickers = mediaPickers.prefix(4).map { $0 }
} }
let removedIDs = oldValue
.filter { !mediaPickers.contains($0) }
.compactMap { $0.itemIdentifier }
mediaContainers.removeAll { removedIDs.contains($0.id) }
let newPickerItems = mediaPickers.filter { !oldValue.contains($0) }
if !newPickerItems.isEmpty {
isMediasLoading = true isMediasLoading = true
inflateSelectedMedias() for item in newPickerItems {
prepareToPost(for: item)
}
}
} }
} }
var isMediasLoading: Bool = false var isMediasLoading: Bool = false
var mediasImages: [StatusEditorMediaContainer] = [] private(set) var mediaContainers: [StatusEditorMediaContainer] = []
var replyToStatus: Status? var replyToStatus: Status?
var embeddedStatus: Status? var embeddedStatus: Status?
@ -106,11 +117,11 @@ import SwiftUI
var showPostingErrorAlert: Bool = false var showPostingErrorAlert: Bool = false
var canPost: Bool { var canPost: Bool {
statusText.length > 0 || !mediasImages.isEmpty statusText.length > 0 || !mediaContainers.isEmpty
} }
var shouldDisablePollButton: Bool { var shouldDisablePollButton: Bool {
!selectedMedias.isEmpty !mediaPickers.isEmpty
} }
var shouldDisplayDismissWarning: Bool { var shouldDisplayDismissWarning: Bool {
@ -137,7 +148,6 @@ import SwiftUI
private var mentionString: String? private var mentionString: String?
private var uploadTask: Task<Void, Never>?
private var suggestedTask: Task<Void, Never>? private var suggestedTask: Task<Void, Never>?
init(mode: Mode) { init(mode: Mode) {
@ -182,7 +192,7 @@ import SwiftUI
visibility: visibility, visibility: visibility,
inReplyToId: mode.replyToStatus?.id, inReplyToId: mode.replyToStatus?.id,
spoilerText: spoilerOn ? spoilerText : nil, spoilerText: spoilerOn ? spoilerText : nil,
mediaIds: mediasImages.compactMap { $0.mediaAttachment?.id }, mediaIds: mediaContainers.compactMap { $0.mediaAttachment?.id },
poll: pollData, poll: pollData,
language: selectedLanguage, language: selectedLanguage,
mediaAttributes: mediaAttributes) mediaAttributes: mediaAttributes)
@ -278,11 +288,15 @@ import SwiftUI
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, mediaContainers = status.mediaAttachments.map {
StatusEditorMediaContainer(
id: UUID().uuidString,
image: nil,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: $0, mediaAttachment: $0,
error: nil) } error: nil)
}
case let .quote(status): case let .quote(status):
embeddedStatus = status embeddedStatus = status
if let url = embeddedStatusURL { if let url = embeddedStatusURL {
@ -370,12 +384,14 @@ import SwiftUI
} }
func processCameraPhoto(image: UIImage) { func processCameraPhoto(image: UIImage) {
mediasImages.append(.init(image: image, let container = StatusEditorMediaContainer(
id: UUID().uuidString,
image: image,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: nil, mediaAttachment: nil,
error: nil)) error: nil)
processMediasToUpload() prepareToPost(for: container)
} }
private func processItemsProvider(items: [NSItemProvider]) { private func processItemsProvider(items: [NSItemProvider]) {
@ -391,32 +407,44 @@ import SwiftUI
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, let container = StatusEditorMediaContainer(
id: UUID().uuidString,
image: image,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: nil, mediaAttachment: nil,
error: nil)) error: nil)
prepareToPost(for: container)
} else if let content = content as? ImageFileTranseferable, } else if let content = content as? ImageFileTranseferable,
let compressedData = await compressor.compressImageFrom(url: content.url), let compressedData = await compressor.compressImageFrom(url: content.url),
let image = UIImage(data: compressedData) let image = UIImage(data: compressedData)
{ {
mediasImages.append(.init(image: image, let container = StatusEditorMediaContainer(
id: UUID().uuidString,
image: image,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: nil, mediaAttachment: nil,
error: nil)) error: nil)
prepareToPost(for: container)
} else if let video = content as? MovieFileTranseferable { } else if let video = content as? MovieFileTranseferable {
mediasImages.append(.init(image: nil, let container = StatusEditorMediaContainer(
id: UUID().uuidString,
image: nil,
movieTransferable: video, movieTransferable: video,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: nil, mediaAttachment: nil,
error: nil)) error: nil)
prepareToPost(for: container)
} else if let gif = content as? GifFileTranseferable { } else if let gif = content as? GifFileTranseferable {
mediasImages.append(.init(image: nil, let container = StatusEditorMediaContainer(
id: UUID().uuidString,
image: nil,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: gif, gifTransferable: gif,
mediaAttachment: nil, mediaAttachment: nil,
error: nil)) error: nil)
prepareToPost(for: container)
} }
} catch { } catch {
isMediasLoading = false isMediasLoading = false
@ -427,9 +455,6 @@ import SwiftUI
statusText = .init(string: initialText) statusText = .init(string: initialText)
selectedRange = .init(location: statusText.string.utf16.count, length: 0) selectedRange = .init(location: statusText.string.utf16.count, length: 0)
} }
if !mediasImages.isEmpty {
processMediasToUpload()
}
} }
} }
@ -532,89 +557,107 @@ import SwiftUI
// MARK: - Media related function // MARK: - Media related function
private func indexOf(container: StatusEditorMediaContainer) -> Int? { private func indexOf(container: StatusEditorMediaContainer) -> Int? {
mediasImages.firstIndex(where: { $0.id == container.id }) mediaContainers.firstIndex(where: { $0.id == container.id })
} }
func inflateSelectedMedias() { func prepareToPost(for pickerItem: PhotosPickerItem) {
mediasImages = [] Task(priority: .high) {
if let container = await makeMediaContainer(from: pickerItem) {
Task { self.mediaContainers.append(container)
var medias: [StatusEditorMediaContainer] = [] await upload(container: container)
for media in selectedMedias { self.isMediasLoading = false
var file: (any Transferable)?
if file == nil {
file = try? await media.loadTransferable(type: GifFileTranseferable.self)
} }
if file == nil {
file = try? await media.loadTransferable(type: MovieFileTranseferable.self)
} }
if file == nil {
file = try? await media.loadTransferable(type: ImageFileTranseferable.self)
} }
let compressor = StatusEditorCompressor() func prepareToPost(for container: StatusEditorMediaContainer) {
if let imageFile = file as? ImageFileTranseferable, Task(priority: .high) {
let compressedData = await compressor.compressImageFrom(url: imageFile.url), self.mediaContainers.append(container)
let image = UIImage(data: compressedData) await upload(container: container)
{ self.isMediasLoading = false
medias.append(.init(image: image, }
movieTransferable: nil, }
gifTransferable: nil,
mediaAttachment: nil, func makeMediaContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
error: nil)) await withTaskGroup(of: StatusEditorMediaContainer?.self, returning: StatusEditorMediaContainer?.self) { taskGroup in
} else if let videoFile = file as? MovieFileTranseferable { taskGroup.addTask(priority: .high) { await Self.makeImageContainer(from: pickerItem) }
medias.append(.init(image: nil, taskGroup.addTask(priority: .high) { await Self.makeGifContainer(from: pickerItem) }
movieTransferable: videoFile, taskGroup.addTask(priority: .high) { await Self.makeMovieContainer(from: pickerItem) }
gifTransferable: nil,
mediaAttachment: nil, for await container in taskGroup {
error: nil)) if let container {
} else if let gifFile = file as? GifFileTranseferable { taskGroup.cancelAll()
medias.append(.init(image: nil, return container
}
}
return nil
}
}
private static func makeGifContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
guard let gifFile = try? await pickerItem.loadTransferable(type: GifFileTranseferable.self) else { return nil }
return StatusEditorMediaContainer(
id: pickerItem.itemIdentifier ?? UUID().uuidString,
image: nil,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: gifFile, gifTransferable: gifFile,
mediaAttachment: nil, mediaAttachment: nil,
error: nil)) error: nil)
}
} }
DispatchQueue.main.async { [weak self] in private static func makeMovieContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
self?.mediasImages = medias guard let movieFile = try? await pickerItem.loadTransferable(type: MovieFileTranseferable.self) else { return nil }
self?.processMediasToUpload()
} return StatusEditorMediaContainer(
} id: pickerItem.itemIdentifier ?? UUID().uuidString,
image: nil,
movieTransferable: movieFile,
gifTransferable: nil,
mediaAttachment: nil,
error: nil)
} }
private func processMediasToUpload() { private static func makeImageContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
isMediasLoading = false guard let imageFile = try? await pickerItem.loadTransferable(type: ImageFileTranseferable.self) else { return nil }
uploadTask?.cancel()
let mediasCopy = mediasImages let compressor = StatusEditorCompressor()
uploadTask = Task {
for media in mediasCopy { guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
if !Task.isCancelled { let image = UIImage(data: compressedData)
await upload(container: media) else { return nil }
}
} return StatusEditorMediaContainer(
} id: pickerItem.itemIdentifier ?? UUID().uuidString,
image: image,
movieTransferable: nil,
gifTransferable: nil,
mediaAttachment: nil,
error: nil)
} }
func upload(container: StatusEditorMediaContainer) async { func upload(container: StatusEditorMediaContainer) async {
if let index = indexOf(container: container) { if let index = indexOf(container: container) {
let originalContainer = mediasImages[index] let originalContainer = mediaContainers[index]
guard originalContainer.mediaAttachment == nil else { return } guard originalContainer.mediaAttachment == nil else { return }
let newContainer = StatusEditorMediaContainer(image: originalContainer.image, let newContainer = StatusEditorMediaContainer(
id: originalContainer.id,
image: originalContainer.image,
movieTransferable: originalContainer.movieTransferable, movieTransferable: originalContainer.movieTransferable,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: nil, mediaAttachment: nil,
error: nil) error: nil)
mediasImages[index] = newContainer mediaContainers[index] = newContainer
do { do {
let compressor = StatusEditorCompressor() let compressor = StatusEditorCompressor()
if let image = originalContainer.image { if let image = originalContainer.image {
let imageData = try await compressor.compressImageForUpload(image) let imageData = try await compressor.compressImageForUpload(image)
let uploadedMedia = try await uploadMedia(data: imageData, mimeType: "image/jpeg") let uploadedMedia = try await uploadMedia(data: imageData, mimeType: "image/jpeg")
if let index = indexOf(container: newContainer) { if let index = indexOf(container: newContainer) {
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil, mediaContainers[index] = StatusEditorMediaContainer(
id: originalContainer.id,
image: mode.isInShareExtension ? originalContainer.image : nil,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: uploadedMedia, mediaAttachment: uploadedMedia,
@ -629,7 +672,9 @@ import SwiftUI
{ {
let uploadedMedia = try await uploadMedia(data: data, mimeType: compressedVideoURL.mimeType()) let uploadedMedia = try await uploadMedia(data: data, mimeType: compressedVideoURL.mimeType())
if let index = indexOf(container: newContainer) { if let index = indexOf(container: newContainer) {
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil, mediaContainers[index] = StatusEditorMediaContainer(
id: originalContainer.id,
image: mode.isInShareExtension ? originalContainer.image : nil,
movieTransferable: originalContainer.movieTransferable, movieTransferable: originalContainer.movieTransferable,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: uploadedMedia, mediaAttachment: uploadedMedia,
@ -641,7 +686,9 @@ import SwiftUI
} else if let gifData = originalContainer.gifTransferable?.data { } else if let gifData = originalContainer.gifTransferable?.data {
let uploadedMedia = try await uploadMedia(data: gifData, mimeType: "image/gif") let uploadedMedia = try await uploadMedia(data: gifData, mimeType: "image/gif")
if let index = indexOf(container: newContainer) { if let index = indexOf(container: newContainer) {
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil, mediaContainers[index] = StatusEditorMediaContainer(
id: originalContainer.id,
image: mode.isInShareExtension ? originalContainer.image : nil,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: originalContainer.gifTransferable, gifTransferable: originalContainer.gifTransferable,
mediaAttachment: uploadedMedia, mediaAttachment: uploadedMedia,
@ -653,7 +700,9 @@ import SwiftUI
} }
} catch { } catch {
if let index = indexOf(container: newContainer) { if let index = indexOf(container: newContainer) {
mediasImages[index] = .init(image: originalContainer.image, mediaContainers[index] = StatusEditorMediaContainer(
id: originalContainer.id,
image: originalContainer.image,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: nil, mediaAttachment: nil,
@ -667,17 +716,19 @@ import SwiftUI
Task { Task {
repeat { repeat {
if let client, if let client,
let index = mediasImages.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id }) let index = mediaContainers.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id })
{ {
guard mediasImages[index].mediaAttachment?.url == nil else { guard mediaContainers[index].mediaAttachment?.url == nil else {
return return
} }
do { do {
let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id, let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id,
json: .init(description: nil))) json: .init(description: nil)))
if newAttachement.url != nil { if newAttachement.url != nil {
let oldContainer = mediasImages[index] let oldContainer = mediaContainers[index]
mediasImages[index] = .init(image: oldContainer.image, mediaContainers[index] = StatusEditorMediaContainer(
id: mediaAttachement.id,
image: oldContainer.image,
movieTransferable: oldContainer.movieTransferable, movieTransferable: oldContainer.movieTransferable,
gifTransferable: oldContainer.gifTransferable, gifTransferable: oldContainer.gifTransferable,
mediaAttachment: newAttachement, mediaAttachment: newAttachement,
@ -696,7 +747,9 @@ import SwiftUI
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,
json: .init(description: description))) json: .init(description: description)))
mediasImages[index] = .init(image: nil, mediaContainers[index] = StatusEditorMediaContainer(
id: container.id,
image: nil,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: nil, gifTransferable: nil,
mediaAttachment: media, mediaAttachment: media,
@ -785,3 +838,5 @@ extension StatusEditorViewModel: UITextPasteDelegate {
} }
} }
} }
extension PhotosPickerItem: @unchecked Sendable {}