// // 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) } @MainActor protocol StatusCollectionViewCell: UICollectionViewCell { // 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 } // TODO: why is one of these ! and the other ? var mastodonController: MastodonController! { get } var delegate: StatusCollectionViewCellDelegate? { get } var showStatusAutomatically: Bool { get } var showReplyIndicator: Bool { get } var statusID: String! { get set } var statusState: StatusState! { 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 } 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) { precondition(delegate != nil, "StatusCollectionViewCell must have delegate") statusID = status.id accountID = status.account.id updateAccountUI(account: status.account) updateUIForPreferences(status: status) contentContainer.contentTextView.setTextFrom(status: status) contentContainer.cardView.card = status.card contentContainer.cardView.isHidden = status.card == nil contentContainer.cardView.navigationDelegate = delegate contentContainer.cardView.actionProvider = delegate contentContainer.attachmentsView.updateUI(status: status) updateStatusState(status: status) contentWarningLabel.text = status.spoilerText contentWarningLabel.isHidden = status.spoilerText.isEmpty if !contentWarningLabel.isHidden { contentWarningLabel.setEmojis(status.emojis, identifier: statusID) } let reblogDisabled: Bool if mastodonController.instanceFeatures.boostToOriginalAudience { reblogDisabled = status.visibility == .direct || (status.visibility == .private && mastodonController.loggedIn && accountID != mastodonController.account.id) } else { reblogDisabled = status.visibility == .direct || status.visibility == .private } reblogButton.isEnabled = !reblogDisabled && mastodonController.loggedIn replyButton.isEnabled = mastodonController.loggedIn favoriteButton.isEnabled = mastodonController.loggedIn if statusState.unknown { statusState.resolveFor(status: status, text: contentContainer.contentTextView.text) 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") } } 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 contentContainer.attachmentsView.contentHidden = Preferences.shared.blurAllMedia || status.sensitive } // only called when isGrayscale does not match the pref func updateGrayscaleableUI(status: StatusMO) { isGrayscale = Preferences.shared.grayscaleImages if contentContainer.contentTextView.hasEmojis { contentContainer.contentTextView.setTextFrom(status: status) } 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, sourceView: moreButton, includeReply: 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() // 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) } 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 contextMenuConfigurationForAccount(sourceView: UIView) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration() { ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) } actionProvider: { _ in return UIMenu(children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: 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)] } }