Tusker/Tusker/Views/Status/StatusCollectionViewCell.swift

369 lines
16 KiB
Swift

//
// StatusCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/5/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
@MainActor
protocol StatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?)
func statusCellShowFiltered(_ cell: StatusCollectionViewCell)
}
@MainActor
protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate {
// MARK: Subviews
var avatarImageView: CachedImageView { get }
var displayNameLabel: AccountDisplayNameLabel { get }
var usernameLabel: UILabel { get }
var contentWarningLabel: EmojiLabel { get }
var collapseButton: StatusCollapseButton { get }
var contentContainer: StatusContentContainer { get }
var contentTextView: StatusContentTextView { get }
var pollView: StatusPollView { get }
var cardView: StatusCardView { get }
var attachmentsView: AttachmentsContainerView { get }
var replyButton: UIButton { get }
var favoriteButton: ToggleableButton { get }
var reblogButton: ToggleableButton { get }
var moreButton: UIButton { get }
var prevThreadLinkView: UIView? { get set }
var nextThreadLinkView: UIView? { get set }
var delegate: StatusCollectionViewCellDelegate? { get }
var mastodonController: MastodonController! { get }
var showStatusAutomatically: Bool { get }
var statusID: String! { get set }
var statusState: CollapseState! { get set }
var accountID: String! { get set }
var isGrayscale: Bool { get set }
var cancellables: Set<AnyCancellable> { get set }
func updateAttachmentsUI(status: StatusMO)
func updateUIForPreferences(status: StatusMO)
func updateStatusState(status: StatusMO)
func estimateContentHeight() -> CGFloat
}
// MARK: UI Configuration
extension StatusCollectionViewCell {
static var avatarImageViewSize: CGFloat { 50 }
var mastodonController: MastodonController! { delegate?.apiController }
func baseCreateObservers() {
mastodonController.persistentContainer.statusSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] _ in
if let mastodonController = self.mastodonController,
let status = mastodonController.persistentContainer.status(for: self.statusID) {
// update immediately w/o animation
self.favoriteButton.active = status.favourited
self.reblogButton.active = status.reblogged
self.delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil)
}
}
.store(in: &cancellables)
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] in
if let mastodonController = self.mastodonController,
let account = mastodonController.persistentContainer.account(for: $0) {
self.updateAccountUI(account: account)
}
}
.store(in: &cancellables)
}
func doUpdateUI(status: StatusMO, content: NSAttributedString) {
statusID = status.id
accountID = status.account.id
updateAccountUI(account: status.account)
contentTextView.setTextFrom(status: status, content: content)
contentTextView.navigationDelegate = delegate
self.updateAttachmentsUI(status: status)
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
pollView.delegate = delegate
pollView.updateUI(status: status, poll: status.poll)
if Preferences.shared.showLinkPreviews {
cardView.updateUI(status: status)
cardView.isHidden = status.card == nil
cardView.navigationDelegate = delegate
cardView.actionProvider = delegate
} else {
cardView.isHidden = true
}
updateUIForPreferences(status: status)
updateStatusState(status: status)
contentWarningLabel.text = status.spoilerText
contentWarningLabel.isHidden = status.spoilerText.isEmpty
if !contentWarningLabel.isHidden {
contentWarningLabel.setEmojis(status.emojis, identifier: statusID)
}
reblogButton.isEnabled = reblogEnabled(status: status)
replyButton.isEnabled = mastodonController.loggedIn
favoriteButton.isEnabled = mastodonController.loggedIn
let didResolve = statusState.resolveFor(status: status, height: self.estimateContentHeight)
if didResolve {
if statusState.collapsible! && showStatusAutomatically {
statusState.collapsed = false
}
}
let expected = !statusState.collapsible!
// Very very rarely, setting isHidden to false only seems to partially take effect:
// the button will be rendered, but isHidden will still return true, and the
// containing stack view won't have updated constraints for it and so the cell
// layout will be wrong and the button will overlap other views in the stack.
// So, as a truly cursed workaround, just try a few times in a row until reading
// back isHidden returns the correct value.
for _ in 0..<5 {
collapseButton.isHidden = expected
if collapseButton.isHidden == expected {
break
}
}
contentContainer.setCollapsed(statusState.collapsed!)
if statusState.collapsed! {
contentContainer.alpha = 0
// TODO: is this accessing the image view before the button's been laid out?
collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: 0)
collapseButton.accessibilityLabel = NSLocalizedString("Expand Status", comment: "expand status button accessibility label")
} else {
contentContainer.alpha = 1
collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: .pi)
collapseButton.accessibilityLabel = NSLocalizedString("Collapse Status", comment: "collapse status button accessibility label")
}
}
private func reblogEnabled(status: StatusMO) -> Bool {
guard mastodonController.loggedIn else {
return false
}
if status.visibility == .direct {
return false
} else if status.visibility == .private {
if mastodonController.instanceFeatures.boostToOriginalAudience,
status.account.id == mastodonController.account?.id {
return true
}
return false
}
return true
}
func updateAttachmentsUI(status: StatusMO) {
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: status.attachments)
}
func updateAccountUI(account: AccountMO) {
avatarImageView.update(for: account.avatar)
displayNameLabel.updateForAccountDisplayName(account: account)
usernameLabel.text = "@\(account.acct)"
}
func baseUpdateUIForPreferences(status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
let newCardHidden = !Preferences.shared.showLinkPreviews || status.card == nil
if cardView.isHidden != newCardHidden {
delegate?.statusCellNeedsReconfigure(self, animated: false, completion: nil)
}
switch Preferences.shared.attachmentBlurMode {
case .never:
attachmentsView.contentHidden = false
case .always:
attachmentsView.contentHidden = true
default:
if status.sensitive {
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else {
attachmentsView.contentHidden = false
}
}
let reblogButtonImage: UIImage
if Preferences.shared.alwaysShowStatusVisibilityIcon || reblogEnabled(status: status) {
reblogButtonImage = UIImage(systemName: "repeat")!
} else {
reblogButtonImage = UIImage(systemName: status.visibility.imageName)!
}
reblogButton.setImage(reblogButtonImage, for: .normal)
}
// only called when isGrayscale does not match the pref
func updateGrayscaleableUI(status: StatusMO) {
isGrayscale = Preferences.shared.grayscaleImages
if contentTextView.hasEmojis {
contentTextView.setEmojis(status.emojis, identifier: status.id)
}
displayNameLabel.updateForAccountDisplayName(account: status.account)
}
func baseUpdateStatusState(status: StatusMO) {
favoriteButton.active = status.favourited
if status.favourited {
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
} else {
favoriteButton.accessibilityLabel = NSLocalizedString("Favorite", comment: "favorite button accessibility label")
}
reblogButton.active = status.reblogged
if status.reblogged {
reblogButton.accessibilityLabel = NSLocalizedString("Undo Reblog", comment: "undo reblog button accessibility label")
} else {
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
}
// keep menu in sync with changed states e.g. bookmarked, muted
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
pollView.delegate = delegate
pollView.updateUI(status: status, poll: status.poll)
}
func setShowThreadLinks(prev: Bool, next: Bool) {
if prev {
if let prevThreadLinkView {
prevThreadLinkView.isHidden = false
} else {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
prevThreadLinkView = view
contentView.addSubview(view)
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 5),
view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
view.topAnchor.constraint(equalTo: contentView.topAnchor),
view.bottomAnchor.constraint(equalTo: avatarImageView.topAnchor, constant: -2),
])
}
} else {
prevThreadLinkView?.isHidden = true
}
if next {
if let nextThreadLinkView {
nextThreadLinkView.isHidden = false
} else {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
nextThreadLinkView = view
contentView.addSubview(view)
let bottomConstraint = view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
// let this constraint get broken during intermediate layouts to avoid a bunch of spurious 'unable to simultaneously satisfy constraints' messages
bottomConstraint.priority = .init(999)
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 5),
view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
view.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 2),
bottomConstraint,
])
}
} else {
nextThreadLinkView?.isHidden = true
}
}
}
// MARK: Interaction
extension StatusCollectionViewCell {
func toggleCollapse() {
statusState.collapsed!.toggle()
// mask so that the content appears to expand with the container during the animation
// but only while the animation is taking place, otherwise the mask interferes with context menu presentation animation
contentContainer.layer.masksToBounds = true
// this delegate call causes the collection view to reconfigure this cell, at which point (and inside of the collection view's animation handling) we'll update the contentContainer
delegate?.statusCellNeedsReconfigure(self, animated: true) {
self.contentContainer.layer.masksToBounds = false
}
}
func toggleFavorite() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError()
}
Task {
await FavoriteService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleFavorite()
}
}
func toggleReblog() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError()
}
Task {
await ReblogService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleReblog()
}
}
}
extension StatusCollectionViewCell {
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
guard let delegate = delegate,
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
// TODO: PiP
// gallery.avPlayerViewControllerDelegate = self
return gallery
}
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
delegate?.present(vc, animated: animated)
}
}
extension StatusCollectionViewCell {
func contextMenuConfigurationForAccount(sourceView: UIView) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration() {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { _ in
return UIMenu(children: self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(sourceView)) ?? [])
}
}
}
extension StatusCollectionViewCell {
func dragItemsForAccount() -> [UIDragItem] {
guard let currentAccountID = mastodonController.accountInfo?.id,
let account = mastodonController.persistentContainer.account(for: accountID) else {
return []
}
let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}