// // ConversationMainStatusCollectionViewCell.swift // Tusker // // Created by Shadowfacts on 1/20/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell { static let contentFont = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 18)) static let monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 18, weight: .regular)) static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle static let metaFont = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 15)) static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .medium return formatter }() // MARK: Subviews private static let avatarImageViewSize: CGFloat = 50 private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure { $0.layer.masksToBounds = true $0.layer.cornerCurve = .continuous NSLayoutConstraint.activate([ $0.heightAnchor.constraint(equalToConstant: ConversationMainStatusCollectionViewCell.avatarImageViewSize), $0.widthAnchor.constraint(equalToConstant: ConversationMainStatusCollectionViewCell.avatarImageViewSize), ]) $0.isUserInteractionEnabled = true $0.addInteraction(UIPointerInteraction(delegate: self)) $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) } let displayNameLabel = AccountDisplayNameLabel().configure { $0.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 24, weight: .semibold)) $0.adjustsFontForContentSizeCategory = true } private let metaIndicatorsView = StatusMetaIndicatorsView().configure { $0.allowedIndicators = [.visibility, .localOnly] $0.squeezeHorizontal = true $0.primaryAxis = .horizontal } let usernameLabel = UILabel().configure { $0.textColor = .secondaryLabel $0.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 17, weight: .light)) $0.adjustsFontForContentSizeCategory = true } private lazy var accountDetailContainerView = UIView().configure { $0.backgroundColor = .clear $0.addSubview(avatarImageView) $0.addSubview(displayNameLabel) $0.addSubview(metaIndicatorsView) $0.addSubview(usernameLabel) avatarImageView.translatesAutoresizingMaskIntoConstraints = false displayNameLabel.translatesAutoresizingMaskIntoConstraints = false metaIndicatorsView.translatesAutoresizingMaskIntoConstraints = false usernameLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ avatarImageView.leadingAnchor.constraint(equalTo: $0.leadingAnchor), avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor), avatarImageView.bottomAnchor.constraint(equalTo: $0.bottomAnchor), displayNameLabel.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 8), displayNameLabel.trailingAnchor.constraint(equalTo: metaIndicatorsView.leadingAnchor, constant: -8), displayNameLabel.topAnchor.constraint(equalTo: $0.topAnchor), metaIndicatorsView.topAnchor.constraint(equalTo: $0.topAnchor), metaIndicatorsView.trailingAnchor.constraint(equalTo: $0.trailingAnchor), usernameLabel.leadingAnchor.constraint(equalTo: displayNameLabel.leadingAnchor), usernameLabel.trailingAnchor.constraint(equalTo: $0.trailingAnchor), usernameLabel.topAnchor.constraint(equalTo: displayNameLabel.bottomAnchor), usernameLabel.bottomAnchor.constraint(equalTo: $0.bottomAnchor), ]) $0.isUserInteractionEnabled = true $0.addInteraction(UIContextMenuInteraction(delegate: self)) $0.addInteraction(UIDragInteraction(delegate: self)) $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) } private lazy var accountDetailAccessibilityElement = ConversationMainStatusAccountDetailAccessibilityElement(accessibilityContainer: self) private(set) lazy var contentWarningLabel = EmojiLabel().configure { $0.numberOfLines = 0 $0.textColor = .secondaryLabel $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold.rawValue, ] ]), size: 0) $0.adjustsFontForContentSizeCategory = true // this needs to have a higher priorty than the content container's zero height constraint $0.setContentHuggingPriority(.defaultHigh, for: .vertical) $0.isUserInteractionEnabled = true $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed))) } private(set) lazy var collapseButton = StatusCollapseButton(configuration: { var config = UIButton.Configuration.filled() config.image = UIImage(systemName: "chevron.down") return config }()).configure { // this button is so big that dimming its background color is visually distracting $0.tintAdjustmentMode = .normal $0.setContentHuggingPriority(.defaultHigh, for: .vertical) $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) } let contentContainer = StatusContentContainer(useTopSpacer: true).configure { $0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont $0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont $0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle $0.contentTextView.isSelectable = true $0.contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber] if #available(iOS 16.0, *) { $0.contentTextView.dataDetectorTypes.formUnion([.money, .physicalValue]) } $0.setContentHuggingPriority(.defaultLow, for: .vertical) } private lazy var favoritesCountButton = UIButton().configure { $0.titleLabel!.adjustsFontForContentSizeCategory = true $0.addTarget(self, action: #selector(favoritesCountPressed), for: .touchUpInside) $0.isPointerInteractionEnabled = true } private lazy var reblogsCountButton = UIButton().configure { $0.titleLabel!.adjustsFontForContentSizeCategory = true $0.addTarget(self, action: #selector(reblogsCountPressed), for: .touchUpInside) $0.isPointerInteractionEnabled = true } private lazy var actionsCountHStack = UIStackView(arrangedSubviews: [ reblogsCountButton, favoritesCountButton, ]).configure { $0.axis = .horizontal $0.spacing = 8 } private let timestampAndClientLabel = UILabel().configure { $0.textColor = .secondaryLabel $0.font = ConversationMainStatusCollectionViewCell.metaFont $0.adjustsFontForContentSizeCategory = true } private lazy var editTimestampButton = UIButton().configure { $0.titleLabel!.adjustsFontForContentSizeCategory = true $0.addTarget(self, action: #selector(editTimestampPressed), for: .touchUpInside) $0.isPointerInteractionEnabled = true } private let firstSeparator = UIView().configure { $0.backgroundColor = .separator NSLayoutConstraint.activate([ $0.heightAnchor.constraint(equalToConstant: 0.5), ]) } private let secondSeparator = UIView().configure { $0.backgroundColor = .separator NSLayoutConstraint.activate([ $0.heightAnchor.constraint(equalToConstant: 0.5), ]) } private lazy var metaVStack = UIStackView(arrangedSubviews: [ firstSeparator, actionsCountHStack, timestampAndClientLabel, editTimestampButton, secondSeparator, ]).configure { $0.axis = .vertical $0.spacing = 4 $0.alignment = .leading } private(set) lazy var replyButton = UIButton().configure { $0.setImage(UIImage(systemName: "arrowshape.turn.up.left.fill"), for: .normal) $0.addTarget(self, action: #selector(replyPressed), for: .touchUpInside) $0.addInteraction(UIPointerInteraction(delegate: self)) } private(set) lazy var favoriteButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure { $0.setImage(UIImage(systemName: "star.fill"), for: .normal) $0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside) $0.addInteraction(UIPointerInteraction(delegate: self)) } private(set) lazy var reblogButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure { $0.setImage(UIImage(systemName: "repeat"), for: .normal) $0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside) $0.addInteraction(UIPointerInteraction(delegate: self)) } private(set) lazy var moreButton = UIButton().configure { $0.setImage(UIImage(systemName: "ellipsis"), for: .normal) $0.showsMenuAsPrimaryAction = true $0.addInteraction(UIPointerInteraction(delegate: self)) } private var actionButtons: [UIButton] { [replyButton, reblogButton, favoriteButton, moreButton] } private lazy var actionsHStack = UIStackView(arrangedSubviews: [ replyButton, reblogButton, favoriteButton, moreButton, ]).configure { $0.axis = .horizontal $0.distribution = .fillEqually NSLayoutConstraint.activate([ $0.heightAnchor.constraint(equalToConstant: 26), ]) } private let accountDetailToContentWarningSpacer = UIView().configure { $0.backgroundColor = .clear $0.heightAnchor.constraint(equalToConstant: 4).isActive = true } private let contentWarningToCollapseButtonSpacer = UIView().configure { $0.backgroundColor = .clear $0.heightAnchor.constraint(equalToConstant: 4).isActive = true } private let contentToMetaSpacer = UIView().configure { $0.backgroundColor = .clear $0.heightAnchor.constraint(equalToConstant: 8).isActive = true } private let metaToActionsSpacer = UIView().configure { $0.backgroundColor = .clear $0.heightAnchor.constraint(equalToConstant: 8).isActive = true } private lazy var mainVStack = UIStackView(arrangedSubviews: [ accountDetailContainerView, accountDetailToContentWarningSpacer, contentWarningLabel, contentWarningToCollapseButtonSpacer, collapseButton, contentContainer, contentToMetaSpacer, metaVStack, metaToActionsSpacer, actionsHStack, ]).configure { $0.axis = .vertical $0.spacing = 0 $0.alignment = .leading NSLayoutConstraint.activate([ accountDetailContainerView.widthAnchor.constraint(equalTo: $0.widthAnchor), contentWarningLabel.widthAnchor.constraint(equalTo: $0.widthAnchor), collapseButton.widthAnchor.constraint(equalTo: $0.widthAnchor), contentContainer.widthAnchor.constraint(equalTo: $0.widthAnchor), firstSeparator.widthAnchor.constraint(equalTo: $0.widthAnchor), secondSeparator.widthAnchor.constraint(equalTo: $0.widthAnchor), actionsHStack.widthAnchor.constraint(equalTo: $0.widthAnchor), ]) } var prevThreadLinkView: UIView? var nextThreadLinkView: UIView? // MARK: Cell State var mastodonController: MastodonController! { delegate?.apiController } weak var delegate: StatusCollectionViewCellDelegate? var showStatusAutomatically = false var statusID: String! var statusState: CollapseState! var accountID: String! var isGrayscale = false private var hasCreatedObservers = false var cancellables = Set() override init(frame: CGRect) { super.init(frame: frame) // don't show selection background, because this cell isn't selectable automaticallyUpdatesBackgroundConfiguration = false mainVStack.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(mainVStack) NSLayoutConstraint.activate([ mainVStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), mainVStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), mainVStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), mainVStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), ]) accessibilityElements = [ accountDetailAccessibilityElement, contentWarningLabel, collapseButton, contentContainer.contentTextView, contentContainer.attachmentsView, contentContainer.pollView, favoritesCountButton, reblogsCountButton, timestampAndClientLabel, replyButton, favoriteButton, reblogButton, moreButton, ] NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func updateConfiguration(using state: UICellConfigurationState) { backgroundConfiguration = .appListPlainCell(for: state) } // MARK: Configure UI func updateUI(statusID: String, state: CollapseState) { guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() } createObservers() self.statusID = statusID self.statusState = state doUpdateUI(status: status) accountDetailToContentWarningSpacer.isHidden = collapseButton.isHidden contentWarningToCollapseButtonSpacer.isHidden = collapseButton.isHidden accountDetailAccessibilityElement.navigationDelegate = delegate accountDetailAccessibilityElement.accountID = accountID var timestampAndClientText = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: status.createdAt) if let application = status.applicationName { timestampAndClientText += " • \(application)" } timestampAndClientLabel.text = timestampAndClientText if let editedAt = status.editedAt { editTimestampButton.isHidden = false var config = UIButton.Configuration.plain() config.baseForegroundColor = .secondaryLabel config.attributedTitle = AttributedString("Edited on \(ConversationMainStatusCollectionViewCell.dateFormatter.string(from: editedAt))", attributes: AttributeContainer([ .font: ConversationMainStatusCollectionViewCell.metaFont ])) config.contentInsets = .zero editTimestampButton.configuration = config } else { editTimestampButton.isHidden = true } } private func createObservers() { guard !hasCreatedObservers else { return } hasCreatedObservers = true baseCreateObservers() } func updateStatusState(status: StatusMO) { baseUpdateStatusState(status: status) let attributes = AttributeContainer([ .font: ConversationMainStatusCollectionViewCell.metaFont ]) let favoritesFormat = NSLocalizedString("favorites count", comment: "conv main status favorites button label") var favoritesConfig = UIButton.Configuration.plain() favoritesConfig.baseForegroundColor = .secondaryLabel favoritesConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(favoritesFormat, status.favouritesCount), attributes: attributes) favoritesConfig.contentInsets = .zero favoritesCountButton.configuration = favoritesConfig let reblogsFormat = NSLocalizedString("reblogs count", comment: "conv main status reblogs button label") var reblogsConfig = UIButton.Configuration.plain() reblogsConfig.baseForegroundColor = .secondaryLabel reblogsConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(reblogsFormat, status.reblogsCount), attributes: attributes) reblogsConfig.contentInsets = .zero reblogsCountButton.configuration = reblogsConfig } func estimateContentHeight() -> CGFloat { let width = bounds.width - 2*16 return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width) } func updateUIForPreferences(status: StatusMO) { baseUpdateUIForPreferences(status: status) } @objc private func preferencesChanged() { guard let mastodonController, let statusID, let status = mastodonController.persistentContainer.status(for: statusID) else { return } updateUIForPreferences(status: status) if isGrayscale != Preferences.shared.grayscaleImages { updateGrayscaleableUI(status: status) } if actionsCountHStack.isHidden != !Preferences.shared.showFavoriteAndReblogCounts { actionsCountHStack.isHidden = !Preferences.shared.showFavoriteAndReblogCounts delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil) } } // MARK: Interaction @objc private func accountPressed() { delegate?.selected(account: accountID) } @objc private func collapseButtonPressed() { toggleCollapse() } @objc private func favoritesCountPressed() { guard let delegate else { return } let vc = StatusActionAccountListViewController(actionType: .favorite, statusID: statusID, statusState: statusState.copy(), accountIDs: nil, mastodonController: mastodonController) vc.showInaccurateCountWarning = true delegate.show(vc) } @objc private func reblogsCountPressed() { guard let delegate else { return } let vc = StatusActionAccountListViewController(actionType: .reblog, statusID: statusID, statusState: statusState.copy(), accountIDs: nil, mastodonController: mastodonController) vc.showInaccurateCountWarning = true delegate.show(vc) } @objc private func replyPressed() { delegate?.compose(inReplyToID: statusID) } @objc private func favoritePressed() { toggleFavorite() } @objc private func reblogPressed() { toggleReblog() } @objc private func editTimestampPressed() { delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil) } } private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement { var navigationDelegate: TuskerNavigationDelegate! var mastodonController: MastodonController { navigationDelegate.apiController } var accountID: String! override var accessibilityLabel: String? { get { mastodonController.persistentContainer.account(for: accountID)?.displayNameWithoutCustomEmoji } set {} } override var accessibilityHint: String? { get { "Double tap to show profile." } set {} } override func accessibilityActivate() -> Bool { navigationDelegate.selected(account: accountID) return true } } extension ConversationMainStatusCollectionViewCell: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return contextMenuConfigurationForAccount(sourceView: accountDetailContainerView) } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { if let delegate { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: delegate) } } } extension ConversationMainStatusCollectionViewCell: UIDragInteractionDelegate { func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] { return dragItemsForAccount() } } extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate { func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? { if interaction.view == avatarImageView { return defaultRegion } else if let button = interaction.view as? UIButton, actionButtons.contains(button) { var rect = button.convert(button.imageView!.bounds, to: button.imageView!) rect = rect.insetBy(dx: -24, dy: -24) return UIPointerRegion(rect: rect) } return nil } func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { if interaction.view == avatarImageView { let preview = UITargetedPreview(view: avatarImageView) return UIPointerStyle(effect: .lift(preview)) } else if let button = interaction.view as? UIButton, actionButtons.contains(button) { let preview = UITargetedPreview(view: button.imageView!) var rect = button.convert(button.imageView!.bounds, to: button.imageView!) rect = rect.insetBy(dx: -24, dy: -24) return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect)) } return nil } }