// // 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: EmojiLabel { get } var usernameLabel: UILabel { get } var contentWarningLabel: EmojiLabel { get } var collapseButton: StatusCollapseButton { get } var contentContainer: StatusContentContainer { get } var replyButton: UIButton { get } var favoriteButton: UIButton { get } var reblogButton: UIButton { get } var moreButton: UIButton { get } var delegate: StatusCollectionViewCellDelegate? { get } var mastodonController: MastodonController! { get } var showStatusAutomatically: Bool { get } var showReplyIndicator: 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 { get set } func updateUIForPreferences(status: StatusMO) } // 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: $0) { self.updateStatusState(status: status) } } .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, precomputedContent: NSAttributedString? = nil) { statusID = status.id accountID = status.account.id updateAccountUI(account: status.account) updateUIForPreferences(status: status) contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent) contentContainer.contentTextView.navigationDelegate = delegate contentContainer.attachmentsView.delegate = self contentContainer.attachmentsView.updateUI(status: status) contentContainer.cardView.updateUI(status: status) contentContainer.cardView.isHidden = status.card == nil contentContainer.cardView.navigationDelegate = delegate contentContainer.cardView.actionProvider = delegate 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 if statusState.unknown { // layout so that we can take the content height into consideration when deciding whether to collapse layoutIfNeeded() statusState.resolveFor(status: status, height: contentContainer.contentTextView.bounds.height) if statusState.collapsible! && showStatusAutomatically { statusState.collapsed = false } } collapseButton.isHidden = !statusState.collapsible! 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 || status.visibility == .private { if mastodonController.instanceFeatures.boostToOriginalAudience, status.account.id == mastodonController.account?.id { return true } return false } return true } 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 if Preferences.shared.blurAllMedia { contentContainer.attachmentsView.contentHidden = true } else if status.sensitive { if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty { contentContainer.attachmentsView.contentHidden = false } else { contentContainer.attachmentsView.contentHidden = true } } else { contentContainer.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 contentContainer.contentTextView.hasEmojis { contentContainer.contentTextView.setEmojis(status.emojis, identifier: status.id) } displayNameLabel.updateForAccountDisplayName(account: status.account) } func updateStatusState(status: StatusMO) { if status.favourited { favoriteButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1) favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label") } else { favoriteButton.tintColor = nil favoriteButton.accessibilityLabel = NSLocalizedString("Favorite", comment: "favorite button accessibility label") } if status.reblogged { reblogButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1) reblogButton.accessibilityLabel = NSLocalizedString("Undo Reblog", comment: "undo reblog button accessibility label") } else { reblogButton.tintColor = nil 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) ?? []) contentContainer.pollView.isHidden = status.poll == nil contentContainer.pollView.mastodonController = mastodonController contentContainer.pollView.toastableViewController = delegate?.toastableViewController contentContainer.pollView.updateUI(status: status, poll: status.poll) } } // 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(contentContainer.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)] } }