// // 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() static let composeContentLimit: Int = 500 // input private var coreDataStack: CoreDataStack? var managedObjectContext: NSManagedObjectContext? var inputItems = CurrentValueSubject<[NSExtensionItem], Never>([]) let viewDidAppear = CurrentValueSubject(false) let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make initial event emit let selectedStatusVisibility = CurrentValueSubject(.public) // output let authentication = CurrentValueSubject?, Never>(nil) let isFetchAuthentication = CurrentValueSubject(true) let isPublishing = CurrentValueSubject(false) let isBusy = CurrentValueSubject(true) let isValid = CurrentValueSubject(false) let shouldDismiss = CurrentValueSubject(true) let composeViewModel = ComposeViewModel() let characterCount = CurrentValueSubject(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, 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, Error>] = { var subscriptions: [AnyPublisher, 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() } }