// // StatusView+Configuration.swift // Mastodon // // Created by MainasuK on 2022-1-12. // import UIKit import Combine import MastodonSDK import MastodonCore import MastodonLocalization import MastodonMeta import Meta import NaturalLanguage extension StatusView { static let statusFilterWorkingQueue = DispatchQueue(label: "StatusFilterWorkingQueue") } extension StatusView { public func configure(status: Mastodon.Entity.Status, statusEdit: Mastodon.Entity.StatusEdit) { viewModel.objects.append(status) if let reblog = status.reblog { viewModel.objects.append(reblog) } configureHeader(status: status) let author = (status.reblog ?? status).account configureAuthor(author: author) let timestamp = (status.reblog ?? status).publisher(for: \.createdAt) configureTimestamp(timestamp: timestamp.eraseToAnyPublisher()) configureApplicationName(status.application?.name) configureMedia(status: .from(status: status)) configurePollHistory(statusEdit: statusEdit) configureCard(status: status) configureToolbar(status: status) configureFilter(status: status) configureContent(statusEdit: statusEdit, status: status) configureMedia(status: .from(statusEdit: statusEdit)) actionToolbarAdaptiveMarginContainerView.isHidden = true authorView.menuButton.isHidden = true headerAdaptiveMarginContainerView.isHidden = true viewModel.isSensitiveToggled = true viewModel.isContentReveal = true } public func configure(status: Mastodon.Entity.Status) { viewModel.objects.append(status) if let reblog = status.reblog { viewModel.objects.append(reblog) } configureHeader(status: status) let author = (status.reblog ?? status).account configureAuthor(author: author) let timestamp = (status.reblog ?? status).publisher(for: \.createdAt) configureTimestamp(timestamp: timestamp.eraseToAnyPublisher()) configureApplicationName(status.application?.name) configureContent(status: status) configureMedia(status: .from(status: status)) configurePoll(status: status) configureCard(status: status) configureToolbar(status: status) configureFilter(status: status) viewModel.originalStatus = status [ status.publisher(for: \.translatedContent), status.reblog?.publisher(for: \.translatedContent) ].compactMap { $0 } .last? .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.configureTranslated(status: status) } .store(in: &disposeBag) } } extension StatusView { private func configureHeader(status: Mastodon.Entity.Status) { if let _ = status.reblog { Publishers.CombineLatest( status.account.publisher(for: \.displayName), status.account.publisher(for: \.emojis) ) .map { name, emojis -> StatusView.ViewModel.Header in let text = L10n.Common.Controls.Status.userReblogged(status.account.displayNameWithFallback) let content = MastodonContent(content: text, emojis: emojis?.asDictionary ?? [:]) do { let metaContent = try MastodonMetaContent.convert(document: content) return .repost(info: .init(header: metaContent)) } catch { let metaContent = PlaintextMetaContent(string: name) return .repost(info: .init(header: metaContent)) } } .assign(to: \.header, on: viewModel) .store(in: &disposeBag) } else if let _ = status.inReplyToID, let inReplyToAccountID = status.inReplyToAccountID { func createHeader( name: String?, emojis: MastodonContent.Emojis? ) -> ViewModel.Header { let fallbackMetaContent = PlaintextMetaContent(string: L10n.Common.Controls.Status.userRepliedTo("-")) let fallbackReplyHeader = ViewModel.Header.reply(info: .init(header: fallbackMetaContent)) guard let name = name, let emojis = emojis else { return fallbackReplyHeader } let content = MastodonContent(content: L10n.Common.Controls.Status.userRepliedTo(name), emojis: emojis) guard let metaContent = try? MastodonMetaContent.convert(document: content) else { return fallbackReplyHeader } let header = ViewModel.Header.reply(info: .init(header: metaContent)) return header } if let replyTo = status.inReplyToID, let authBox = viewModel.authContext?.mastodonAuthenticationBox { Task { let replyStatus = try await Mastodon.API.Statuses.status( session: URLSession.shared, domain: authBox.domain, statusID: replyTo, authorization: authBox.userAuthorization ).singleOutput().value // A. replyTo status exist let header = createHeader(name: replyStatus.account.displayNameWithFallback, emojis: replyStatus.account.emojis?.asDictionary ?? [:]) viewModel.header = header } } else if let authBox = viewModel.authContext?.mastodonAuthenticationBox { // B. replyTo status not exist Task { let user = try await Mastodon.API.Account.accountInfo( session: URLSession.shared, domain: authBox.domain, userID: inReplyToAccountID, authorization: authBox.userAuthorization ).singleOutput().value let header = createHeader(name: user.displayNameWithFallback, emojis: user.emojis?.asDictionary ?? [:]) viewModel.header = header } // let request = MastodonUser.sortedFetchRequest // request.predicate = MastodonUser.predicate(domain: status.domain, id: inReplyToAccountID) // if let user = status.managedObjectContext?.safeFetch(request).first { // // B1. replyTo user exist // // } else { // // B2. replyTo user not exist // let header = createHeader(name: nil, emojis: nil) // viewModel.header = header // // if let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox { // Just(inReplyToAccountID) // .asyncMap { userID in // return try await Mastodon.API.Account.accountInfo( // session: .shared, // domain: authenticationBox.domain, // userID: userID, // authorization: authenticationBox.userAuthorization // ).singleOutput() // } // .receive(on: DispatchQueue.main) // .sink { completion in // // do nothing // } receiveValue: { [weak self] response in // guard let self = self else { return } // let user = response.value // let header = createHeader(name: user.displayNameWithFallback, emojis: user.emojiMeta) // self.viewModel.header = header // } // .store(in: &disposeBag) // } // end if let // } // end else B2. } // end else B. } else { viewModel.header = .none } } public func configureAuthor(author: Mastodon.Entity.Account) { // author avatar Publishers.CombineLatest( author.publisher(for: \.avatar), UserDefaults.shared.publisher(for: \.preferredStaticAvatar) ) .map { _ in author.avatarImageURL() } .assign(to: \.authorAvatarImageURL, on: viewModel) .store(in: &disposeBag) // author name Publishers.CombineLatest( author.publisher(for: \.displayName), author.publisher(for: \.emojis) ) .map { _, emojis in do { let content = MastodonContent(content: author.displayNameWithFallback, emojis: author.emojis?.asDictionary ?? [:]) let metaContent = try MastodonMetaContent.convert(document: content) return metaContent } catch { assertionFailure(error.localizedDescription) return PlaintextMetaContent(string: author.displayNameWithFallback) } } .assign(to: \.authorName, on: viewModel) .store(in: &disposeBag) // author username author.publisher(for: \.acct) .map { $0 as String? } .assign(to: \.authorUsername, on: viewModel) .store(in: &disposeBag) // locked author.publisher(for: \.locked) .assign(to: \.locked, on: viewModel) .store(in: &disposeBag) // isMuting author.publisher(for: \.mutingBy) .map { [weak viewModel] mutingBy in guard let viewModel = viewModel else { return false } guard let authContext = viewModel.authContext else { return false } return mutingBy.contains(where: { $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain }) } .assign(to: \.isMuting, on: viewModel) .store(in: &disposeBag) // isBlocking author.publisher(for: \.blockingBy) .map { [weak viewModel] blockingBy in guard let viewModel = viewModel else { return false } guard let authContext = viewModel.authContext else { return false } return blockingBy.contains(where: { $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain }) } .assign(to: \.isBlocking, on: viewModel) .store(in: &disposeBag) // isMyself Publishers.CombineLatest( author.publisher(for: \.domain), author.publisher(for: \.id) ) .map { [weak viewModel] domain, id in guard let viewModel = viewModel else { return false } guard let authContext = viewModel.authContext else { return false } return authContext.mastodonAuthenticationBox.domain == domain && authContext.mastodonAuthenticationBox.userID == id } .assign(to: \.isMyself, on: viewModel) .store(in: &disposeBag) // Following author.publisher(for: \.followingBy) .map { [weak viewModel] followingBy in guard let viewModel = viewModel else { return false } guard let authContext = viewModel.authContext else { return false } return followingBy.contains(where: { $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain }) } .assign(to: \.isFollowed, on: viewModel) .store(in: &disposeBag) } private func configureTimestamp(timestamp: AnyPublisher) { // timestamp viewModel.timestampFormatter = { (date: Date, isEdited: Bool) in if isEdited { return L10n.Common.Controls.Status.editedAtTimestampPrefix(date.localizedSlowedTimeAgoSinceNow) } return date.localizedSlowedTimeAgoSinceNow } timestamp .map { $0 as Date? } .assign(to: \.timestamp, on: viewModel) .store(in: &disposeBag) } private func configureApplicationName(_ applicationName: String?) { viewModel.applicationName = applicationName } public func revertTranslation() { guard let originalStatus = viewModel.originalStatus else { return } viewModel.translatedFromLanguage = nil viewModel.translatedUsingProvider = nil originalStatus.reblog?.update(translatedContent: nil) originalStatus.update(translatedContent: nil) configure(status: originalStatus) } func configureTranslated(status: Mastodon.Entity.Status) { let translatedContent: Mastodon.Entity.Status.TranslatedContent? = { if let translatedContent = status.reblog?.translatedContent { return translatedContent } return status.translatedContent }() guard let translatedContent = translatedContent else { viewModel.isCurrentlyTranslating = false return } // content do { let content = MastodonContent(content: translatedContent.content, emojis: status.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent viewModel.translatedFromLanguage = status.reblog?.language ?? status.language viewModel.translatedUsingProvider = status.reblog?.translatedContent?.provider ?? status.translatedContent?.provider viewModel.isCurrentlyTranslating = false } catch { assertionFailure(error.localizedDescription) viewModel.content = PlaintextMetaContent(string: "") } } private func configureContent(statusEdit: Mastodon.Entity.StatusEdit, status: Mastodon.Entity.Status) { statusEdit.spoilerText.map { viewModel.spoilerContent = PlaintextMetaContent(string: $0) } // language viewModel.language = (status.reblog ?? status).language // content do { let content = MastodonContent(content: statusEdit.content, emojis: statusEdit.emojis?.asDictionary ?? [:]) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent viewModel.translatedFromLanguage = nil viewModel.isCurrentlyTranslating = false } catch { assertionFailure(error.localizedDescription) viewModel.content = PlaintextMetaContent(string: "") } } private func configureContent(status: Mastodon.Entity.Status) { guard status.translatedContent == nil else { return configureTranslated(status: status) } let status = status.reblog ?? status // spoilerText if let spoilerText = status.spoilerText, !spoilerText.isEmpty { do { let content = MastodonContent(content: spoilerText, emojis: status.emojis?.asDictionary ?? [:]) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.spoilerContent = metaContent } catch { assertionFailure(error.localizedDescription) viewModel.spoilerContent = PlaintextMetaContent(string: "") } } else { viewModel.spoilerContent = nil } // language viewModel.language = (status.reblog ?? status).language // content do { let content = MastodonContent(content: status.content, emojis: status.emojis?.asDictionary ?? [:]) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent viewModel.translatedFromLanguage = nil viewModel.isCurrentlyTranslating = false } catch { assertionFailure(error.localizedDescription) viewModel.content = PlaintextMetaContent(string: "") } // visibility status.publisher(for: \.visibility) .map { $0 ?? .public } .assign(to: \.visibility, on: viewModel) .store(in: &disposeBag) // sensitive viewModel.isContentSensitive = status.isContentSensitive status.publisher(for: \.isSensitiveToggled) .assign(to: \.isSensitiveToggled, on: viewModel) .store(in: &disposeBag) } private func configureMedia(status: StatusCompatible) { let status = status.reblog ?? status viewModel.isMediaSensitive = status.isMediaSensitive let configurations = MediaView.configuration(status: status) viewModel.mediaViewConfigurations = configurations } private func configurePollHistory(statusEdit: Mastodon.Entity.StatusEdit) { guard let poll = statusEdit.poll else { return } let pollItems = poll.options.map { PollItem.history(option: $0) } self.viewModel.pollItems = pollItems pollStatusStackView.isHidden = true var _snapshot = NSDiffableDataSourceSnapshot() _snapshot.appendSections([.main]) _snapshot.appendItems(pollItems, toSection: .main) pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot) } private func configurePoll(status: Mastodon.Entity.Status) { let status = status.reblog ?? status if let poll = status.poll { viewModel.objects.insert(poll) } // pollItems status.publisher(for: \.poll) .sink { [weak self] poll in guard let self = self else { return } guard let poll = poll else { self.viewModel.pollItems = [] return } let options = poll.options.sorted(by: { $0.index < $1.index }) let items: [PollItem] = options.map { .option(record: .init(objectID: $0.objectID)) } self.viewModel.pollItems = items } .store(in: &disposeBag) // isVoteButtonEnabled status.poll?.publisher(for: \.updatedAt) .sink { [weak self] _ in guard let self = self else { return } guard let poll = status.poll else { return } let options = poll.options let hasSelectedOption = options.contains(where: { $0.isSelected }) self.viewModel.isVoteButtonEnabled = hasSelectedOption } .store(in: &disposeBag) // isVotable if let poll = status.poll { Publishers.CombineLatest( poll.publisher(for: \.votedBy), poll.publisher(for: \.expired) ) .map { [weak viewModel] votedBy, expired in guard let viewModel = viewModel else { return false } guard let authContext = viewModel.authContext else { return false } let domain = authContext.mastodonAuthenticationBox.domain let userID = authContext.mastodonAuthenticationBox.userID let isVoted = votedBy?.contains(where: { $0.domain == domain && $0.id == userID }) ?? false return !isVoted && !expired } .assign(to: &viewModel.$isVotable) } // votesCount status.poll?.publisher(for: \.votesCount) .map { Int($0) } .assign(to: \.voteCount, on: viewModel) .store(in: &disposeBag) // voterCount status.poll?.publisher(for: \.votersCount) .map { Int($0) } .assign(to: \.voterCount, on: viewModel) .store(in: &disposeBag) // expireAt status.poll?.publisher(for: \.expiresAt) .assign(to: \.expireAt, on: viewModel) .store(in: &disposeBag) // expired status.poll?.publisher(for: \.expired) .assign(to: \.expired, on: viewModel) .store(in: &disposeBag) // isVoting status.poll?.publisher(for: \.isVoting) .assign(to: \.isVoting, on: viewModel) .store(in: &disposeBag) } private func configureCard(status: Mastodon.Entity.Status) { let status = status.reblog ?? status if viewModel.mediaViewConfigurations.isEmpty { status.publisher(for: \.card) .assign(to: \.card, on: viewModel) .store(in: &disposeBag) } else { viewModel.card = nil } } private func configureToolbar(status: Mastodon.Entity.Status) { let status = status.reblog ?? status status.publisher(for: \.repliesCount) .assign(to: \.replyCount, on: viewModel) .store(in: &disposeBag) status.publisher(for: \.reblogsCount) emojis?.asDictionary ?? [:] .assign(to: \.reblogCount, on: viewModel) .store(in: &disposeBag) status.publisher(for: \.favouritesCount) .assign(to: \.favoriteCount, on: viewModel) .store(in: &disposeBag) status.publisher(for: \.editedAt) .assign(to: \.editedAt, on: viewModel) .store(in: &disposeBag) // status.publisher(for: \.editHistory) // .compactMap({ guard let edits = $0 else { return nil } // //TODO: @zeitschlag get edits here // return Array(edits) // }) // .assign(to: \.statusEdits, on: viewModel) // .store(in: &disposeBag) // relationship status.publisher(for: \.reblogged) .assign(to: \.isReblog, on: viewModel) .store(in: &disposeBag) status.publisher(for: \.favourited) .assign(to: \.isFavorite, on: viewModel) .store(in: &disposeBag) status.publisher(for: \.bookmarked) .assign(to: \.isBookmark, on: viewModel) .store(in: &disposeBag) } private func configureFilter(status: Mastodon.Entity.Status) { let status = status.reblog ?? status let content = status.content.lowercased() Publishers.CombineLatest( viewModel.$activeFilters, viewModel.$filterContext ) .receive(on: StatusView.statusFilterWorkingQueue) .map { filters, filterContext in var wordFilters: [Mastodon.Entity.Filter] = [] var nonWordFilters: [Mastodon.Entity.Filter] = [] for filter in filters { guard filter.context.contains(where: { $0 == filterContext }) else { continue } if filter.wholeWord { wordFilters.append(filter) } else { nonWordFilters.append(filter) } } var needsFilter = false for filter in nonWordFilters { guard content.contains(filter.phrase.lowercased()) else { continue } needsFilter = true break } if needsFilter { return true } let tokenizer = NLTokenizer(unit: .word) tokenizer.string = content let phraseWords = wordFilters.map { $0.phrase.lowercased() } tokenizer.enumerateTokens(in: content.startIndex..