// // TimelineStatusCollectionViewCell.swift // Tusker // // Created by Shadowfacts on 10/1/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine private let reblogIcon = UIImage(systemName: "repeat") private let hashtagIcon = UIImage(systemName: "number") class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell { static let separatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) // MARK: Subviews private lazy var timelineReasonLabel = EmojiLabel().configure { $0.textColor = .secondaryLabel $0.font = .preferredFont(forTextStyle: .body) $0.adjustsFontForContentSizeCategory = true } private let timelineReasonIcon = UIImageView(image: reblogIcon).configure { $0.tintColor = .secondaryLabel } private lazy var timelineReasonHStack = UIStackView(arrangedSubviews: [ timelineReasonIcon, timelineReasonLabel, ]).configure { $0.axis = .horizontal $0.spacing = 8 // 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(reblogLabelPressed))) } private lazy var mainContainer = UIView().configure { avatarImageView.translatesAutoresizingMaskIntoConstraints = false $0.addSubview(avatarImageView) contentVStack.translatesAutoresizingMaskIntoConstraints = false $0.addSubview(contentVStack) metaIndicatorsView.translatesAutoresizingMaskIntoConstraints = false $0.addSubview(metaIndicatorsView) NSLayoutConstraint.activate([ avatarImageView.leadingAnchor.constraint(equalTo: $0.leadingAnchor), avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor), contentVStack.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 8), contentVStack.trailingAnchor.constraint(equalTo: $0.trailingAnchor), contentVStack.topAnchor.constraint(equalTo: $0.topAnchor), contentVStack.bottomAnchor.constraint(equalTo: $0.bottomAnchor), metaIndicatorsView.leadingAnchor.constraint(greaterThanOrEqualTo: $0.leadingAnchor), metaIndicatorsView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), metaIndicatorsView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 4), ]) } private static let avatarImageViewSize: CGFloat = 50 private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure { $0.layer.masksToBounds = true NSLayoutConstraint.activate([ $0.heightAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize), $0.widthAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize), ]) $0.isUserInteractionEnabled = true $0.addInteraction(UIContextMenuInteraction(delegate: self)) $0.addInteraction(UIDragInteraction(delegate: self)) $0.addInteraction(UIPointerInteraction(delegate: self)) $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) } private let metaIndicatorsView = StatusMetaIndicatorsView().configure { $0.primaryAxis = .vertical $0.secondaryAxisAlignment = .trailing } private lazy var contentVStack = UIStackView(arrangedSubviews: [ nameHStack, contentWarningLabel, collapseButton, contentContainer, ]).configure { $0.axis = .vertical $0.spacing = 4 $0.alignment = .fill } private lazy var nameHStack = UIStackView(arrangedSubviews: [ displayNameLabel, usernameLabel, pinImageView, timestampLabel, ]).configure { $0.axis = .horizontal $0.spacing = 4 } let displayNameLabel = EmojiLabel().configure { $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue, ] ]), size: 0) $0.adjustsFontForContentSizeCategory = true $0.setContentHuggingPriority(.init(251), for: .horizontal) $0.setContentCompressionResistancePriority(.init(749), for: .horizontal) } let usernameLabel = UILabel().configure { $0.textColor = .secondaryLabel $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue, ] ]), size: 0) $0.adjustsFontForContentSizeCategory = true $0.setContentHuggingPriority(.init(249), for: .horizontal) $0.setContentCompressionResistancePriority(.init(748), for: .horizontal) } private let pinImageView = UIImageView(image: UIImage(systemName: "pin.fill")).configure { $0.tintColor = .secondaryLabel $0.setContentHuggingPriority(.init(251), for: .horizontal) } private let timestampLabel = UILabel().configure { $0.textColor = .secondaryLabel $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue, ] ]), size: 0) $0.adjustsFontForContentSizeCategory = true } 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().configure { $0.setContentHuggingPriority(.defaultLow, for: .vertical) } private var contentTextView: StatusContentTextView { contentContainer.contentTextView } private var cardView: StatusCardView { contentContainer.cardView } private var attachmentsView: AttachmentsContainerView { contentContainer.attachmentsView } private var pollView: StatusPollView { contentContainer.pollView } private var placeholderReplyButtonLeadingConstraint: NSLayoutConstraint! private lazy var actionsContainer = UIView().configure { replyButton.translatesAutoresizingMaskIntoConstraints = false $0.addSubview(replyButton) favoriteButton.translatesAutoresizingMaskIntoConstraints = false $0.addSubview(favoriteButton) reblogButton.translatesAutoresizingMaskIntoConstraints = false $0.addSubview(reblogButton) moreButton.translatesAutoresizingMaskIntoConstraints = false $0.addSubview(moreButton) placeholderReplyButtonLeadingConstraint = replyButton.leadingAnchor.constraint(equalTo: $0.leadingAnchor) NSLayoutConstraint.activate([ favoriteButton.widthAnchor.constraint(equalTo: replyButton.widthAnchor), reblogButton.widthAnchor.constraint(equalTo: replyButton.widthAnchor), moreButton.widthAnchor.constraint(equalTo: replyButton.widthAnchor), placeholderReplyButtonLeadingConstraint, replyButton.topAnchor.constraint(equalTo: $0.topAnchor), replyButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor), favoriteButton.leadingAnchor.constraint(equalTo: replyButton.trailingAnchor), favoriteButton.topAnchor.constraint(equalTo: $0.topAnchor), favoriteButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor), reblogButton.leadingAnchor.constraint(equalTo: favoriteButton.trailingAnchor), reblogButton.topAnchor.constraint(equalTo: $0.topAnchor), reblogButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor), moreButton.leadingAnchor.constraint(equalTo: reblogButton.trailingAnchor), moreButton.trailingAnchor.constraint(equalTo: $0.trailingAnchor), moreButton.topAnchor.constraint(equalTo: $0.topAnchor), moreButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor), ]) } 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 = UIButton().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 = UIButton().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, favoriteButton, reblogButton, moreButton] } // MARK: Cell state private var mainContainerTopToReblogLabelConstraint: NSLayoutConstraint! private var mainContainerTopToSelfConstraint: NSLayoutConstraint! private var mainContainerBottomToActionsConstraint: NSLayoutConstraint! private var mainContainerBottomToSelfConstraint: NSLayoutConstraint! weak var overrideMastodonController: MastodonController? var mastodonController: MastodonController! { overrideMastodonController ?? delegate?.apiController } weak var delegate: StatusCollectionViewCellDelegate? var showStatusAutomatically: Bool { // TODO: needed once conversation controller refactored false } var showReplyIndicator: Bool { // TODO: needed once conversation controller refactored true } var showPinned: Bool = false var showFollowedHashtags: Bool = false // 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 var isGrayscale = false private var updateTimestampWorkItem: DispatchWorkItem? private var hasCreatedObservers = false var cancellables = Set() override init(frame: CGRect) { super.init(frame: frame) for subview in [timelineReasonHStack, mainContainer, actionsContainer] { subview.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(subview) } mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4) mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8) mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4) mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6) let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6) // sometimes during intermediate layouts, there are conflicting constraints, so let this one get broken temporarily, to avoid a bunch of printing metaIndicatorsBottomConstraint.priority = .init(999) NSLayoutConstraint.activate([ // why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced timelineReasonHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), timelineReasonLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor), timelineReasonHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16), mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), actionsContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), actionsContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), // yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven actionsContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6), metaIndicatorsBottomConstraint, ]) updateActionsVisibility() NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() if firstLayout { firstLayout = false // the button's image view doesn't exist until after the first layout // accessing it before that cause the button to layoutIfNeeded which generates a broken, intermediate layout and prints a bunch of unhelpful autolayout warnings // so we wait until after the first layout pass to setup the reply button's real constraint placeholderReplyButtonLeadingConstraint.isActive = false replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true } } // MARK: Accessibility override var isAccessibilityElement: Bool { get { true } set {} } override var accessibilityAttributedLabel: NSAttributedString? { get { guard let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } var str = AttributedString("\(status.account.displayOrUserName), ") if statusState.collapsed ?? false { if !status.spoilerText.isEmpty { str += AttributedString(status.spoilerText) str += ", " } str += "collapsed" } else { str += AttributedString(contentTextView.attributedText) } if status.attachments.count > 0 { // TODO: localize me str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")") } if status.poll != nil { str += ", poll" } str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))") if status.visibility < .unlisted { str += AttributedString(", \(status.visibility.displayName)") } if status.localOnly { str += ", local" } if let rebloggerID, let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { str += AttributedString(", reblogged by \(reblogger.displayOrUserName)") } return NSAttributedString(str) } set {} } override var accessibilityHint: String? { get { if statusState.collapsed ?? false { return "Double tap to expand the post." } else { return nil } } set {} } override func accessibilityActivate() -> Bool { if statusState.collapsed ?? false { toggleCollapse() } else { delegate?.selected(status: statusID, state: statusState.copy()) } return true } override var accessibilityCustomActions: [UIAccessibilityCustomAction]? { get { guard let text = contentTextView.attributedText else { return nil } var actions: [UIAccessibilityCustomAction] = [] text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, range, stop in guard let value = value as? URL else { return } let text = text.attributedSubstring(from: range).string actions.append(UIAccessibilityCustomAction(name: text) { [unowned self] _ in self.contentTextView.handleLinkTapped(url: value, text: text) return true }) } return actions } set {} } // MARK: Configure UI func updateUI(statusID: String, state: StatusState) { guard var status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() } createObservers() self.statusState = state var hideTimelineReason = true if let rebloggedStatus = status.reblog { reblogStatusID = statusID rebloggerID = status.account.id hideTimelineReason = false timelineReasonIcon.image = reblogIcon updateRebloggerLabel(reblogger: status.account) status = rebloggedStatus } else { reblogStatusID = nil rebloggerID = nil } if showFollowedHashtags { let hashtags = mastodonController.followedHashtags.filter({ followed in status.hashtags.contains(where: { followed.name == $0.name }) }) if !hashtags.isEmpty { hideTimelineReason = false timelineReasonIcon.image = hashtagIcon timelineReasonLabel.text = hashtags.map(\.name).formatted(.list(type: .and, width: .narrow)) timelineReasonLabel.removeEmojis() } } timelineReasonHStack.isHidden = hideTimelineReason mainContainerTopToReblogLabelConstraint.isActive = !hideTimelineReason mainContainerTopToSelfConstraint.isActive = hideTimelineReason doUpdateUI(status: status) doUpdateTimestamp(status: status) timestampLabel.isHidden = showPinned pinImageView.isHidden = !showPinned } func createObservers() { guard !hasCreatedObservers else { return } hasCreatedObservers = true baseCreateObservers() mastodonController.persistentContainer.accountSubject .receive(on: DispatchQueue.main) .filter { [unowned self] in $0 == self.rebloggerID } .sink { [unowned self] in if let mastodonController = self.mastodonController, let reblogger = mastodonController.persistentContainer.account(for: $0) { self.updateRebloggerLabel(reblogger: reblogger) } } .store(in: &cancellables) } func updateUIForPreferences(status: StatusMO) { baseUpdateUIForPreferences(status: status) if showReplyIndicator { metaIndicatorsView.allowedIndicators = .all } else { metaIndicatorsView.allowedIndicators = .all.subtracting(.reply) } metaIndicatorsView.updateUI(status: status) if let rebloggerID, let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { updateRebloggerLabel(reblogger: reblogger) } } private func updateTimestamp() { guard let mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) else { return } doUpdateTimestamp(status: status) } private func doUpdateTimestamp(status: StatusMO) { // if there's a pending update timestamp work item, cancel it updateTimestampWorkItem?.cancel() timestampLabel.text = status.createdAt.timeAgoString() let delay: DispatchTimeInterval? switch status.createdAt.timeAgo().1 { case .second: delay = .seconds(10) case .minute: delay = .seconds(60) default: delay = nil } if let delay { if updateTimestampWorkItem == nil { updateTimestampWorkItem = DispatchWorkItem { [weak self] in self?.updateTimestamp() } } DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!) } else { updateTimestampWorkItem = nil } } private func updateRebloggerLabel(reblogger: AccountMO) { if Preferences.shared.hideCustomEmojiInUsernames { timelineReasonLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged" timelineReasonLabel.removeEmojis() } else { timelineReasonLabel.text = "\(reblogger.displayOrUserName) reblogged" timelineReasonLabel.setEmojis(reblogger.emojis, identifier: reblogger.id) } } private func updateActionsVisibility() { if Preferences.shared.hideActionsInTimeline { actionsContainer.isHidden = true mainContainerBottomToSelfConstraint.isActive = true mainContainerBottomToActionsConstraint.isActive = false } else { actionsContainer.isHidden = false mainContainerBottomToSelfConstraint.isActive = false mainContainerBottomToActionsConstraint.isActive = true } } @objc private func preferencesChanged() { guard let mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) else { return } 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 if oldState != Preferences.shared.hideActionsInTimeline { updateActionsVisibility() delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil) } } // MARK: Interaction @objc private func reblogLabelPressed() { if let rebloggerID { delegate?.selected(account: rebloggerID) } else if showFollowedHashtags, let status = mastodonController.persistentContainer.status(for: statusID), let hashtag = mastodonController.followedHashtags.first(where: { followed in status.hashtags.contains(where: { followed.name == $0.name }) }) { delegate?.selected(tag: Hashtag(name: hashtag.name, url: hashtag.url)) } } @objc private func accountPressed() { delegate?.selected(account: accountID) } @objc private func collapseButtonPressed() { toggleCollapse() } @objc private func replyPressed() { 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() { toggleFavorite() } @objc private func reblogPressed() { toggleReblog() } func leadingSwipeActions() -> UISwipeActionsConfiguration? { guard let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } return UISwipeActionsConfiguration(actions: Preferences.shared.leadingStatusSwipeActions.compactMap { $0.createAction(status: status, container: self) }) } func trailingSwipeActions() -> UISwipeActionsConfiguration? { guard let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } return UISwipeActionsConfiguration(actions: Preferences.shared.trailingStatusSwipeActions.compactMap { $0.createAction(status: status, container: self) }) } func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] { // the poll options view is tracking while the user is dragging between options // while that's happening, don't initiate a drag guard !pollView.isTracking, let status = mastodonController.persistentContainer.status(for: statusID), let accountID = mastodonController.accountInfo?.id else { return [] } let provider = NSItemProvider(object: status.url! as NSURL) let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] } func contextMenuConfiguration() -> UIContextMenuConfiguration? { guard let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } return UIContextMenuConfiguration { ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController) } actionProvider: { _ in UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self))) } } } extension TimelineStatusCollectionViewCell: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return contextMenuConfigurationForAccount(sourceView: interaction.view!) } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { if let delegate { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: delegate) } } } extension TimelineStatusCollectionViewCell: UIDragInteractionDelegate { func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] { return dragItemsForAccount() } } extension TimelineStatusCollectionViewCell: 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, from: 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, from: button.imageView!) rect = rect.insetBy(dx: -8, dy: -8) return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect)) } return nil } } extension TimelineStatusCollectionViewCell: StatusSwipeActionContainer { var navigationDelegate: TuskerNavigationDelegate { delegate! } var toastableViewController: ToastableViewController? { delegate } func performReplyAction() { self.replyPressed() } }