From 4d654358d712e507aa86e744ed9ecc5795e040b8 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 5 Oct 2022 23:12:03 -0400 Subject: [PATCH] Extract a bunch of common stuff to StatusCollectionViewCell protocol --- Tusker.xcodeproj/project.pbxproj | 4 + .../Timeline/TimelineViewController.swift | 4 +- Tusker/Screens/Utilities/Previewing.swift | 15 + .../Status/StatusCollectionViewCell.swift | 269 +++++++++++++++++ .../TimelineStatusCollectionViewCell.swift | 284 +++--------------- 5 files changed, 330 insertions(+), 246 deletions(-) create mode 100644 Tusker/Views/Status/StatusCollectionViewCell.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index eb269ad4..ecfa4aba 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; }; D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; }; D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; }; + D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; }; D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; @@ -387,6 +388,7 @@ D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = ""; }; D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = ""; }; D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = ""; }; + D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = ""; }; D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = ""; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = ""; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = ""; }; @@ -1030,6 +1032,7 @@ D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */, D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */, D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */, + D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */, D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */, D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */, ); @@ -1837,6 +1840,7 @@ D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, + D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */, D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */, D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */, D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */, diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 1d212f70..28becf56 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -355,8 +355,8 @@ extension TimelineViewController: TuskerNavigationDelegate { extension TimelineViewController: MenuActionProvider { } -extension TimelineViewController: TimelineStatusCollectionViewCellDelegate { - func statusCellNeedsReconfigure(_ cell: TimelineStatusCollectionViewCell, animated: Bool) { +extension TimelineViewController: StatusCollectionViewCellDelegate { + func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool) { if let indexPath = collectionView.indexPath(for: cell) { var snapshot = dataSource.snapshot() snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 778588cc..3dc73ff5 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -409,6 +409,21 @@ extension MenuActionProvider { } +struct MenuPreviewHelper { + static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) { + if let viewController = animator.previewViewController { + animator.preferredCommitStyle = .pop + animator.addCompletion { + if let customPresenting = viewController as? CustomPreviewPresenting { + customPresenting.presentFromPreview(presenter: presenter) + } else { + presenter.show(viewController, sender: nil) + } + } + } + } +} + extension LargeImageViewController: CustomPreviewPresenting { func presentFromPreview(presenter: UIViewController) { presenter.present(self, animated: true) diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift new file mode 100644 index 00000000..295aa13d --- /dev/null +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -0,0 +1,269 @@ +// +// StatusCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 10/5/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@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: UIButton { 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 } + + func updateUIForPreferences(status: StatusMO) +} + +// MARK: UI Configuration +extension StatusCollectionViewCell { + static var avatarImageViewSize: CGFloat { 50 } + + func doUpdateUI(status: StatusMO) { + statusID = status.id + accountID = status.account.id + + let account = status.account + avatarImageView.update(for: account.avatar) + displayNameLabel.updateForAccountDisplayName(account: account) + usernameLabel.text = "@\(account.acct)" + contentContainer.contentTextView.setTextFrom(status: status) + + updateUIForPreferences(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 + } + } + 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: .pi) + collapseButton.accessibilityLabel = NSLocalizedString("Expand Status", comment: "expand status button accessibility label") + } else { + contentContainer.alpha = 1 + collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: 0) + collapseButton.accessibilityLabel = NSLocalizedString("Collapse Status", comment: "collapse status button accessibility label") + } + } + + 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() + } + let oldValue = status.favourited + status.favourited.toggle() + // update ui before network request to make things appear speedy + updateStatusState(status: status) + + let request = (status.favourited ? Status.favourite : Status.unfavourite)(statusID) + Task { + do { + let (newStatus, _) = try await mastodonController.run(request) + mastodonController.persistentContainer.addOrUpdate(status: newStatus) + // TODO: should this before the network request + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } catch { + status.favourited = oldValue + // TODO: display error message + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } + } + + func toggleReblog() { + guard let status = mastodonController.persistentContainer.status(for: statusID) else { + fatalError() + } + + if !status.reblogged, + Preferences.shared.confirmBeforeReblog { + let image: UIImage? + let reblogVisibilityActions: [CustomAlertController.MenuAction]? + if mastodonController.instanceFeatures.reblogVisibility { + image = UIImage(systemName: Status.Visibility.public.unfilledImageName) + reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in + CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in + self.doReblog(status: status, visibility: visibility) + } + } + } else { + image = nil + reblogVisibilityActions = [] + } + + let preview = ConfirmReblogStatusPreviewView(status: status) + var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [ + CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil), + CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in + self.doReblog(status: status, visibility: nil) + }) + ]) + if let reblogVisibilityActions { + var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil) + menuAction.isSecondaryMenu = true + config.actions.append(menuAction) + } + let alert = CustomAlertController(config: config) + delegate?.present(alert, animated: true) + } else { + doReblog(status: status, visibility: nil) + } + } + + private func doReblog(status: StatusMO, visibility: Status.Visibility?) { + let oldValue = status.reblogged + status.reblogged.toggle() + updateStatusState(status: status) + + let request: Request + if status.reblogged { + request = Status.reblog(statusID, visibility: visibility) + } else { + request = Status.unreblog(statusID) + } + Task { + do { + let (newStatus, _) = try await mastodonController.run(request) + mastodonController.persistentContainer.addOrUpdate(status: newStatus) + // TODO: should this before the network request + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } catch { + status.reblogged = oldValue + // TODO: display error message + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } + } +} + +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)] + } +} diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index 73c8a936..6d884904 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -9,12 +9,7 @@ import UIKit import Pachyderm -@MainActor -protocol TimelineStatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider { - func statusCellNeedsReconfigure(_ cell: TimelineStatusCollectionViewCell, animated: Bool) -} - -class TimelineStatusCollectionViewCell: UICollectionViewListCell { +class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell { // MARK: Subviews @@ -46,7 +41,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } private static let avatarImageViewSize: CGFloat = 50 - private lazy var avatarImageView = CachedImageView(cache: .avatars).configure { + private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure { $0.layer.masksToBounds = true NSLayoutConstraint.activate([ $0.heightAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize), @@ -82,7 +77,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) } - private let displayNameLabel = EmojiLabel().configure { + let displayNameLabel = EmojiLabel().configure { $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue, @@ -92,7 +87,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { $0.setContentCompressionResistancePriority(.init(749), for: .horizontal) } - private let usernameLabel = UILabel().configure { + let usernameLabel = UILabel().configure { $0.textColor = .secondaryLabel $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ @@ -117,7 +112,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { ]), size: 0) } - private lazy var contentWarningLabel = EmojiLabel().configure { + private(set) lazy var contentWarningLabel = EmojiLabel().configure { $0.textColor = .secondaryLabel $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ @@ -129,16 +124,18 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed))) } - private lazy var collapseButton = UIButton(configuration: { + private(set) lazy var collapseButton = UIButton(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) } - private let contentContainer = StatusContentContainer().configure { + let contentContainer = StatusContentContainer().configure { $0.setContentHuggingPriority(.defaultLow, for: .vertical) } private var contentTextView: StatusContentTextView { @@ -190,22 +187,22 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { ]) } - private lazy var replyButton = UIButton().configure { + 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) } - private lazy var favoriteButton = UIButton().configure { + private(set) lazy var favoriteButton = UIButton().configure { $0.setImage(UIImage(systemName: "star.fill"), for: .normal) $0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside) } - private lazy var reblogButton = UIButton().configure { + private(set) lazy var reblogButton = UIButton().configure { $0.setImage(UIImage(systemName: "repeat"), for: .normal) $0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside) } - private let moreButton = UIButton().configure { + let moreButton = UIButton().configure { $0.setImage(UIImage(systemName: "ellipsis"), for: .normal) $0.showsMenuAsPrimaryAction = true } @@ -218,7 +215,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { private var mainContainerBottomToSelfConstraint: NSLayoutConstraint! weak var mastodonController: MastodonController! - weak var delegate: TimelineStatusCollectionViewCellDelegate? + weak var delegate: StatusCollectionViewCellDelegate? var showStatusAutomatically: Bool { // TODO: needed once conversation controller refactored false @@ -232,14 +229,15 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { false } - private(set) var statusID: String! - private(set) var statusState = StatusState.unknown - private var accountID: String! + // alas these need to be internal so they're accessible from the protocol extensions + var statusID: String! + var statusState: StatusState! + var accountID: String! private var reblogStatusID: String? private var rebloggerID: String? private var firstLayout = true - private var isGrayscale = false + var isGrayscale = false private var updateTimestampWorkItem: DispatchWorkItem? @@ -280,23 +278,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { updateActionsVisibility() NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) - - // TEMP - reblogLabel.text = "Reblogged by Person" - avatarImageView.backgroundColor = .red - displayNameLabel.text = "Display name" - usernameLabel.text = "@username" - timestampLabel.text = "2m" - contentWarningLabel.text = "Content Warning" - contentTextView.setTextFromHtml("

Weeeeeeeee

") - attachmentsView.backgroundColor = .red - metaIndicatorsView.placeholder() -// contentWarningLabel.isHidden = true -// collapseButton.isHidden = true -// cardView.isHidden = true -// pollView.isHidden = true -// attachmentsView.isHidden = true -// actionsContainer.isHidden = true } required init?(coder: NSCoder) { @@ -375,68 +356,14 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } doUpdateUI(status: status) - } - - private func doUpdateUI(status: StatusMO) { - self.statusID = status.id - self.accountID = status.account.id - - let account = status.account - usernameLabel.text = "@\(account.acct)" - contentTextView.setTextFrom(status: status) - updateGrayscaleableUI(account: account, status: status) - updateUIForPreferences(account: account, status: status) doUpdateTimestamp(status: status) - timestampLabel.isHidden = showPinned pinImageView.isHidden = !showPinned - - cardView.card = status.card - cardView.isHidden = status.card == nil - cardView.navigationDelegate = delegate - cardView.actionProvider = delegate - - 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 && status.account.id != mastodonController.account.id) - } else { - reblogDisabled = status.visibility == .private || status.visibility == .direct - } - reblogButton.isEnabled = !reblogDisabled && mastodonController.loggedIn - - favoriteButton.isEnabled = mastodonController.loggedIn - replyButton.isEnabled = mastodonController.loggedIn - - if statusState.unknown { - statusState.resolveFor(status: status, text: contentTextView.text) - if statusState.collapsible! && showStatusAutomatically { - statusState.collapsed = false - } - } - contentContainer.setCollapsed(statusState.collapsed!) - contentContainer.alpha = statusState.collapsed! ? 0 : 1 - collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: statusState.collapsed! ? .pi : 0) - if statusState.collapsed! { - collapseButton.accessibilityLabel = NSLocalizedString("Expand Status", comment: "expand status button accessibility label") - } else { - collapseButton.accessibilityLabel = NSLocalizedString("Collapse Status", comment: "collapse status button accessibility label") - } } - private func updateUIForPreferences(account: AccountMO, status: StatusMO) { - avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * TimelineStatusCollectionViewCell.avatarImageViewSize - attachmentsView.contentHidden = Preferences.shared.blurAllMedia || status.sensitive + func updateUIForPreferences(status: StatusMO) { + baseUpdateUIForPreferences(status: status) if showReplyIndicator { metaIndicatorsView.allowedIndicators = .all @@ -444,10 +371,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { metaIndicatorsView.allowedIndicators = .all.subtracting(.reply) } - if isGrayscale != Preferences.shared.grayscaleImages { - updateGrayscaleableUI(account: account, status: status) - } - if let rebloggerID, let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { updateRebloggerLabel(reblogger: reblogger) @@ -496,41 +419,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } } - func updateGrayscaleableUI(account: AccountMO, status: StatusMO) { - isGrayscale = Preferences.shared.grayscaleImages - avatarImageView.update(for: account.avatar) - if contentTextView.hasEmojis { - contentTextView.setTextFrom(status: status) - } - displayNameLabel.updateForAccountDisplayName(account: 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) ?? []) - - pollView.isHidden = status.poll == nil - pollView.mastodonController = mastodonController - pollView.toastableViewController = delegate?.toastableViewController - pollView.updateUI(status: status, poll: status.poll) - } - private func updateActionsVisibility() { if Preferences.shared.hideActionsInTimeline { actionsContainer.isHidden = true @@ -549,7 +437,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { return } - updateUIForPreferences(account: status.account, status: status) + updateUIForPreferences(status: status) + + if isGrayscale != Preferences.shared.grayscaleImages { + updateGrayscaleableUI(status: status) + } // only needs to happen when prefs change, rather than in updateUIForPrefs b/c this is setup correctly during init let oldState = actionsContainer.isHidden @@ -573,139 +465,43 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } @objc private func collapseButtonPressed() { - 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) + toggleCollapse() } @objc private func replyPressed() { - fatalError() + if Preferences.shared.mentionReblogger, + let rebloggerID = rebloggerID, + let rebloggerAccount = mastodonController.persistentContainer.account(for: rebloggerID) { + delegate?.compose(inReplyToID: statusID, mentioningAcct: rebloggerAccount.acct) + } else { + delegate?.compose(inReplyToID: statusID) + } } @objc private func favoritePressed() { - guard let status = mastodonController.persistentContainer.status(for: statusID) else { - fatalError() - } - let oldValue = status.favourited - status.favourited.toggle() - // update ui before network request to make things appear speedy - updateStatusState(status: status) - - let request = (status.favourited ? Status.favourite : Status.unfavourite)(statusID) - Task { - do { - let (newStatus, _) = try await mastodonController.run(request) - mastodonController.persistentContainer.addOrUpdate(status: newStatus) - // TODO: should this before the network request - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } catch { - status.favourited = oldValue - // TODO: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - } - } + toggleFavorite() } @objc private func reblogPressed() { - guard let status = mastodonController.persistentContainer.status(for: statusID) else { - fatalError() - } - - if !status.reblogged, - Preferences.shared.confirmBeforeReblog { - let image: UIImage? - let reblogVisibilityActions: [CustomAlertController.MenuAction]? - if mastodonController.instanceFeatures.reblogVisibility { - image = UIImage(systemName: Status.Visibility.public.unfilledImageName) - reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in - CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in - self.doReblog(status: status, visibility: visibility) - } - } - } else { - image = nil - reblogVisibilityActions = [] - } - - let preview = ConfirmReblogStatusPreviewView(status: status) - var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [ - CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil), - CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in - self.doReblog(status: status, visibility: nil) - }) - ]) - if let reblogVisibilityActions { - var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil) - menuAction.isSecondaryMenu = true - config.actions.append(menuAction) - } - let alert = CustomAlertController(config: config) - delegate?.present(alert, animated: true) - } else { - doReblog(status: status, visibility: nil) - } - } - - private func doReblog(status: StatusMO, visibility: Status.Visibility?) { - let oldValue = status.reblogged - status.reblogged.toggle() - updateStatusState(status: status) - - let request: Request - if status.reblogged { - request = Status.reblog(statusID, visibility: visibility) - } else { - request = Status.unreblog(statusID) - } - Task { - do { - let (newStatus, _) = try await mastodonController.run(request) - mastodonController.persistentContainer.addOrUpdate(status: newStatus) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } catch { - status.reblogged = oldValue - // TODO: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - } - } + toggleReblog() } } extension TimelineStatusCollectionViewCell: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { - return UIContextMenuConfiguration() { - ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) - } actionProvider: { _ in - return UIMenu(children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: interaction.view!) ?? []) - } + return contextMenuConfigurationForAccount(sourceView: interaction.view!) } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - if let viewController = animator.previewViewController, - let delegate { - animator.preferredCommitStyle = .pop - animator.addCompletion { - if let customPresenting = viewController as? CustomPreviewPresenting { - customPresenting.presentFromPreview(presenter: delegate) - } else { - delegate.show(viewController) - } - } + if let delegate { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: delegate) } } } extension TimelineStatusCollectionViewCell: UIDragInteractionDelegate { func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [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)] + return dragItemsForAccount() } }