forked from zelo72/mastodon-ios
220 lines
8.8 KiB
Swift
220 lines
8.8 KiB
Swift
//
|
|
// StatusAttachmentViewModel.swift
|
|
// ShareActionExtension
|
|
//
|
|
// Created by MainasuK Cirno on 2021-7-19.
|
|
//
|
|
|
|
import os.log
|
|
import Foundation
|
|
import SwiftUI
|
|
import Combine
|
|
import CoreDataStack
|
|
import MastodonSDK
|
|
import MastodonUI
|
|
import AVFoundation
|
|
import GameplayKit
|
|
import MobileCoreServices
|
|
import UniformTypeIdentifiers
|
|
|
|
protocol StatusAttachmentViewModelDelegate: AnyObject {
|
|
func statusAttachmentViewModel(_ viewModel: StatusAttachmentViewModel, uploadStateDidChange state: StatusAttachmentViewModel.UploadState?)
|
|
}
|
|
|
|
final class StatusAttachmentViewModel: ObservableObject, Identifiable {
|
|
|
|
static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate)
|
|
static let videoSplashImage: UIImage = {
|
|
let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64))
|
|
return image
|
|
}()
|
|
|
|
let logger = Logger(subsystem: "StatusAttachmentViewModel", category: "logic")
|
|
|
|
weak var delegate: StatusAttachmentViewModelDelegate?
|
|
var disposeBag = Set<AnyCancellable>()
|
|
|
|
let id = UUID()
|
|
let itemProvider: NSItemProvider
|
|
|
|
// input
|
|
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
|
|
let authentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
|
|
@Published var descriptionContent = ""
|
|
|
|
// output
|
|
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
|
|
@Published var thumbnailImage: UIImage?
|
|
@Published var descriptionPlaceholder = ""
|
|
@Published var isUploading = true
|
|
@Published var progressViewTintColor = UIColor.systemFill
|
|
@Published var error: Error?
|
|
@Published var errorPrompt: String?
|
|
@Published var errorPromptImage: UIImage = StatusAttachmentViewModel.photoFillSplitImage
|
|
|
|
private(set) lazy var uploadStateMachine: GKStateMachine = {
|
|
// exclude timeline middle fetcher state
|
|
let stateMachine = GKStateMachine(states: [
|
|
UploadState.Initial(viewModel: self),
|
|
UploadState.Uploading(viewModel: self),
|
|
UploadState.Fail(viewModel: self),
|
|
UploadState.Finish(viewModel: self),
|
|
])
|
|
stateMachine.enter(UploadState.Initial.self)
|
|
return stateMachine
|
|
}()
|
|
lazy var uploadStateMachineSubject = CurrentValueSubject<StatusAttachmentViewModel.UploadState?, Never>(nil)
|
|
|
|
init(itemProvider: NSItemProvider) {
|
|
self.itemProvider = itemProvider
|
|
|
|
// bind attachment from item provider
|
|
Just(itemProvider)
|
|
.receive(on: DispatchQueue.main)
|
|
.flatMap { result -> AnyPublisher<Mastodon.Query.MediaAttachment?, Error> in
|
|
if itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) {
|
|
return ItemProviderLoader.loadImageData(from: result).eraseToAnyPublisher()
|
|
}
|
|
if itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) {
|
|
return ItemProviderLoader.loadVideoData(from: result).eraseToAnyPublisher()
|
|
}
|
|
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
|
|
}
|
|
.sink { [weak self] completion in
|
|
guard let self = self else { return }
|
|
switch completion {
|
|
case .failure(let error):
|
|
self.error = error
|
|
self.uploadStateMachine.enter(UploadState.Fail.self)
|
|
case .finished:
|
|
break
|
|
}
|
|
} receiveValue: { [weak self] file in
|
|
guard let self = self else { return }
|
|
self.file.value = file
|
|
self.uploadStateMachine.enter(UploadState.Initial.self)
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
// bind progress view tint color
|
|
$thumbnailImage
|
|
.receive(on: DispatchQueue.main)
|
|
.map { image -> UIColor in
|
|
guard let image = image else { return .systemFill }
|
|
switch image.domainLumaCoefficientsStyle {
|
|
case .light:
|
|
return UIColor.black.withAlphaComponent(0.8)
|
|
default:
|
|
return UIColor.white.withAlphaComponent(0.8)
|
|
}
|
|
}
|
|
.assign(to: &$progressViewTintColor)
|
|
|
|
// bind description placeholder and error prompt image
|
|
file
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] file in
|
|
guard let self = self else { return }
|
|
guard let file = file else { return }
|
|
switch file {
|
|
case .jpeg, .png, .gif:
|
|
self.descriptionPlaceholder = L10n.Scene.Compose.Attachment.descriptionPhoto
|
|
self.errorPromptImage = StatusAttachmentViewModel.photoFillSplitImage
|
|
case .other:
|
|
self.descriptionPlaceholder = L10n.Scene.Compose.Attachment.descriptionVideo
|
|
self.errorPromptImage = StatusAttachmentViewModel.videoSplashImage
|
|
}
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
// bind thumbnail image
|
|
file
|
|
.receive(on: DispatchQueue.main)
|
|
.map { file -> UIImage? in
|
|
guard let file = file else {
|
|
return nil
|
|
}
|
|
|
|
switch file {
|
|
case .jpeg(let data), .png(let data):
|
|
return data.flatMap { UIImage(data: $0) }
|
|
case .gif:
|
|
// TODO:
|
|
return nil
|
|
case .other(let url, _, _):
|
|
guard let url = url, 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 {
|
|
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
.assign(to: &$thumbnailImage)
|
|
|
|
// bind state and error
|
|
Publishers.CombineLatest(
|
|
uploadStateMachineSubject,
|
|
$error
|
|
)
|
|
.sink { [weak self] state, error in
|
|
guard let self = self else { return }
|
|
// trigger delegate
|
|
self.delegate?.statusAttachmentViewModel(self, uploadStateDidChange: state)
|
|
|
|
// set error prompt
|
|
if let error = error {
|
|
self.isUploading = false
|
|
self.errorPrompt = error.localizedDescription
|
|
} else {
|
|
guard let state = state else { return }
|
|
switch state {
|
|
case is UploadState.Finish:
|
|
self.isUploading = false
|
|
case is UploadState.Fail:
|
|
self.isUploading = false
|
|
// FIXME: not display
|
|
self.errorPrompt = {
|
|
guard let file = self.file.value else {
|
|
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
|
}
|
|
switch file {
|
|
case .jpeg, .png, .gif:
|
|
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
|
case .other:
|
|
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
|
|
}
|
|
}()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
// trigger delegate when authentication get new value
|
|
authentication
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] authentication in
|
|
guard let self = self else { return }
|
|
guard authentication != nil else { return }
|
|
self.delegate?.statusAttachmentViewModel(self, uploadStateDidChange: self.uploadStateMachineSubject.value)
|
|
}
|
|
.store(in: &disposeBag)
|
|
}
|
|
|
|
}
|
|
|
|
extension StatusAttachmentViewModel {
|
|
enum AttachmentError: Error {
|
|
case invalidAttachmentType
|
|
case attachmentTooLarge
|
|
}
|
|
}
|