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,
selection: $viewModel.selectedMedias,
selection: $viewModel.mediaPickers,
maxSelectionCount: 4,
matching: .any(of: [.images, .videos]))
matching: .any(of: [.images, .videos]),
photoLibrary: .shared()
)
.fileImporter(isPresented: $isFileImporterPresented,
allowedContentTypes: [.image, .video],
allowsMultipleSelection: true)

View File

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

View File

@ -17,25 +17,26 @@ struct StatusEditorMediaView: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.mediasImages) { container in
ForEach(viewModel.mediaContainers) { container in
Menu {
makeImageMenu(container: container)
} label: {
ZStack(alignment: .bottomTrailing) {
if let attachement = container.mediaAttachment {
makeLazyImage(mediaAttachement: attachement)
} else if container.image != nil {
makeLocalImage(container: container)
} else if container.movieTransferable != nil || container.gifTransferable != nil {
makeVideoAttachement(container: container)
} else if let error = container.error as? ServerError {
makeErrorView(error: error)
}
if container.mediaAttachment?.description?.isEmpty == false {
altMarker
}
if let attachement = container.mediaAttachment {
makeLazyImage(mediaAttachement: attachement)
} else if container.image != nil {
makeLocalImage(container: container)
} else if container.movieTransferable != nil || container.gifTransferable != nil {
makeVideoAttachement(container: container)
} else if let error = container.error as? ServerError {
makeErrorView(error: error)
}
}
.overlay(alignment: .bottomTrailing) {
makeAltMarker(container: container)
}
.overlay(alignment: .topTrailing) {
makeDiscardMarker(container: container)
}
}
}
.padding(.horizontal, .layoutPadding)
@ -122,7 +123,13 @@ struct StatusEditorMediaView: View {
Button(role: .destructive) {
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("action.delete", systemImage: "trash")
@ -141,14 +148,37 @@ struct StatusEditorMediaView: View {
}
}
private var altMarker: some View {
Button {} label: {
private func makeAltMarker(container: StatusEditorMediaContainer) -> some View {
Button {
editingContainer = container
} label: {
Text("status.image.alt-text.abbreviation")
.font(.caption2)
}
.padding(4)
.background(.thinMaterial)
.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 {

View File

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