2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00
mastodon-ios/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift
2024-04-17 16:36:03 +02:00

503 lines
19 KiB
Swift

//
// StatusView+Configuration.swift
// Mastodon
//
// Created by MainasuK on 2022-1-12.
//
import UIKit
import Combine
import CoreDataStack
import MastodonSDK
import MastodonCore
import MastodonLocalization
import MastodonMeta
import Meta
import NaturalLanguage
extension StatusView {
static let statusFilterWorkingQueue = DispatchQueue(label: "StatusFilterWorkingQueue")
public func configure(feed: MastodonFeed) {
switch feed.kind {
case .home:
guard let status = feed.status else {
assertionFailure()
return
}
configure(status: status)
case .notificationAll:
assertionFailure("TODO")
case .notificationMentions:
assertionFailure("TODO")
case .none:
break
}
}
}
extension StatusView {
public func configure(status: MastodonStatus, statusEdit: Mastodon.Entity.StatusEdit) {
configureHeader(status: status)
let author = (status.reblog ?? status).entity.account
configureAuthor(author: author)
configureTimestamp(timestamp: (status.reblog ?? status).entity.createdAt)
configureApplicationName(status.entity.application?.name)
configureMedia(status: status)
configurePollHistory(statusEdit: statusEdit)
configureCard(status: status)
configureToolbar(status: status)
configureFilter(status: status)
configureContent(statusEdit: statusEdit, status: status)
configureMedia(status: statusEdit)
actionToolbarAdaptiveMarginContainerView.isHidden = true
authorView.menuButton.isHidden = true
headerAdaptiveMarginContainerView.isHidden = true
viewModel.isSensitiveToggled = true
viewModel.isContentReveal = true
}
public func configure(status: MastodonStatus) {
configureHeader(status: status)
let author = (status.reblog ?? status).entity.account
configureAuthor(author: author)
let timestamp = (status.reblog ?? status).entity.createdAt
configureTimestamp(timestamp: timestamp)
configureApplicationName(status.entity.application?.name)
configureContent(status: status)
configureMedia(status: status)
configurePoll(status: status)
configureCard(status: status)
configureToolbar(status: status)
configureFilter(status: status)
viewModel.originalStatus = status
viewModel.$translation
.receive(on: DispatchQueue.main)
.sink { [weak self] translation in
self?.configureTranslated(status: status)
}
.store(in: &disposeBag)
}
}
extension StatusView {
private func configureHeader(status: MastodonStatus) {
if status.entity.reblogged == true,
let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox,
let account = authenticationBox.authentication.account() {
let name = account.displayNameWithFallback
let emojis = account.emojis
viewModel.header = {
let text = L10n.Common.Controls.Status.userReblogged(name)
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))
}
}()
} else if status.reblog != nil {
let name = status.entity.account.displayNameWithFallback
let emojis = status.entity.account.emojis
viewModel.header = {
let text = L10n.Common.Controls.Status.userReblogged(name)
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))
}
}()
} else if let _ = status.entity.inReplyToID,
let inReplyToAccountID = status.entity.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 inReplyToID = status.entity.inReplyToID {
// A. replyTo status exist
/// we need to initially set an empty header, otherwise the layout gets messed up
viewModel.header = createHeader(name: "", emojis: [:])
/// finally we can load the status information and display the correct header
if let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox {
Mastodon.API.Statuses.status(
session: .shared,
domain: authenticationBox.domain,
statusID: inReplyToID,
authorization: authenticationBox.userAuthorization
)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
// no-op
}, receiveValue: { [weak self] response in
guard let self else { return }
let replyTo = response.value
let header = createHeader(name: replyTo.account.displayNameWithFallback, emojis: replyTo.account.emojis.asDictionary)
self.viewModel.header = header
})
.store(in: &disposeBag)
}
} else {
// B. replyTo status 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 B.
} else {
viewModel.header = .none
}
}
public func configureAuthor(author: Mastodon.Entity.Account) {
Task { @MainActor in
// author avatar
viewModel.authorAvatarImageURL = author.avatarImageURL()
let emojis = author.emojis.asDictionary
// author name
viewModel.authorName = {
do {
let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis)
let metaContent = try MastodonMetaContent.convert(document: content)
return metaContent
} catch {
assertionFailure(error.localizedDescription)
return PlaintextMetaContent(string: author.displayNameWithFallback)
}
}()
viewModel.authorId = author.id
// author username
viewModel.authorUsername = author.acct
// locked
viewModel.locked = author.locked
// isMyself
viewModel.isMyself = {
guard let authContext = viewModel.authContext else { return false }
return authContext.mastodonAuthenticationBox.domain == author.domain && authContext.mastodonAuthenticationBox.userID == author.id
}()
// isMuting, isBlocking, Following
guard viewModel.authContext?.mastodonAuthenticationBox != nil else { return }
guard !viewModel.isMyself else {
viewModel.isMuting = false
viewModel.isBlocking = false
viewModel.isFollowed = false
return
}
}
}
private func configureTimestamp(timestamp: Date) {
// timestamp
viewModel.timestampFormatter = { (date: Date, isEdited: Bool) in
if isEdited {
return L10n.Common.Controls.Status.editedAtTimestampPrefix(date.localizedAbbreviatedSlowedTimeAgoSinceNow)
} else {
return date.localizedAbbreviatedSlowedTimeAgoSinceNow
}
}
viewModel.timestamp = timestamp
}
private func configureApplicationName(_ applicationName: String?) {
viewModel.applicationName = applicationName
}
public func revertTranslation() {
guard let originalStatus = viewModel.originalStatus else { return }
viewModel.translation = nil
configure(status: originalStatus)
}
func configureTranslated(status: MastodonStatus) {
guard let translation = viewModel.translation,
let translatedContent = translation.content else {
viewModel.isCurrentlyTranslating = false
return
}
// content
do {
let content = MastodonContent(content: translatedContent, emojis: status.entity.emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.isCurrentlyTranslating = false
} catch {
assertionFailure(error.localizedDescription)
viewModel.content = PlaintextMetaContent(string: "")
}
}
private func configureContent(statusEdit: Mastodon.Entity.StatusEdit, status: MastodonStatus) {
statusEdit.spoilerText.map {
viewModel.spoilerContent = PlaintextMetaContent(string: $0)
}
// language
viewModel.language = (status.reblog ?? status).entity.language
// content
do {
let content = MastodonContent(content: statusEdit.content, emojis: statusEdit.emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.isCurrentlyTranslating = false
} catch {
assertionFailure(error.localizedDescription)
viewModel.content = PlaintextMetaContent(string: "")
}
}
private func configureContent(status: MastodonStatus) {
guard viewModel.translation == nil else {
return configureTranslated(status: status)
}
let status = status.reblog ?? status
// spoilerText
if let spoilerText = status.entity.spoilerText, !spoilerText.isEmpty {
do {
let content = MastodonContent(content: spoilerText, emojis: status.entity.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).entity.language
// content
do {
let content = MastodonContent(content: status.entity.content ?? "", emojis: status.entity.emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.isCurrentlyTranslating = false
} catch {
assertionFailure(error.localizedDescription)
viewModel.content = PlaintextMetaContent(string: "")
}
// visibility
viewModel.visibility = status.entity.mastodonVisibility
// sensitive
viewModel.isContentSensitive = status.entity.sensitive == true
viewModel.isSensitiveToggled = status.isSensitiveToggled
}
private func configureMedia(status: MastodonStatus) {
let status = status.reblog ?? status
viewModel.isMediaSensitive = status.entity.sensitive == true
let configurations = MediaView.configuration(status: status)
viewModel.mediaViewConfigurations = configurations
}
private func configureMedia(status: Mastodon.Entity.StatusEdit) {
viewModel.isMediaSensitive = status.sensitive
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<PollSection, PollItem>()
_snapshot.appendSections([.main])
_snapshot.appendItems(pollItems, toSection: .main)
pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot)
}
private func configurePoll(status: MastodonStatus) {
let status = status.reblog ?? status
guard let poll = status.poll else {
return
}
let options = poll.options
let items: [PollItem] = options.map { .option(record: $0) }
self.viewModel.pollItems = items
let hasSelectedOption = options.contains(where: { $0.isSelected == true })
viewModel.isVoteButtonEnabled = hasSelectedOption
Publishers.CombineLatest(
poll.$voted,
poll.$expired
)
.map { voted, expired in
return voted == false && expired == false
}
.assign(to: &viewModel.$isVotable)
poll.$votesCount
.assign(to: \.voteCount, on: viewModel)
.store(in: &disposeBag)
poll.$votersCount
.assign(to: \.voterCount, on: viewModel)
.store(in: &disposeBag)
poll.$expiresAt
.assign(to: \.expireAt, on: viewModel)
.store(in: &disposeBag)
poll.$expired
.assign(to: \.expired, on: viewModel)
.store(in: &disposeBag)
poll.$voted
.map { $0 == true }
.assign(to: \.isVoting, on: viewModel)
.store(in: &disposeBag)
}
private func configureCard(status: MastodonStatus) {
let status = status.reblog ?? status
if viewModel.mediaViewConfigurations.isEmpty {
viewModel.card = status.entity.card
} else {
viewModel.card = nil
}
}
private func configureToolbar(status: MastodonStatus) {
let status = status.reblog ?? status
viewModel.replyCount = status.entity.repliesCount ?? 0
viewModel.reblogCount = status.entity.reblogsCount
viewModel.favoriteCount = status.entity.favouritesCount
viewModel.editedAt = status.entity.editedAt
// relationship
viewModel.isReblog = status.entity.reblogged == true
viewModel.isFavorite = status.entity.favourited == true
viewModel.isBookmark = status.entity.bookmarked == true
}
private func configureFilter(status: MastodonStatus) {
let status = status.reblog ?? status
guard let content = status.entity.content?.lowercased() else { return }
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..<content.endIndex) { range, _ in
let word = String(content[range])
if phraseWords.contains(word) {
needsFilter = true
return false
} else {
return true
}
}
return needsFilter
}
.receive(on: DispatchQueue.main)
.assign(to: \.isFiltered, on: viewModel)
.store(in: &disposeBag)
}
}