2021-03-11 08:41:27 +01:00
|
|
|
//
|
|
|
|
// ComposeViewModel.swift
|
|
|
|
// Mastodon
|
|
|
|
//
|
|
|
|
// Created by MainasuK Cirno on 2021-3-11.
|
|
|
|
//
|
|
|
|
|
2021-03-26 12:16:19 +01:00
|
|
|
import os.log
|
2021-03-11 08:41:27 +01:00
|
|
|
import UIKit
|
|
|
|
import Combine
|
|
|
|
import CoreData
|
|
|
|
import CoreDataStack
|
2021-03-18 10:33:07 +01:00
|
|
|
import GameplayKit
|
2021-03-25 12:34:30 +01:00
|
|
|
import MastodonSDK
|
2021-03-11 08:41:27 +01:00
|
|
|
|
2021-06-29 10:41:58 +02:00
|
|
|
final class ComposeViewModel: NSObject {
|
2021-03-11 08:41:27 +01:00
|
|
|
|
2021-03-26 12:16:19 +01:00
|
|
|
static let composeContentLimit: Int = 500
|
|
|
|
|
2021-03-12 07:18:07 +01:00
|
|
|
var disposeBag = Set<AnyCancellable>()
|
2021-07-26 14:09:23 +02:00
|
|
|
|
|
|
|
let id = UUID()
|
2021-03-12 07:18:07 +01:00
|
|
|
|
2021-03-11 08:41:27 +01:00
|
|
|
// input
|
|
|
|
let context: AppContext
|
2021-03-12 07:18:07 +01:00
|
|
|
let composeKind: ComposeStatusSection.ComposeKind
|
2021-03-16 04:23:19 +01:00
|
|
|
let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
|
2021-03-23 11:47:21 +01:00
|
|
|
let isPollComposing = CurrentValueSubject<Bool, Never>(false)
|
2021-03-25 08:56:17 +01:00
|
|
|
let isCustomEmojiComposing = CurrentValueSubject<Bool, Never>(false)
|
2021-03-25 11:17:05 +01:00
|
|
|
let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false)
|
2021-04-14 10:17:34 +02:00
|
|
|
let selectedStatusVisibility: CurrentValueSubject<ComposeToolbarView.VisibilitySelectionType, Never>
|
2021-03-12 07:18:07 +01:00
|
|
|
let activeAuthentication: CurrentValueSubject<MastodonAuthentication?, Never>
|
2021-07-20 10:40:04 +02:00
|
|
|
let activeAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never>
|
2021-07-06 10:38:14 +02:00
|
|
|
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
|
2021-04-15 06:10:43 +02:00
|
|
|
let repliedToCellFrame = CurrentValueSubject<CGRect, Never>(.zero)
|
2021-05-14 14:02:59 +02:00
|
|
|
let autoCompleteRetryLayoutTimes = CurrentValueSubject<Int, Never>(0)
|
2021-05-18 08:25:32 +02:00
|
|
|
let autoCompleteInfo = CurrentValueSubject<ComposeViewController.AutoCompleteInfo?, Never>(nil)
|
2021-06-29 10:41:58 +02:00
|
|
|
var isViewAppeared = false
|
2021-03-11 08:41:27 +01:00
|
|
|
|
|
|
|
// output
|
2021-06-28 13:41:41 +02:00
|
|
|
let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell()
|
2021-06-29 10:41:58 +02:00
|
|
|
let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell()
|
|
|
|
let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell()
|
|
|
|
|
2021-06-28 13:41:41 +02:00
|
|
|
var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
|
2021-03-25 08:56:17 +01:00
|
|
|
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>!
|
2021-03-18 10:33:07 +01:00
|
|
|
private(set) lazy var publishStateMachine: GKStateMachine = {
|
|
|
|
// exclude timeline middle fetcher state
|
|
|
|
let stateMachine = GKStateMachine(states: [
|
|
|
|
PublishState.Initial(viewModel: self),
|
|
|
|
PublishState.Publishing(viewModel: self),
|
|
|
|
PublishState.Fail(viewModel: self),
|
2021-03-29 11:44:52 +02:00
|
|
|
PublishState.Discard(viewModel: self),
|
2021-03-18 10:33:07 +01:00
|
|
|
PublishState.Finish(viewModel: self),
|
|
|
|
])
|
|
|
|
stateMachine.enter(PublishState.Initial.self)
|
|
|
|
return stateMachine
|
|
|
|
}()
|
2021-03-29 11:44:52 +02:00
|
|
|
private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
|
|
|
|
private(set) var publishDate = Date() // update it when enter Publishing state
|
2021-08-09 11:54:11 +02:00
|
|
|
|
|
|
|
// TODO: group post material into Hashable class
|
|
|
|
var idempotencyKey = CurrentValueSubject<String, Never>(UUID().uuidString)
|
|
|
|
|
2021-03-12 07:18:07 +01:00
|
|
|
// UI & UX
|
2021-03-11 08:41:27 +01:00
|
|
|
let title: CurrentValueSubject<String, Never>
|
|
|
|
let shouldDismiss = CurrentValueSubject<Bool, Never>(true)
|
2021-03-18 12:42:26 +01:00
|
|
|
let isPublishBarButtonItemEnabled = CurrentValueSubject<Bool, Never>(false)
|
2021-03-23 11:47:21 +01:00
|
|
|
let isMediaToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
|
|
|
|
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
|
2021-03-26 12:16:19 +01:00
|
|
|
let characterCount = CurrentValueSubject<Int, Never>(0)
|
2021-04-15 06:10:43 +02:00
|
|
|
let collectionViewState = CurrentValueSubject<CollectionViewState, Never>(.fold)
|
2021-03-11 08:41:27 +01:00
|
|
|
|
2021-06-28 13:41:41 +02:00
|
|
|
// for hashtag: "#<hashtag> "
|
2021-05-18 08:25:32 +02:00
|
|
|
// for mention: "@<mention> "
|
2021-04-07 11:10:58 +02:00
|
|
|
private(set) var preInsertedContent: String?
|
2021-04-01 04:12:57 +02:00
|
|
|
|
2021-03-16 04:23:34 +01:00
|
|
|
// custom emojis
|
2021-03-25 08:56:17 +01:00
|
|
|
var customEmojiViewModelSubscription: AnyCancellable?
|
2021-03-16 04:23:34 +01:00
|
|
|
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
|
2021-03-25 08:56:17 +01:00
|
|
|
let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
|
2021-03-26 12:16:19 +01:00
|
|
|
let isLoadingCustomEmoji = CurrentValueSubject<Bool, Never>(false)
|
2021-03-16 04:23:34 +01:00
|
|
|
|
2021-03-17 11:09:38 +01:00
|
|
|
// attachment
|
|
|
|
let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([])
|
|
|
|
|
2021-03-23 11:47:21 +01:00
|
|
|
// polls
|
2021-06-29 10:41:58 +02:00
|
|
|
let pollOptionAttributes = CurrentValueSubject<[ComposeStatusPollItem.PollOptionAttribute], Never>([])
|
|
|
|
let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute()
|
2021-03-23 11:47:21 +01:00
|
|
|
|
2021-03-11 08:41:27 +01:00
|
|
|
init(
|
|
|
|
context: AppContext,
|
2021-04-07 11:10:58 +02:00
|
|
|
composeKind: ComposeStatusSection.ComposeKind
|
2021-03-11 08:41:27 +01:00
|
|
|
) {
|
|
|
|
self.context = context
|
|
|
|
self.composeKind = composeKind
|
|
|
|
switch composeKind {
|
2021-04-07 11:10:58 +02:00
|
|
|
case .post, .hashtag, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
|
|
|
|
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
|
2021-03-11 08:41:27 +01:00
|
|
|
}
|
2021-08-03 11:13:22 +02:00
|
|
|
self.selectedStatusVisibility = {
|
|
|
|
// default private when user locked
|
|
|
|
var visibility: ComposeToolbarView.VisibilitySelectionType = context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public
|
2021-08-03 11:19:12 +02:00
|
|
|
// set visibility for reply post
|
2021-08-03 11:13:22 +02:00
|
|
|
switch composeKind {
|
|
|
|
case .reply(let repliedToStatusObjectID):
|
|
|
|
context.managedObjectContext.performAndWait {
|
|
|
|
guard let status = try? context.managedObjectContext.existingObject(with: repliedToStatusObjectID) as? Status else {
|
|
|
|
assertionFailure()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
guard let repliedStatusVisibility = status.visibilityEnum else { return }
|
|
|
|
switch repliedStatusVisibility {
|
|
|
|
case .public, .unlisted:
|
|
|
|
// keep default
|
|
|
|
break
|
|
|
|
case .private:
|
|
|
|
visibility = .private
|
|
|
|
case .direct:
|
|
|
|
visibility = .direct
|
|
|
|
case ._other:
|
|
|
|
assertionFailure()
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
return CurrentValueSubject(visibility)
|
|
|
|
}()
|
2021-03-12 07:18:07 +01:00
|
|
|
self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value)
|
2021-03-18 10:33:07 +01:00
|
|
|
self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
|
2021-06-29 10:41:58 +02:00
|
|
|
super.init()
|
2021-03-11 08:41:27 +01:00
|
|
|
// end init
|
2021-06-29 10:41:58 +02:00
|
|
|
|
2021-06-15 12:40:51 +02:00
|
|
|
switch composeKind {
|
|
|
|
case .reply(let repliedToStatusObjectID):
|
2021-04-14 09:59:29 +02:00
|
|
|
context.managedObjectContext.performAndWait {
|
|
|
|
guard let status = context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return }
|
|
|
|
let composeAuthor: MastodonUser? = {
|
|
|
|
guard let objectID = self.activeAuthentication.value?.user.objectID else { return nil }
|
|
|
|
guard let author = context.managedObjectContext.object(with: objectID) as? MastodonUser else { return nil }
|
|
|
|
return author
|
|
|
|
}()
|
|
|
|
|
|
|
|
var mentionAccts: [String] = []
|
|
|
|
if composeAuthor?.id != status.author.id {
|
|
|
|
mentionAccts.append("@" + status.author.acct)
|
|
|
|
}
|
|
|
|
let mentions = (status.mentions ?? Set())
|
|
|
|
.sorted(by: { $0.index.intValue < $1.index.intValue })
|
|
|
|
.filter { $0.id != composeAuthor?.id }
|
|
|
|
for mention in mentions {
|
|
|
|
mentionAccts.append("@" + mention.acct)
|
|
|
|
}
|
|
|
|
for acct in mentionAccts {
|
|
|
|
UITextChecker.learnWord(acct)
|
|
|
|
}
|
2021-04-19 13:13:20 +02:00
|
|
|
if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
|
|
|
|
self.isContentWarningComposing.value = true
|
|
|
|
self.composeStatusAttribute.contentWarningContent.value = spoilerText
|
|
|
|
}
|
2021-04-14 09:59:29 +02:00
|
|
|
|
|
|
|
let initialComposeContent = mentionAccts.joined(separator: " ")
|
|
|
|
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
|
|
|
|
self.preInsertedContent = preInsertedContent
|
|
|
|
self.composeStatusAttribute.composeContent.value = preInsertedContent
|
|
|
|
}
|
2021-06-15 12:40:51 +02:00
|
|
|
case .hashtag(let hashtag):
|
|
|
|
let initialComposeContent = "#" + hashtag
|
2021-04-07 11:10:58 +02:00
|
|
|
UITextChecker.learnWord(initialComposeContent)
|
|
|
|
let preInsertedContent = initialComposeContent + " "
|
|
|
|
self.preInsertedContent = preInsertedContent
|
|
|
|
self.composeStatusAttribute.composeContent.value = preInsertedContent
|
2021-06-15 12:40:51 +02:00
|
|
|
case .mention(let mastodonUserObjectID):
|
2021-04-02 12:50:08 +02:00
|
|
|
context.managedObjectContext.performAndWait {
|
|
|
|
let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser
|
2021-04-06 11:52:37 +02:00
|
|
|
let initialComposeContent = "@" + mastodonUser.acct
|
|
|
|
UITextChecker.learnWord(initialComposeContent)
|
2021-04-07 11:10:58 +02:00
|
|
|
let preInsertedContent = initialComposeContent + " "
|
|
|
|
self.preInsertedContent = preInsertedContent
|
|
|
|
self.composeStatusAttribute.composeContent.value = preInsertedContent
|
2021-04-02 12:50:08 +02:00
|
|
|
}
|
2021-06-15 12:40:51 +02:00
|
|
|
case .post:
|
2021-04-07 11:10:58 +02:00
|
|
|
self.preInsertedContent = nil
|
2021-04-02 12:50:08 +02:00
|
|
|
}
|
2021-06-29 10:41:58 +02:00
|
|
|
|
2021-03-25 08:56:17 +01:00
|
|
|
isCustomEmojiComposing
|
|
|
|
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
|
|
|
|
.store(in: &disposeBag)
|
|
|
|
|
2021-03-25 11:17:05 +01:00
|
|
|
isContentWarningComposing
|
|
|
|
.assign(to: \.value, on: composeStatusAttribute.isContentWarningComposing)
|
|
|
|
.store(in: &disposeBag)
|
|
|
|
|
2021-03-12 07:18:07 +01:00
|
|
|
// bind active authentication
|
|
|
|
context.authenticationService.activeMastodonAuthentication
|
|
|
|
.assign(to: \.value, on: activeAuthentication)
|
|
|
|
.store(in: &disposeBag)
|
2021-03-18 10:33:07 +01:00
|
|
|
context.authenticationService.activeMastodonAuthenticationBox
|
|
|
|
.assign(to: \.value, on: activeAuthenticationBox)
|
|
|
|
.store(in: &disposeBag)
|
2021-03-12 07:18:07 +01:00
|
|
|
|
2021-03-16 04:23:34 +01:00
|
|
|
// bind avatar and names
|
2021-03-12 07:18:07 +01:00
|
|
|
activeAuthentication
|
|
|
|
.sink { [weak self] mastodonAuthentication in
|
|
|
|
guard let self = self else { return }
|
|
|
|
let mastodonUser = mastodonAuthentication?.user
|
|
|
|
let username = mastodonUser?.username ?? " "
|
2021-03-11 08:41:27 +01:00
|
|
|
|
2021-03-16 04:23:34 +01:00
|
|
|
self.composeStatusAttribute.avatarURL.value = mastodonUser?.avatarImageURL()
|
|
|
|
self.composeStatusAttribute.displayName.value = {
|
2021-03-12 07:18:07 +01:00
|
|
|
guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else {
|
|
|
|
return username
|
|
|
|
}
|
|
|
|
return displayName
|
|
|
|
}()
|
2021-07-23 13:10:27 +02:00
|
|
|
self.composeStatusAttribute.emojiMeta.value = mastodonUser?.emojiMeta ?? [:]
|
2021-03-16 04:23:34 +01:00
|
|
|
self.composeStatusAttribute.username.value = username
|
2021-03-12 07:18:07 +01:00
|
|
|
}
|
|
|
|
.store(in: &disposeBag)
|
2021-03-12 08:57:58 +01:00
|
|
|
|
2021-03-26 12:16:19 +01:00
|
|
|
// bind character count
|
|
|
|
Publishers.CombineLatest3(
|
|
|
|
composeStatusAttribute.composeContent.eraseToAnyPublisher(),
|
|
|
|
composeStatusAttribute.isContentWarningComposing.eraseToAnyPublisher(),
|
|
|
|
composeStatusAttribute.contentWarningContent.eraseToAnyPublisher()
|
|
|
|
)
|
|
|
|
.map { composeContent, isContentWarningComposing, contentWarningContent -> Int in
|
|
|
|
let composeContent = composeContent ?? ""
|
|
|
|
var count = composeContent.count
|
|
|
|
if isContentWarningComposing {
|
|
|
|
count += contentWarningContent.count
|
|
|
|
}
|
|
|
|
return count
|
|
|
|
}
|
|
|
|
.assign(to: \.value, on: characterCount)
|
|
|
|
.store(in: &disposeBag)
|
2021-07-20 10:40:04 +02:00
|
|
|
|
2021-03-16 04:23:34 +01:00
|
|
|
// bind compose bar button item UI state
|
2021-03-18 12:42:26 +01:00
|
|
|
let isComposeContentEmpty = composeStatusAttribute.composeContent
|
|
|
|
.map { ($0 ?? "").isEmpty }
|
2021-07-20 10:40:04 +02:00
|
|
|
let isComposeContentValid = characterCount
|
|
|
|
.map { characterCount -> Bool in
|
|
|
|
return characterCount <= ComposeViewModel.composeContentLimit
|
2021-03-26 12:16:19 +01:00
|
|
|
}
|
2021-03-18 12:42:26 +01:00
|
|
|
let isMediaEmpty = attachmentServices
|
|
|
|
.map { $0.isEmpty }
|
|
|
|
let isMediaUploadAllSuccess = attachmentServices
|
|
|
|
.map { services in
|
|
|
|
services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish }
|
2021-03-12 08:57:58 +01:00
|
|
|
}
|
2021-03-24 08:46:40 +01:00
|
|
|
let isPollAttributeAllValid = pollOptionAttributes
|
2021-03-23 12:33:12 +01:00
|
|
|
.map { pollAttributes in
|
|
|
|
pollAttributes.allSatisfy { attribute -> Bool in
|
|
|
|
!attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4(
|
2021-07-20 10:40:04 +02:00
|
|
|
isComposeContentEmpty,
|
|
|
|
isComposeContentValid,
|
|
|
|
isMediaEmpty,
|
|
|
|
isMediaUploadAllSuccess
|
2021-03-18 12:42:26 +01:00
|
|
|
)
|
2021-03-23 12:33:12 +01:00
|
|
|
.map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in
|
2021-03-18 12:42:26 +01:00
|
|
|
if isMediaEmpty {
|
|
|
|
return isComposeContentValid && !isComposeContentEmpty
|
|
|
|
} else {
|
|
|
|
return isComposeContentValid && isMediaUploadAllSuccess
|
|
|
|
}
|
|
|
|
}
|
2021-03-23 12:33:12 +01:00
|
|
|
.eraseToAnyPublisher()
|
|
|
|
|
|
|
|
let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4(
|
2021-07-20 10:40:04 +02:00
|
|
|
isComposeContentEmpty,
|
|
|
|
isComposeContentValid,
|
|
|
|
isPollComposing,
|
|
|
|
isPollAttributeAllValid
|
2021-03-23 12:33:12 +01:00
|
|
|
)
|
|
|
|
.map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in
|
|
|
|
if isPollComposing {
|
|
|
|
return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid
|
|
|
|
} else {
|
|
|
|
return isComposeContentValid && !isComposeContentEmpty
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.eraseToAnyPublisher()
|
|
|
|
|
|
|
|
Publishers.CombineLatest(
|
|
|
|
isPublishBarButtonItemEnabledPrecondition1,
|
|
|
|
isPublishBarButtonItemEnabledPrecondition2
|
|
|
|
)
|
|
|
|
.map { $0 && $1 }
|
2021-03-18 12:42:26 +01:00
|
|
|
.assign(to: \.value, on: isPublishBarButtonItemEnabled)
|
|
|
|
.store(in: &disposeBag)
|
2021-03-12 08:57:58 +01:00
|
|
|
|
2021-03-16 04:23:34 +01:00
|
|
|
// bind modal dismiss state
|
|
|
|
composeStatusAttribute.composeContent
|
2021-03-12 08:57:58 +01:00
|
|
|
.receive(on: DispatchQueue.main)
|
2021-04-01 04:12:57 +02:00
|
|
|
.map { [weak self] content in
|
2021-03-12 08:57:58 +01:00
|
|
|
let content = content ?? ""
|
2021-04-01 04:12:57 +02:00
|
|
|
if content.isEmpty {
|
|
|
|
return true
|
|
|
|
}
|
2021-04-07 07:57:03 +02:00
|
|
|
// if preInsertedContent plus a space is equal to the content, simply dismiss the modal
|
|
|
|
if let preInsertedContent = self?.preInsertedContent {
|
2021-06-15 12:40:51 +02:00
|
|
|
return content == preInsertedContent
|
2021-04-01 04:12:57 +02:00
|
|
|
}
|
|
|
|
return false
|
2021-03-12 08:57:58 +01:00
|
|
|
}
|
|
|
|
.assign(to: \.value, on: shouldDismiss)
|
|
|
|
.store(in: &disposeBag)
|
2021-03-16 04:23:34 +01:00
|
|
|
|
|
|
|
// bind custom emojis
|
|
|
|
context.authenticationService.activeMastodonAuthenticationBox
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
.sink { [weak self] activeMastodonAuthenticationBox in
|
|
|
|
guard let self = self else { return }
|
|
|
|
guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return }
|
|
|
|
let domain = activeMastodonAuthenticationBox.domain
|
|
|
|
|
|
|
|
// trigger dequeue to preload emojis
|
2021-03-16 07:19:12 +01:00
|
|
|
self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain)
|
2021-03-16 04:23:34 +01:00
|
|
|
}
|
|
|
|
.store(in: &disposeBag)
|
2021-05-31 10:42:49 +02:00
|
|
|
|
2021-06-29 10:41:58 +02:00
|
|
|
// setup attribute updater
|
2021-06-30 10:14:01 +02:00
|
|
|
attachmentServices
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
.debounce(for: 0.3, scheduler: DispatchQueue.main)
|
|
|
|
.sink { attachmentServices in
|
|
|
|
// drive service upload state
|
|
|
|
// make image upload in the queue
|
|
|
|
for attachmentService in attachmentServices {
|
|
|
|
// skip when prefix N task when task finish OR fail OR uploading
|
|
|
|
guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
|
|
|
|
if currentState is MastodonAttachmentService.UploadState.Fail {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if currentState is MastodonAttachmentService.UploadState.Finish {
|
|
|
|
continue
|
|
|
|
}
|
2021-08-09 11:02:32 +02:00
|
|
|
if currentState is MastodonAttachmentService.UploadState.Processing {
|
|
|
|
continue
|
|
|
|
}
|
2021-06-30 10:14:01 +02:00
|
|
|
if currentState is MastodonAttachmentService.UploadState.Uploading {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
// trigger uploading one by one
|
|
|
|
if currentState is MastodonAttachmentService.UploadState.Initial {
|
|
|
|
attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
|
|
|
|
break
|
|
|
|
}
|
2021-03-23 11:47:21 +01:00
|
|
|
}
|
|
|
|
}
|
2021-06-30 10:14:01 +02:00
|
|
|
.store(in: &disposeBag)
|
2021-03-23 11:47:21 +01:00
|
|
|
|
2021-03-23 12:33:12 +01:00
|
|
|
// bind delegate
|
|
|
|
attachmentServices
|
|
|
|
.sink { [weak self] attachmentServices in
|
|
|
|
guard let self = self else { return }
|
|
|
|
attachmentServices.forEach { $0.delegate = self }
|
|
|
|
}
|
|
|
|
.store(in: &disposeBag)
|
|
|
|
|
2021-03-24 08:46:40 +01:00
|
|
|
pollOptionAttributes
|
2021-03-23 12:33:12 +01:00
|
|
|
.sink { [weak self] pollAttributes in
|
|
|
|
guard let self = self else { return }
|
|
|
|
pollAttributes.forEach { $0.delegate = self }
|
|
|
|
}
|
|
|
|
.store(in: &disposeBag)
|
|
|
|
|
|
|
|
// bind compose toolbar UI state
|
2021-03-23 11:47:21 +01:00
|
|
|
Publishers.CombineLatest(
|
|
|
|
isPollComposing.eraseToAnyPublisher(),
|
|
|
|
attachmentServices.eraseToAnyPublisher()
|
|
|
|
)
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
.sink(receiveValue: { [weak self] isPollComposing, attachmentServices in
|
|
|
|
guard let self = self else { return }
|
|
|
|
let shouldMediaDisable = isPollComposing || attachmentServices.count >= 4
|
|
|
|
let shouldPollDisable = attachmentServices.count > 0
|
|
|
|
|
|
|
|
self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable
|
|
|
|
self.isPollToolbarButtonEnabled.value = !shouldPollDisable
|
|
|
|
})
|
|
|
|
.store(in: &disposeBag)
|
2021-08-09 11:54:11 +02:00
|
|
|
|
|
|
|
// calculate `Idempotency-Key`
|
|
|
|
let content = Publishers.CombineLatest3(
|
|
|
|
composeStatusAttribute.isContentWarningComposing,
|
|
|
|
composeStatusAttribute.contentWarningContent,
|
|
|
|
composeStatusAttribute.composeContent
|
|
|
|
)
|
|
|
|
.map { isContentWarningComposing, contentWarningContent, composeContent -> String in
|
|
|
|
if isContentWarningComposing {
|
|
|
|
return contentWarningContent + (composeContent ?? "")
|
|
|
|
} else {
|
|
|
|
return composeContent ?? ""
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let attachmentIDs = attachmentServices.map { attachments -> String in
|
|
|
|
let attachmentIDs = attachments.compactMap { $0.attachment.value?.id }
|
|
|
|
return attachmentIDs.joined(separator: ",")
|
|
|
|
}
|
|
|
|
let pollOptionsAndDuration = Publishers.CombineLatest3(
|
|
|
|
isPollComposing,
|
|
|
|
pollOptionAttributes,
|
|
|
|
pollExpiresOptionAttribute.expiresOption
|
|
|
|
)
|
|
|
|
.map { isPollComposing, pollOptionAttributes, expiresOption -> String in
|
|
|
|
guard isPollComposing else {
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",")
|
|
|
|
return pollOptions + expiresOption.rawValue
|
|
|
|
}
|
|
|
|
|
|
|
|
Publishers.CombineLatest4(
|
|
|
|
content,
|
|
|
|
attachmentIDs,
|
|
|
|
pollOptionsAndDuration,
|
|
|
|
selectedStatusVisibility
|
|
|
|
)
|
|
|
|
.map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in
|
|
|
|
var hasher = Hasher()
|
|
|
|
hasher.combine(content)
|
|
|
|
hasher.combine(attachmentIDs)
|
|
|
|
hasher.combine(pollOptionsAndDuration)
|
|
|
|
hasher.combine(selectedStatusVisibility.visibility.rawValue)
|
|
|
|
let hashValue = hasher.finalize()
|
|
|
|
return "\(hashValue)"
|
|
|
|
}
|
|
|
|
.assign(to: \.value, on: idempotencyKey)
|
|
|
|
.store(in: &disposeBag)
|
|
|
|
|
2021-03-11 08:41:27 +01:00
|
|
|
}
|
2021-03-12 07:18:07 +01:00
|
|
|
|
2021-03-26 12:16:19 +01:00
|
|
|
deinit {
|
|
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
|
|
|
}
|
|
|
|
|
2021-03-11 08:41:27 +01:00
|
|
|
}
|
2021-03-18 12:42:26 +01:00
|
|
|
|
2021-04-15 06:10:43 +02:00
|
|
|
extension ComposeViewModel {
|
|
|
|
enum CollectionViewState {
|
|
|
|
case fold // snap to input
|
|
|
|
case expand // snap to reply
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-23 11:47:21 +01:00
|
|
|
extension ComposeViewModel {
|
|
|
|
func createNewPollOptionIfPossible() {
|
2021-03-24 08:46:40 +01:00
|
|
|
guard pollOptionAttributes.value.count < 4 else { return }
|
2021-03-23 11:47:21 +01:00
|
|
|
|
2021-06-29 10:41:58 +02:00
|
|
|
let attribute = ComposeStatusPollItem.PollOptionAttribute()
|
2021-03-24 08:46:40 +01:00
|
|
|
pollOptionAttributes.value = pollOptionAttributes.value + [attribute]
|
2021-03-23 11:47:21 +01:00
|
|
|
}
|
2021-03-29 11:44:52 +02:00
|
|
|
|
|
|
|
func updatePublishDate() {
|
|
|
|
publishDate = Date()
|
|
|
|
}
|
2021-03-23 11:47:21 +01:00
|
|
|
}
|
|
|
|
|
2021-05-31 10:42:49 +02:00
|
|
|
extension ComposeViewModel {
|
|
|
|
|
|
|
|
enum AttachmentPrecondition: Error, LocalizedError {
|
|
|
|
case videoAttachWithPhoto
|
|
|
|
case moreThanOneVideo
|
|
|
|
|
|
|
|
var errorDescription: String? {
|
|
|
|
return L10n.Common.Alerts.PublishPostFailure.title
|
|
|
|
}
|
|
|
|
|
|
|
|
var failureReason: String? {
|
|
|
|
switch self {
|
|
|
|
case .videoAttachWithPhoto:
|
2021-07-20 10:40:04 +02:00
|
|
|
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
|
2021-05-31 10:42:49 +02:00
|
|
|
case .moreThanOneVideo:
|
2021-07-20 10:40:04 +02:00
|
|
|
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
|
2021-05-31 10:42:49 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// check exclusive limit:
|
|
|
|
// - up to 1 video
|
|
|
|
// - up to 4 photos
|
|
|
|
func checkAttachmentPrecondition() throws {
|
|
|
|
let attachmentServices = self.attachmentServices.value
|
|
|
|
guard !attachmentServices.isEmpty else { return }
|
|
|
|
var photoAttachmentServices: [MastodonAttachmentService] = []
|
|
|
|
var videoAttachmentServices: [MastodonAttachmentService] = []
|
|
|
|
attachmentServices.forEach { service in
|
|
|
|
guard let file = service.file.value else {
|
|
|
|
assertionFailure()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
switch file {
|
|
|
|
case .jpeg, .png, .gif:
|
|
|
|
photoAttachmentServices.append(service)
|
|
|
|
case .other:
|
|
|
|
videoAttachmentServices.append(service)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !videoAttachmentServices.isEmpty {
|
|
|
|
guard videoAttachmentServices.count == 1 else {
|
|
|
|
throw AttachmentPrecondition.moreThanOneVideo
|
|
|
|
}
|
|
|
|
guard photoAttachmentServices.isEmpty else {
|
|
|
|
throw AttachmentPrecondition.videoAttachWithPhoto
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2021-03-18 12:42:26 +01:00
|
|
|
// MARK: - MastodonAttachmentServiceDelegate
|
|
|
|
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
|
|
|
|
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
|
|
|
|
// trigger new output event
|
|
|
|
attachmentServices.value = attachmentServices.value
|
|
|
|
}
|
|
|
|
}
|
2021-03-23 12:33:12 +01:00
|
|
|
|
2021-03-24 07:49:27 +01:00
|
|
|
// MARK: - ComposePollAttributeDelegate
|
|
|
|
extension ComposeViewModel: ComposePollAttributeDelegate {
|
2021-06-29 10:41:58 +02:00
|
|
|
func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) {
|
2021-03-23 12:33:12 +01:00
|
|
|
// trigger update
|
2021-05-06 10:36:10 +02:00
|
|
|
pollOptionAttributes.value = pollOptionAttributes.value
|
2021-03-23 12:33:12 +01:00
|
|
|
}
|
|
|
|
}
|