// Copyright © 2020 Metabolist. All rights reserved. import Combine import Foundation import Mastodon import ServiceLayer public final class StatusViewModel: AttachmentsRenderingViewModel, ObservableObject { public let accountViewModel: AccountViewModel public let content: NSAttributedString public let contentEmojis: [Emoji] public let spoilerText: String public let isReblog: Bool public let rebloggedByDisplayName: String public let rebloggedByDisplayNameEmojis: [Emoji] public let attachmentViewModels: [AttachmentViewModel] public let pollEmojis: [Emoji] @Published public var pollOptionSelections = Set() public var configuration = CollectionItem.StatusConfiguration.default public var showReportSelectionToggle = false public var selectedForReport = false public let identityContext: IdentityContext private let statusService: StatusService private let eventsSubject: PassthroughSubject, Never> init(statusService: StatusService, identityContext: IdentityContext, eventsSubject: PassthroughSubject, Never>) { self.statusService = statusService self.identityContext = identityContext self.eventsSubject = eventsSubject accountViewModel = AccountViewModel( accountService: statusService.navigationService .accountService(account: statusService.status.displayStatus.account), identityContext: identityContext, eventsSubject: eventsSubject) content = statusService.status.displayStatus.content.attributed contentEmojis = statusService.status.displayStatus.emojis spoilerText = statusService.status.displayStatus.spoilerText isReblog = statusService.status.reblog != nil rebloggedByDisplayName = statusService.status.account.displayName.isEmpty ? statusService.status.account.username : statusService.status.account.displayName rebloggedByDisplayNameEmojis = statusService.status.account.emojis attachmentViewModels = statusService.status.displayStatus.mediaAttachments .map { AttachmentViewModel(attachment: $0, identityContext: identityContext, status: statusService.status) } pollEmojis = statusService.status.displayStatus.poll?.emojis ?? [] } } public extension StatusViewModel { var isMine: Bool { statusService.status.displayStatus.account.id == identityContext.identity.account?.id } var shouldShowContent: Bool { guard spoilerText != "" else { return true } if identityContext.identity.preferences.readingExpandSpoilers { return !configuration.showContentToggled } else { return configuration.showContentToggled } } var shouldShowAttachments: Bool { switch identityContext.identity.preferences.readingExpandMedia { case .default, .unknown: return !sensitive || configuration.showAttachmentsToggled case .showAll: return !configuration.showAttachmentsToggled case .hideAll: return configuration.showAttachmentsToggled } } var shouldShowHideAttachmentsButton: Bool { sensitive || identityContext.identity.preferences.readingExpandMedia == .hideAll } var id: Status.Id { statusService.status.displayStatus.id } var accountName: String { "@".appending(statusService.status.displayStatus.account.acct) } var avatarURL: URL { if identityContext.appPreferences.animateAvatars == .everywhere { return statusService.status.displayStatus.account.avatar } else { return statusService.status.displayStatus.account.avatarStatic } } var rebloggerAvatarURL: URL { if identityContext.appPreferences.animateAvatars == .everywhere { return statusService.status.account.avatar } else { return statusService.status.account.avatarStatic } } var time: String? { statusService.status.displayStatus.createdAt.timeAgo } var accessibilityTime: String? { statusService.status.displayStatus.createdAt.accessibilityTimeAgo } var contextParentTime: String { Self.contextParentDateFormatter.string(from: statusService.status.displayStatus.createdAt) } var accessibilityContextParentTime: String { Self.contextParentAccessibilityDateFormatter.string(from: statusService.status.displayStatus.createdAt) } var applicationName: String? { statusService.status.displayStatus.application?.name } var applicationURL: URL? { guard let website = statusService.status.displayStatus.application?.website else { return nil } return URL(string: website) } var mentions: [Mention] { statusService.status.displayStatus.mentions } var visibility: Status.Visibility { statusService.status.displayStatus.visibility } var repliesCount: Int { statusService.status.displayStatus.repliesCount } var reblogsCount: Int { statusService.status.displayStatus.reblogsCount } var favoritesCount: Int { statusService.status.displayStatus.favouritesCount } var reblogged: Bool { statusService.status.displayStatus.reblogged } var favorited: Bool { statusService.status.displayStatus.favourited } var bookmarked: Bool { statusService.status.displayStatus.bookmarked } var sensitive: Bool { statusService.status.displayStatus.sensitive } var pinned: Bool? { statusService.status.displayStatus.pinned } var muted: Bool { statusService.status.displayStatus.muted } var sharingURL: URL? { guard let urlString = statusService.status.displayStatus.url else { return nil } return URL(string: urlString) } var isPollExpired: Bool { statusService.status.displayStatus.poll?.expired ?? true } var hasVotedInPoll: Bool { statusService.status.displayStatus.poll?.voted ?? false } var isPollMultipleSelection: Bool { statusService.status.displayStatus.poll?.multiple ?? false } var pollOptions: [Poll.Option] { statusService.status.displayStatus.poll?.options ?? [] } var pollVotersCount: Int { guard let poll = statusService.status.displayStatus.poll else { return 0 } return poll.votersCount ?? poll.votesCount } var pollOwnVotes: Set { Set(statusService.status.displayStatus.poll?.ownVotes ?? []) } var pollTimeLeft: String? { guard let expiresAt = statusService.status.displayStatus.poll?.expiresAt, expiresAt > Date() else { return nil } return expiresAt.fullUnitTimeUntil } var cardViewModel: CardViewModel? { if let card = statusService.status.displayStatus.card { return CardViewModel(card: card) } else { return nil } } var canBeReblogged: Bool { switch statusService.status.displayStatus.visibility { case .direct, .private: return false default: return true } } func toggleShowContent() { eventsSubject.send( statusService.toggleShowContent() .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } func toggleShowAttachments() { eventsSubject.send( statusService.toggleShowAttachments() .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } func urlSelected(_ url: URL) { eventsSubject.send( statusService.navigationService.item(url: url) .map { .navigation($0) } .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func accountSelected() { eventsSubject.send( Just(.navigation( .profile( statusService.navigationService.profileService( account: statusService.status.displayStatus.account)))) .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func rebloggerAccountSelected() { eventsSubject.send( Just(.navigation( .profile( statusService.navigationService.profileService( account: statusService.status.account)))) .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func rebloggedBySelected() { eventsSubject.send( Just(.navigation(.collection(statusService.rebloggedByService()))) .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func favoritedBySelected() { eventsSubject.send( Just(.navigation(.collection(statusService.favoritedByService()))) .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func reply() { let replyViewModel = Self(statusService: statusService, identityContext: identityContext, eventsSubject: .init()) replyViewModel.configuration = configuration.reply() eventsSubject.send( Just(.compose(inReplyTo: replyViewModel)) .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func toggleReblogged() { eventsSubject.send( statusService.toggleReblogged() .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } func toggleFavorited() { eventsSubject.send( statusService.toggleFavorited() .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } func toggleBookmarked() { eventsSubject.send( statusService.toggleBookmarked() .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } func togglePinned() { eventsSubject.send( statusService.togglePinned() .collect() .map { _ in .refresh } .eraseToAnyPublisher()) } func toggleMuted() { eventsSubject.send( statusService.toggleMuted() .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } func confirmDelete(redraft: Bool) { eventsSubject.send( Just(.confirmDelete(self, redraft: redraft)) .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func delete() { eventsSubject.send( statusService.delete() .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } func deleteAndRedraft() { let identityContext = self.identityContext eventsSubject.send( statusService.deleteAndRedraft() .map { redraft, inReplyToStatusService in let inReplyToViewModel: StatusViewModel? if let inReplyToStatusService = inReplyToStatusService { inReplyToViewModel = Self( statusService: inReplyToStatusService, identityContext: identityContext, eventsSubject: .init()) inReplyToViewModel?.configuration = CollectionItem.StatusConfiguration.default.reply() } else { inReplyToViewModel = nil } return .compose(inReplyTo: inReplyToViewModel, redraft: redraft) } .eraseToAnyPublisher()) } func attachmentSelected(viewModel: AttachmentViewModel) { if viewModel.attachment.type == .unknown, let remoteUrl = viewModel.attachment.remoteUrl { urlSelected(remoteUrl) } else { eventsSubject.send(Just(.attachment(viewModel, self)).setFailureType(to: Error.self).eraseToAnyPublisher()) } } func shareStatus() { guard let urlString = statusService.status.displayStatus.url, let url = URL(string: urlString) else { return } eventsSubject.send(Just(.share(url)).setFailureType(to: Error.self).eraseToAnyPublisher()) } func reportStatus() { eventsSubject.send( Just(.report(ReportViewModel( accountService: statusService.navigationService.accountService( account: statusService.status.displayStatus.account), statusId: statusService.status.displayStatus.id, identityContext: identityContext))) .setFailureType(to: Error.self) .eraseToAnyPublisher()) } func vote() { eventsSubject.send( statusService.vote(selectedOptions: pollOptionSelections) .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } func refreshPoll() { eventsSubject.send( statusService.refreshPoll() .map { _ in .ignorableOutput } .eraseToAnyPublisher()) } } private extension StatusViewModel { private static let contextParentDateFormatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .short return dateFormatter }() private static let contextParentAccessibilityDateFormatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .long dateFormatter.timeStyle = .short return dateFormatter }() }