Media refactoring

This commit is contained in:
Justin Mazzocchi 2020-10-22 15:16:06 -07:00
parent 264881f9b0
commit ff2f813280
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
18 changed files with 168 additions and 114 deletions

View file

@ -1,10 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
extension AppPreferences {
var shouldReduceMotion: Bool {
UIAccessibility.isReduceMotionEnabled && useSystemReduceMotionForMedia
}
}

View file

@ -27,7 +27,6 @@
D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */; };
D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */; };
D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; };
D0A3C2F725390A9700739F88 /* AppPreferences+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */; };
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */; };
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B8510B25259E56004E0744 /* LoadMoreCell.swift */; };
@ -131,7 +130,6 @@
D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomTransitionController.swift; sourceTree = "<group>"; };
D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomAnimatableView.swift; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPreferences+Extensions.swift"; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = "<group>"; };
@ -384,7 +382,6 @@
D0C7D46824F76169001EBDBB /* Extensions */ = {
isa = PBXGroup;
children = (
D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */,
D01C6FAB252024BD003D0300 /* Array+Extensions.swift */,
D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */,
D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */,
@ -614,7 +611,6 @@
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
D0A3C2F725390A9700739F88 /* AppPreferences+Extensions.swift in Sources */,
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */,
D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */,
D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */,

View file

@ -13,6 +13,7 @@ public struct AppEnvironment {
let keychain: Keychain.Type
let userDefaults: UserDefaults
let userNotificationClient: UserNotificationClient
let reduceMotion: () -> Bool
let uuid: () -> UUID
let inMemoryContent: Bool
let fixtureDatabase: IdentityDatabase?
@ -22,6 +23,7 @@ public struct AppEnvironment {
keychain: Keychain.Type,
userDefaults: UserDefaults,
userNotificationClient: UserNotificationClient,
reduceMotion: @escaping () -> Bool,
uuid: @escaping () -> UUID,
inMemoryContent: Bool,
fixtureDatabase: IdentityDatabase?) {
@ -30,6 +32,7 @@ public struct AppEnvironment {
self.keychain = keychain
self.userDefaults = userDefaults
self.userNotificationClient = userNotificationClient
self.reduceMotion = reduceMotion
self.uuid = uuid
self.inMemoryContent = inMemoryContent
self.fixtureDatabase = fixtureDatabase
@ -37,13 +40,14 @@ public struct AppEnvironment {
}
public extension AppEnvironment {
static func live(userNotificationCenter: UNUserNotificationCenter) -> Self {
static func live(userNotificationCenter: UNUserNotificationCenter, reduceMotion: @escaping () -> Bool) -> Self {
Self(
session: URLSession.shared,
webAuthSessionType: LiveWebAuthSession.self,
keychain: LiveKeychain.self,
userDefaults: .standard,
userNotificationClient: .live(userNotificationCenter),
reduceMotion: reduceMotion,
uuid: UUID.init,
inMemoryContent: false,
fixtureDatabase: nil)

View file

@ -5,9 +5,11 @@ import Foundation
public struct AppPreferences {
private let userDefaults: UserDefaults
private let systemReduceMotion: () -> Bool
public init(environment: AppEnvironment) {
self.userDefaults = environment.userDefaults
self.systemReduceMotion = environment.reduceMotion
}
}
@ -73,6 +75,10 @@ public extension AppPreferences {
}
set { self[.autoplayVideos] = newValue.rawValue }
}
var shouldReduceMotion: Bool {
systemReduceMotion() && useSystemReduceMotionForMedia
}
}
extension AppPreferences {

View file

@ -23,6 +23,7 @@ public extension AppEnvironment {
keychain: keychain,
userDefaults: userDefaults,
userNotificationClient: userNotificationClient,
reduceMotion: { false },
uuid: uuid,
inMemoryContent: inMemoryContent,
fixtureDatabase: fixtureDatabase)

View file

@ -14,7 +14,9 @@ struct MetatextApp: App {
RootView(
// swiftlint:disable force_try
viewModel: try! RootViewModel(
environment: .live(userNotificationCenter: .current()),
environment: .live(
userNotificationCenter: .current(),
reduceMotion: { UIAccessibility.isReduceMotionEnabled }),
registerForRemoteNotifications: appDelegate.registerForRemoteNotifications))
// swiftlint:enable force_try
}

View file

@ -7,7 +7,7 @@ class ImagePageViewController: UIPageViewController {
let imageViewControllers: [ImageViewController]
init(initiallyVisible: AttachmentViewModel, statusViewModel: StatusViewModel) {
imageViewControllers = statusViewModel.attachmentViewModels.map(ImageViewController.init(viewModel:))
imageViewControllers = statusViewModel.attachmentViewModels.map { ImageViewController(viewModel: $0) }
super.init(
transitionStyle: .scroll,
@ -21,6 +21,17 @@ class ImagePageViewController: UIPageViewController {
setViewControllers([imageViewControllers[index ?? 0]], direction: .forward, animated: false)
}
init(imageURL: URL) {
imageViewControllers = [ImageViewController(imageURL: imageURL)]
super.init(
transitionStyle: .scroll,
navigationOrientation: .horizontal,
options: [.interPageSpacing: CGFloat.defaultSpacing])
setViewControllers(imageViewControllers, direction: .forward, animated: false)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")

View file

@ -9,13 +9,15 @@ class ImageViewController: UIViewController {
let imageView = AnimatedImageView()
let playerView = PlayerView()
private let viewModel: AttachmentViewModel
private let viewModel: AttachmentViewModel?
private let imageURL: URL?
private let contentView = UIView()
private let descriptionBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
private let descriptionTextView = UITextView()
init(viewModel: AttachmentViewModel) {
init(viewModel: AttachmentViewModel? = nil, imageURL: URL? = nil) {
self.viewModel = viewModel
self.imageURL = imageURL
super.init(nibName: nil, bundle: nil)
}
@ -51,21 +53,22 @@ class ImageViewController: UIViewController {
contentView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.kf.indicatorType = .activity
contentView.addSubview(playerView)
playerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(descriptionBackgroundView)
descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false
descriptionBackgroundView.isHidden = viewModel.attachment.description == nil
|| viewModel.attachment.description == ""
descriptionBackgroundView.isHidden = viewModel?.attachment.description == nil
|| viewModel?.attachment.description == ""
descriptionBackgroundView.contentView.addSubview(descriptionTextView)
descriptionTextView.translatesAutoresizingMaskIntoConstraints = false
descriptionTextView.backgroundColor = .clear
descriptionTextView.font = .preferredFont(forTextStyle: .caption1)
descriptionTextView.adjustsFontForContentSizeCategory = true
descriptionTextView.text = viewModel.attachment.description
descriptionTextView.text = viewModel?.attachment.description
descriptionTextView.isScrollEnabled = false
descriptionTextView.isEditable = false
@ -99,37 +102,40 @@ class ImageViewController: UIViewController {
descriptionTextView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])
switch viewModel.attachment.type {
case .image:
imageView.tag = viewModel.tag
if let viewModel = viewModel {
switch viewModel.attachment.type {
case .image:
imageView.tag = viewModel.tag
playerView.isHidden = true
imageView.kf.setImage(
with: viewModel.attachment.previewUrl,
options: [.onlyFromCache],
completionHandler: { [weak self] in
guard let self = self else { return }
if case .success = $0 {
self.imageView.kf.indicatorType = .none
}
self.imageView.kf.setImage(
with: viewModel.attachment.url,
options: [.keepCurrentImageWhileLoading])
})
case .gifv:
playerView.tag = viewModel.tag
imageView.isHidden = true
let player = PlayerCache.shared.player(url: viewModel.attachment.url)
player.isMuted = true
playerView.player = player
player.play()
default: break
}
} else if let imageURL = imageURL {
imageView.tag = imageURL.hashValue
playerView.isHidden = true
imageView.isHidden = false
imageView.kf.indicatorType = .activity
imageView.kf.setImage(
with: viewModel.attachment.previewUrl,
options: [.onlyFromCache],
completionHandler: { [weak self] in
guard let self = self else { return }
if case .success = $0 {
self.imageView.kf.indicatorType = .none
}
self.imageView.kf.setImage(
with: self.viewModel.attachment.url,
options: [.keepCurrentImageWhileLoading])
})
case .gifv:
playerView.tag = viewModel.tag
playerView.isHidden = false
imageView.isHidden = true
let player = PlayerCache.shared.player(url: viewModel.attachment.url)
player.isMuted = true
playerView.player = player
player.play()
default: break
imageView.kf.setImage(with: imageURL)
}
}
}

View file

@ -30,6 +30,19 @@ final class ProfileViewController: TableViewController {
}
.store(in: &cancellables)
viewModel.imagePresentations.sink { [weak self] in
guard let self = self else { return }
let imagePageViewController = ImagePageViewController(imageURL: $0)
let imageNavigationController = ImageNavigationController(imagePageViewController: imagePageViewController)
imageNavigationController.transitionController.fromDelegate = self
self.transitionViewTag = $0.hashValue
self.present(imageNavigationController, animated: true)
}
.store(in: &cancellables)
tableView.tableHeaderView = accountHeaderView
}
}

View file

@ -7,13 +7,14 @@ import SwiftUI
import ViewModels
class TableViewController: UITableViewController {
var transitionViewTag = -1
private let viewModel: CollectionViewModel
private let identification: Identification
private let loadingTableFooterView = LoadingTableFooterView()
private let webfingerIndicatorView = WebfingerIndicatorView()
private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
private var transitionViewTag = -1
private lazy var dataSource: TableViewDataSource = {
.init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:))
@ -201,7 +202,7 @@ extension TableViewController: ZoomAnimatorDelegate {
}
func referenceView(for zoomAnimator: ZoomAnimator) -> UIView? {
tableView.visibleCells.compactMap { $0.viewWithTag(transitionViewTag) }.first
view.viewWithTag(transitionViewTag)
}
func referenceViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? {

View file

@ -7,9 +7,9 @@ import ServiceLayer
public struct AccountViewModel: CollectionItemViewModel {
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
public let identification: Identification
private let accountService: AccountService
private let identification: Identification
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
init(accountService: AccountService, identification: Identification) {
@ -20,13 +20,13 @@ public struct AccountViewModel: CollectionItemViewModel {
}
public extension AccountViewModel {
var avatarURL: URL { accountService.account.avatar }
var avatarStaticURL: URL { accountService.account.avatarStatic }
var headerURL: URL { accountService.account.header }
var headerStaticURL: URL { accountService.account.headerStatic }
var headerURL: URL {
if !identification.appPreferences.shouldReduceMotion, identification.appPreferences.animateHeaders {
return accountService.account.header
} else {
return accountService.account.headerStatic
}
}
var displayName: String { accountService.account.displayName }
@ -36,6 +36,16 @@ public extension AccountViewModel {
var emoji: [Emoji] { accountService.account.emojis }
func avatarURL(profile: Bool = false) -> URL {
if !identification.appPreferences.shouldReduceMotion,
(identification.appPreferences.animateAvatars == .everywhere
|| identification.appPreferences.animateAvatars == .profiles && profile) {
return accountService.account.avatar
} else {
return accountService.account.avatarStatic
}
}
func urlSelected(_ url: URL) {
eventsSubject.send(
accountService.navigationService.item(url: url)

View file

@ -2,15 +2,18 @@
import Foundation
import Mastodon
import Network
public struct AttachmentViewModel {
public let attachment: Attachment
private let status: Status
private let identification: Identification
init(attachment: Attachment, status: Status) {
init(attachment: Attachment, status: Status, identification: Identification) {
self.attachment = attachment
self.status = status
self.identification = identification
}
}
@ -33,4 +36,22 @@ public extension AttachmentViewModel {
return nil
}
var shouldAutoplay: Bool {
switch attachment.type {
case .video:
return identification.appPreferences.autoplayVideos == .always
|| (identification.appPreferences.autoplayVideos == .wifi
&& Self.wifiMonitor.currentPath.status == .satisfied)
case .gifv:
return identification.appPreferences.autoplayGIFs == .always
|| (identification.appPreferences.autoplayGIFs == .wifi
&& Self.wifiMonitor.currentPath.status == .satisfied)
default: return false
}
}
}
private extension AttachmentViewModel {
static let wifiMonitor = NWPathMonitor(requiredInterfaceType: .wifi)
}

View file

@ -9,13 +9,16 @@ final public class ProfileViewModel {
@Published public private(set) var accountViewModel: AccountViewModel?
@Published public var collection = ProfileCollection.statuses
@Published public var alertItem: AlertItem?
public let imagePresentations: AnyPublisher<URL, Never>
private let profileService: ProfileService
private let collectionViewModel: CurrentValueSubject<CollectionItemsViewModel, Never>
private let imagePresentationsSubject = PassthroughSubject<URL, Never>()
private var cancellables = Set<AnyCancellable>()
public init(profileService: ProfileService, identification: Identification) {
self.profileService = profileService
imagePresentations = imagePresentationsSubject.eraseToAnyPublisher()
collectionViewModel = CurrentValueSubject(
CollectionItemsViewModel(
@ -40,6 +43,14 @@ final public class ProfileViewModel {
}
}
public extension ProfileViewModel {
func presentHeader() {
guard let accountViewModel = accountViewModel else { return }
imagePresentationsSubject.send(accountViewModel.headerURL)
}
}
extension ProfileViewModel: CollectionViewModel {
public var updates: AnyPublisher<CollectionUpdate, Never> {
collectionViewModel.flatMap(\.updates).eraseToAnyPublisher()

View file

@ -18,10 +18,10 @@ public struct StatusViewModel: CollectionItemViewModel {
public let pollOptionTitles: [String]
public let pollEmoji: [Emoji]
public var configuration = CollectionItem.StatusConfiguration.default
public let identification: Identification
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let statusService: StatusService
private let identification: Identification
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
init(statusService: StatusService, identification: Identification) {
@ -40,7 +40,7 @@ public struct StatusViewModel: CollectionItemViewModel {
: statusService.status.account.displayName
rebloggedByDisplayNameEmoji = statusService.status.account.emojis
attachmentViewModels = statusService.status.displayStatus.mediaAttachments
.map { AttachmentViewModel(attachment: $0, status: statusService.status) }
.map { AttachmentViewModel(attachment: $0, status: statusService.status, identification: identification) }
pollOptionTitles = statusService.status.displayStatus.poll?.options.map { $0.title } ?? []
pollEmoji = statusService.status.displayStatus.poll?.emojis ?? []
events = eventsSubject.eraseToAnyPublisher()
@ -75,9 +75,14 @@ public extension StatusViewModel {
var accountName: String { "@" + statusService.status.displayStatus.account.acct }
var avatarURL: URL { statusService.status.displayStatus.account.avatar }
var avatarStaticURL: URL { statusService.status.displayStatus.account.avatarStatic }
var avatarURL: URL {
if !identification.appPreferences.shouldReduceMotion,
identification.appPreferences.animateAvatars == .everywhere {
return statusService.status.displayStatus.account.avatar
} else {
return statusService.status.displayStatus.account.avatarStatic
}
}
var time: String? { statusService.status.displayStatus.createdAt.timeAgo }

View file

@ -5,23 +5,16 @@ import UIKit
import ViewModels
class AccountHeaderView: UIView {
let headerImageView = UIImageView()
let headerImageView = AnimatedImageView()
let headerButton = UIButton()
let noteTextView = TouchFallthroughTextView()
let segmentedControl = UISegmentedControl()
var viewModel: ProfileViewModel? {
didSet {
if let accountViewModel = viewModel?.accountViewModel {
let appPreferences = accountViewModel.identification.appPreferences
let headerURL: URL
if !appPreferences.shouldReduceMotion, appPreferences.animateHeaders {
headerURL = accountViewModel.headerURL
} else {
headerURL = accountViewModel.headerStaticURL
}
headerImageView.kf.setImage(with: headerURL)
headerImageView.kf.setImage(with: accountViewModel.headerURL)
headerImageView.tag = accountViewModel.headerURL.hashValue
let noteFont = UIFont.preferredFont(forTextStyle: .callout)
let mutableNote = NSMutableAttributedString(attributedString: accountViewModel.note)
@ -71,14 +64,25 @@ extension AccountHeaderView: UITextViewDelegate {
}
private extension AccountHeaderView {
// swiftlint:disable:next function_body_length
func initialSetup() {
let baseStackView = UIStackView()
addSubview(headerImageView)
addSubview(baseStackView)
headerImageView.translatesAutoresizingMaskIntoConstraints = false
headerImageView.contentMode = .scaleAspectFill
headerImageView.clipsToBounds = true
headerImageView.isUserInteractionEnabled = true
headerImageView.addSubview(headerButton)
headerButton.translatesAutoresizingMaskIntoConstraints = false
headerButton.setBackgroundImage(.highlightedButtonBackground, for: .highlighted)
headerButton.addAction(
UIAction { [weak self] _ in self?.viewModel?.presentHeader() },
for: .touchUpInside)
addSubview(baseStackView)
baseStackView.translatesAutoresizingMaskIntoConstraints = false
baseStackView.axis = .vertical
@ -111,6 +115,10 @@ private extension AccountHeaderView {
headerImageView.topAnchor.constraint(equalTo: topAnchor),
headerImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
headerImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
headerButton.leadingAnchor.constraint(equalTo: headerImageView.leadingAnchor),
headerButton.topAnchor.constraint(equalTo: headerImageView.topAnchor),
headerButton.bottomAnchor.constraint(equalTo: headerImageView.bottomAnchor),
headerButton.trailingAnchor.constraint(equalTo: headerImageView.trailingAnchor),
baseStackView.topAnchor.constraint(equalTo: headerImageView.bottomAnchor),
baseStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),

View file

@ -96,16 +96,7 @@ private extension AccountView {
}
func applyAccountConfiguration() {
let appPreferences = accountConfiguration.viewModel.identification.appPreferences
let avatarURL: URL
if !appPreferences.shouldReduceMotion && appPreferences.animateAvatars == .everywhere {
avatarURL = accountConfiguration.viewModel.avatarURL
} else {
avatarURL = accountConfiguration.viewModel.avatarStaticURL
}
avatarImageView.kf.setImage(with: avatarURL)
avatarImageView.kf.setImage(with: accountConfiguration.viewModel.avatarURL(profile: false))
if accountConfiguration.viewModel.displayName == "" {
displayNameLabel.isHidden = true

View file

@ -1,7 +1,6 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Network
import UIKit
import ViewModels
@ -86,20 +85,7 @@ extension StatusAttachmentsView {
var shouldAutoplay: Bool {
guard !isHidden, let viewModel = viewModel, viewModel.shouldShowAttachments else { return false }
let appPreferences = viewModel.identification.appPreferences
let onWifi = NWPathMonitor(requiredInterfaceType: .wifi).currentPath.status == .satisfied
let hasVideoAttachment = viewModel.attachmentViewModels.contains { $0.attachment.type == .video }
let shouldAutoplayVideo = appPreferences.autoplayVideos == .always
|| appPreferences.autoplayVideos == .wifi && onWifi
if hasVideoAttachment && shouldAutoplayVideo {
return true
}
let hasGIFAttachment = viewModel.attachmentViewModels.contains { $0.attachment.type == .gifv }
let shouldAutoplayGIF = appPreferences.autoplayGIFs == .always || appPreferences.autoplayGIFs == .wifi && onWifi
return hasGIFAttachment && shouldAutoplayGIF
return viewModel.attachmentViewModels.allSatisfy(\.shouldAutoplay)
}
}

View file

@ -292,22 +292,14 @@ private extension StatusView {
func applyStatusConfiguration() {
let viewModel = statusConfiguration.viewModel
let appPreferences = viewModel.identification.appPreferences
let isContextParent = viewModel.configuration.isContextParent
let mutableContent = NSMutableAttributedString(attributedString: viewModel.content)
let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName)
let mutableSpoilerText = NSMutableAttributedString(string: viewModel.spoilerText)
let contentFont = UIFont.preferredFont(forTextStyle: isContextParent ? .title3 : .callout)
let contentRange = NSRange(location: 0, length: mutableContent.length)
let avatarURL: URL
if !appPreferences.shouldReduceMotion && appPreferences.animateAvatars == .everywhere {
avatarURL = viewModel.avatarURL
} else {
avatarURL = viewModel.avatarStaticURL
}
avatarImageView.kf.setImage(with: avatarURL)
avatarImageView.kf.setImage(with: viewModel.avatarURL)
contentTextView.shouldFallthrough = !isContextParent
sideStackView.isHidden = isContextParent