mastodon-ios/ShareActionExtension/Scene/ShareViewModel.swift

403 lines
16 KiB
Swift

//
// ShareViewModel.swift
// MastodonShareAction
//
// Created by MainasuK Cirno on 2021-7-16.
//
import os.log
import Foundation
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonUI
import SwiftUI
import UniformTypeIdentifiers
import MastodonAsset
import MastodonLocalization
import MastodonUI
final class ShareViewModel {
let logger = Logger(subsystem: "ShareViewModel", category: "logic")
var disposeBag = Set<AnyCancellable>()
static let composeContentLimit: Int = 500
// input
private var coreDataStack: CoreDataStack?
var managedObjectContext: NSManagedObjectContext?
var inputItems = CurrentValueSubject<[NSExtensionItem], Never>([])
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
let selectedStatusVisibility = CurrentValueSubject<ComposeToolbarView.VisibilitySelectionType, Never>(.public)
// output
let authentication = CurrentValueSubject<Result<MastodonAuthentication, Error>?, Never>(nil)
let isFetchAuthentication = CurrentValueSubject<Bool, Never>(true)
let isPublishing = CurrentValueSubject<Bool, Never>(false)
let isBusy = CurrentValueSubject<Bool, Never>(true)
let isValid = CurrentValueSubject<Bool, Never>(false)
let shouldDismiss = CurrentValueSubject<Bool, Never>(true)
let composeViewModel = ComposeViewModel()
let characterCount = CurrentValueSubject<Int, Never>(0)
init() {
viewDidAppear.receive(on: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] viewDidAppear in
guard let self = self else { return }
guard viewDidAppear else { return }
self.setupCoreData()
}
.store(in: &disposeBag)
Publishers.CombineLatest(
inputItems.removeDuplicates(),
viewDidAppear.removeDuplicates()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] inputItems, _ in
guard let self = self else { return }
self.parse(inputItems: inputItems)
}
.store(in: &disposeBag)
// bind authentication loading state
authentication
.map { result in result == nil }
.assign(to: \.value, on: isFetchAuthentication)
.store(in: &disposeBag)
// bind user locked state
authentication
.compactMap { result -> Bool? in
guard let result = result else { return nil }
switch result {
case .success(let authentication):
return authentication.user.locked
case .failure:
return nil
}
}
.map { locked -> ComposeToolbarView.VisibilitySelectionType in
locked ? .private : .public
}
.assign(to: \.value, on: selectedStatusVisibility)
.store(in: &disposeBag)
// bind author
authentication
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self = self else { return }
guard let result = result else { return }
switch result {
case .success(let authentication):
self.composeViewModel.avatarImageURL = authentication.user.avatarImageURL()
self.composeViewModel.authorName = authentication.user.displayNameWithFallback
self.composeViewModel.authorUsername = "@" + authentication.user.username
case .failure:
self.composeViewModel.avatarImageURL = nil
self.composeViewModel.authorName = " "
self.composeViewModel.authorUsername = " "
}
}
.store(in: &disposeBag)
// bind authentication to compose view model
authentication
.map { result -> MastodonAuthentication? in
guard let result = result else { return nil }
switch result {
case .success(let authentication):
return authentication
case .failure:
return nil
}
}
.assign(to: &composeViewModel.$authentication)
// bind isBusy
Publishers.CombineLatest(
isFetchAuthentication,
isPublishing
)
.receive(on: DispatchQueue.main)
.map { $0 || $1 }
.assign(to: \.value, on: isBusy)
.store(in: &disposeBag)
// pass initial i18n string
composeViewModel.statusPlaceholder = L10n.Scene.Compose.contentInputPlaceholder
composeViewModel.contentWarningPlaceholder = L10n.Scene.Compose.ContentWarning.placeholder
composeViewModel.toolbarHeight = ComposeToolbarView.toolbarHeight
// bind compose bar button item UI state
let isComposeContentEmpty = composeViewModel.$statusContent
.map { $0.isEmpty }
isComposeContentEmpty
.assign(to: \.value, on: shouldDismiss)
.store(in: &disposeBag)
let isComposeContentValid = composeViewModel.$characterCount
.map { characterCount -> Bool in
return characterCount <= ShareViewModel.composeContentLimit
}
let isMediaEmpty = composeViewModel.$attachmentViewModels
.map { $0.isEmpty }
let isMediaUploadAllSuccess = composeViewModel.$attachmentViewModels
.map { viewModels in
viewModels.allSatisfy { $0.uploadStateMachineSubject.value is StatusAttachmentViewModel.UploadState.Finish }
}
let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4(
isComposeContentEmpty,
isComposeContentValid,
isMediaEmpty,
isMediaUploadAllSuccess
)
.map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in
if isMediaEmpty {
return isComposeContentValid && !isComposeContentEmpty
} else {
return isComposeContentValid && isMediaUploadAllSuccess
}
}
.eraseToAnyPublisher()
let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest(
isComposeContentEmpty,
isComposeContentValid
)
.map { isComposeContentEmpty, isComposeContentValid -> Bool in
return isComposeContentValid && !isComposeContentEmpty
}
.eraseToAnyPublisher()
Publishers.CombineLatest(
isPublishBarButtonItemEnabledPrecondition1,
isPublishBarButtonItemEnabledPrecondition2
)
.map { $0 && $1 }
.assign(to: \.value, on: isValid)
.store(in: &disposeBag)
// bind counter
composeViewModel.$characterCount
.assign(to: \.value, on: characterCount)
.store(in: &disposeBag)
// setup theme
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
}
private func setupBackgroundColor(theme: Theme) {
composeViewModel.contentWarningBackgroundColor = Color(theme.contentWarningOverlayBackgroundColor)
}
}
extension ShareViewModel {
enum ShareError: Error {
case `internal`(error: Error)
case userCancelShare
case missingAuthentication
}
}
extension ShareViewModel {
private func setupCoreData() {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
DispatchQueue.global().async {
let _coreDataStack = CoreDataStack()
self.coreDataStack = _coreDataStack
self.managedObjectContext = _coreDataStack.persistentContainer.viewContext
_coreDataStack.didFinishLoad
.receive(on: RunLoop.main)
.sink { [weak self] didFinishLoad in
guard let self = self else { return }
guard didFinishLoad else { return }
guard let managedObjectContext = self.managedObjectContext else { return }
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch authentication…")
managedObjectContext.perform {
do {
let request = MastodonAuthentication.sortedFetchRequest
let authentications = try managedObjectContext.fetch(request)
let authentication = authentications.sorted(by: { $0.activedAt > $1.activedAt }).first
guard let activeAuthentication = authentication else {
self.authentication.value = .failure(ShareError.missingAuthentication)
return
}
self.authentication.value = .success(activeAuthentication)
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch authentication success \(activeAuthentication.userID)")
} catch {
self.authentication.value = .failure(ShareError.internal(error: error))
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch authentication fail \(error.localizedDescription)")
assertionFailure(error.localizedDescription)
}
}
}
.store(in: &self.disposeBag)
}
}
}
extension ShareViewModel {
func parse(inputItems: [NSExtensionItem]) {
var itemProviders: [NSItemProvider] = []
for item in inputItems {
itemProviders.append(contentsOf: item.attachments ?? [])
}
let _textProvider = itemProviders.first { provider in
return provider.hasRepresentationConforming(toTypeIdentifier: UTType.plainText.identifier, fileOptions: [])
}
let _urlProvider = itemProviders.first { provider in
return provider.hasRepresentationConforming(toTypeIdentifier: UTType.url.identifier, fileOptions: [])
}
let _movieProvider = itemProviders.first { provider in
return provider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: [])
}
let imageProviders = itemProviders.filter { provider in
return provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: [])
}
Task { @MainActor in
async let text = ShareViewModel.loadText(textProvider: _textProvider)
async let url = ShareViewModel.loadURL(textProvider: _urlProvider)
let content = await [text, url]
.compactMap { $0 }
.joined(separator: " ")
self.composeViewModel.statusContent = content
}
if let movieProvider = _movieProvider {
composeViewModel.setupAttachmentViewModels([
StatusAttachmentViewModel(itemProvider: movieProvider)
])
} else if !imageProviders.isEmpty {
let viewModels = imageProviders.map { provider in
StatusAttachmentViewModel(itemProvider: provider)
}
composeViewModel.setupAttachmentViewModels(viewModels)
}
}
private static func loadText(textProvider: NSItemProvider?) async -> String? {
guard let textProvider = textProvider else { return nil }
do {
let item = try await textProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier)
guard let text = item as? String else { return nil }
return text
} catch {
return nil
}
}
private static func loadURL(textProvider: NSItemProvider?) async -> String? {
guard let textProvider = textProvider else { return nil }
do {
let item = try await textProvider.loadItem(forTypeIdentifier: UTType.url.identifier)
guard let url = item as? URL else { return nil }
return url.absoluteString
} catch {
return nil
}
}
}
extension ShareViewModel {
func publish() -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
guard let authentication = composeViewModel.authentication else {
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
let authenticationBox = MastodonAuthenticationBox(
authenticationRecord: .init(objectID: authentication.objectID),
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
)
let domain = authentication.domain
let attachmentViewModels = composeViewModel.attachmentViewModels
let mediaIDs = attachmentViewModels.compactMap { viewModel in
viewModel.attachment.value?.id
}
let sensitive: Bool = composeViewModel.isContentWarningComposing
let spoilerText: String? = {
let text = composeViewModel.contentWarningContent
guard !text.isEmpty else { return nil }
return text
}()
let visibility = selectedStatusVisibility.value.visibility
let updateMediaQuerySubscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = {
var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = []
for attachmentViewModel in attachmentViewModels {
guard let attachmentID = attachmentViewModel.attachment.value?.id else { continue }
let description = attachmentViewModel.descriptionContent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !description.isEmpty else { continue }
let query = Mastodon.API.Media.UpdateMediaQuery(
file: nil,
thumbnail: nil,
description: description,
focus: nil
)
let subscription = APIService.shared.updateMedia(
domain: domain,
attachmentID: attachmentID,
query: query,
mastodonAuthenticationBox: authenticationBox
)
subscriptions.append(subscription)
}
return subscriptions
}()
let status = composeViewModel.statusContent
return Publishers.MergeMany(updateMediaQuerySubscriptions)
.collect()
.asyncMap { attachments in
let query = Mastodon.API.Statuses.PublishStatusQuery(
status: status,
mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs,
pollOptions: nil,
pollExpiresIn: nil,
inReplyToID: nil,
sensitive: sensitive,
spoilerText: spoilerText,
visibility: visibility
)
return try await APIService.shared.publishStatus(
domain: domain,
idempotencyKey: nil, // FIXME:
query: query,
authenticationBox: authenticationBox
)
}
.eraseToAnyPublisher()
}
}