mastodon-ios/Mastodon/Scene/Compose/ComposeViewModel.swift

388 lines
15 KiB
Swift

//
// ComposeViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-11.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
import MastodonAsset
import MastodonLocalization
import MastodonMeta
import MastodonUI
final class ComposeViewModel: NSObject {
let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel")
var disposeBag = Set<AnyCancellable>()
let id = UUID()
// input
let context: AppContext
let composeKind: ComposeStatusSection.ComposeKind
let authenticationBox: MastodonAuthenticationBox
@Published var isPollComposing = false
@Published var isCustomEmojiComposing = false
@Published var isContentWarningComposing = false
@Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType
@Published var repliedToCellFrame: CGRect = .zero
@Published var autoCompleteRetryLayoutTimes = 0
@Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
var isViewAppeared = false
// output
let instanceConfiguration: Mastodon.Entity.Instance.Configuration?
var composeContentLimit: Int {
guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 }
return max(1, maxCharacters)
}
var maxMediaAttachments: Int {
guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else {
return 4
}
// FIXME: update timeline media preview UI
return min(4, max(1, maxMediaAttachments))
// return max(1, maxMediaAttachments)
}
var maxPollOptions: Int {
guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 }
return max(2, maxOptions)
}
let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell()
let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell()
let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell()
// var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>?
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>?
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),
PublishState.Discard(viewModel: self),
PublishState.Finish(viewModel: self),
])
stateMachine.enter(PublishState.Initial.self)
return stateMachine
}()
private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
private(set) var publishDate = Date() // update it when enter Publishing state
// TODO: group post material into Hashable class
var idempotencyKey = CurrentValueSubject<String, Never>(UUID().uuidString)
// UI & UX
@Published var title: String
@Published var shouldDismiss = true
@Published var isPublishBarButtonItemEnabled = false
@Published var isMediaToolbarButtonEnabled = true
@Published var isPollToolbarButtonEnabled = true
@Published var characterCount = 0
@Published var collectionViewState: CollectionViewState = .fold
// for hashtag: "#<hashtag> "
// for mention: "@<mention> "
var preInsertedContent: String?
// custom emojis
let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
@Published var isLoadingCustomEmoji = false
// attachment
@Published var attachmentServices: [MastodonAttachmentService] = []
// polls
@Published var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = []
let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute()
init(
context: AppContext,
composeKind: ComposeStatusSection.ComposeKind,
authenticationBox: MastodonAuthenticationBox
) {
self.context = context
self.composeKind = composeKind
self.authenticationBox = authenticationBox
self.title = {
switch composeKind {
case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
case .reply: return L10n.Scene.Compose.Title.newReply
}
}()
self.selectedStatusVisibility = {
// default private when user locked
var visibility: ComposeToolbarView.VisibilitySelectionType = {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value,
let author = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
else {
return .public
}
return author.locked ? .private : .public
}()
// set visibility for reply post
switch composeKind {
case .reply(let record):
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else {
assertionFailure()
return
}
let repliedStatusVisibility = status.visibility
switch repliedStatusVisibility {
case .public, .unlisted:
// keep default
break
case .private:
visibility = .private
case .direct:
visibility = .direct
case ._other:
assertionFailure()
break
}
}
default:
break
}
return visibility
}()
// set limit
self.instanceConfiguration = {
var configuration: Mastodon.Entity.Instance.Configuration? = nil
context.managedObjectContext.performAndWait {
guard let authentication = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)
else {
return
}
configuration = authentication.instance?.configuration
}
return configuration
}()
self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authenticationBox.domain)
super.init()
// end init
setup(cell: composeStatusContentTableViewCell)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ComposeViewModel {
enum CollectionViewState {
case fold // snap to input
case expand // snap to reply
}
}
extension ComposeViewModel {
func createNewPollOptionIfPossible() {
guard pollOptionAttributes.count < maxPollOptions else { return }
let attribute = ComposeStatusPollItem.PollOptionAttribute()
pollOptionAttributes = pollOptionAttributes + [attribute]
}
func updatePublishDate() {
publishDate = Date()
}
}
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:
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
case .moreThanOneVideo:
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
}
}
}
// check exclusive limit:
// - up to 1 video
// - up to N photos
func checkAttachmentPrecondition() throws {
let attachmentServices = self.attachmentServices
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
}
}
}
}
// MARK: - MastodonAttachmentServiceDelegate
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
// trigger new output event
attachmentServices = attachmentServices
}
}
// MARK: - ComposePollAttributeDelegate
extension ComposeViewModel: ComposePollAttributeDelegate {
func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) {
// trigger update
pollOptionAttributes = pollOptionAttributes
}
}
extension ComposeViewModel {
private func setup(
cell: ComposeStatusContentTableViewCell
) {
setupStatusHeader(cell: cell)
setupStatusAuthor(cell: cell)
setupStatusContent(cell: cell)
}
private func setupStatusHeader(
cell: ComposeStatusContentTableViewCell
) {
// configure header
let managedObjectContext = context.managedObjectContext
managedObjectContext.performAndWait {
guard case let .reply(record) = self.composeKind,
let replyTo = record.object(in: managedObjectContext)
else {
cell.statusView.viewModel.header = .none
return
}
let info: StatusView.ViewModel.Header.ReplyInfo
do {
let content = MastodonContent(
content: replyTo.author.displayNameWithFallback,
emojis: replyTo.author.emojis.asDictionary
)
let metaContent = try MastodonMetaContent.convert(document: content)
info = .init(header: metaContent)
} catch {
let metaContent = PlaintextMetaContent(string: replyTo.author.displayNameWithFallback)
info = .init(header: metaContent)
}
cell.statusView.viewModel.header = .reply(info: info)
}
}
private func setupStatusAuthor(
cell: ComposeStatusContentTableViewCell
) {
self.context.managedObjectContext.performAndWait {
guard let author = authenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return }
cell.statusView.configureAuthor(author: author)
}
}
private func setupStatusContent(
cell: ComposeStatusContentTableViewCell
) {
switch composeKind {
case .reply(let record):
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else { return }
let author = self.authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
var mentionAccts: [String] = []
if author?.id != status.author.id {
mentionAccts.append("@" + status.author.acct)
}
let mentions = status.mentions
.filter { author?.id != $0.id }
for mention in mentions {
let acct = "@" + mention.acct
guard !mentionAccts.contains(acct) else { continue }
mentionAccts.append(acct)
}
for acct in mentionAccts {
UITextChecker.learnWord(acct)
}
if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
self.isContentWarningComposing = true
self.composeStatusAttribute.contentWarningContent = spoilerText
}
let initialComposeContent = mentionAccts.joined(separator: " ")
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
self.preInsertedContent = preInsertedContent
self.composeStatusAttribute.composeContent = preInsertedContent
}
case .hashtag(let hashtag):
let initialComposeContent = "#" + hashtag
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.preInsertedContent = preInsertedContent
self.composeStatusAttribute.composeContent = preInsertedContent
case .mention(let record):
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
let initialComposeContent = "@" + user.acct
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.preInsertedContent = preInsertedContent
self.composeStatusAttribute.composeContent = preInsertedContent
}
case .post:
self.preInsertedContent = nil
}
// configure content warning
if let composeContent = composeStatusAttribute.composeContent {
cell.metaText.textView.text = composeContent
}
// configure content warning
cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent
}
}