// // 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 + 50 + 8, bottom: 0, trailing: 0) static let contentFont = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 16)) static let monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 16, weight: .regular)) static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle static let htmlConverter = HTMLConverter( font: TimelineStatusCollectionViewCell.contentFont, monospaceFont: TimelineStatusCollectionViewCell.monospaceFont, color: .label, paragraphStyle: TimelineStatusCollectionViewCell.contentParagraphStyle ) private static let timelineReasonIconSize: CGFloat = 25 // MARK: Subviews private lazy var timelineReasonLabel = EmojiLabel().configure { $0.textColor = .secondaryLabel $0.font = .preferredFont(forTextStyle: .body) $0.adjustsFontForContentSizeCategory = true } private let timelineReasonIcon = CachedImageView(cache: .avatars).configure { $0.image = reblogIcon $0.contentMode = .scaleAspectFit $0.layer.masksToBounds = true $0.layer.cornerCurve = .continuous $0.tintColor = .secondaryLabel let heightConstraint = $0.heightAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.timelineReasonIconSize) heightConstraint.identifier = "TimelineReason-Height" NSLayoutConstraint.activate([ // this needs to be lessThanOrEqualTo not just equalTo b/c otherwise intermediate layouts are broken heightConstraint, $0.widthAnchor.constraint(equalTo: $0.heightAnchor), ]) } 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) let avatarTopConstraint = avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor) avatarTopConstraint.identifier = "Avatar-Top" let metaIndicatorsTopConstraint = metaIndicatorsView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 4) metaIndicatorsTopConstraint.identifier = "MetaIndicators-Top" NSLayoutConstraint.activate([ avatarImageView.leadingAnchor.constraint(equalTo: $0.leadingAnchor), avatarTopConstraint, 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), metaIndicatorsTopConstraint, ]) // So that during gallery presentation/dismissal animations, the attachment view appears over everything else. $0.layer.zPosition = 1 } private static let avatarImageViewSize: CGFloat = 50 private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure { $0.contentMode = .scaleAspectFill $0.layer.masksToBounds = true $0.layer.cornerCurve = .continuous 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 = AccountDisplayNameLabel().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) $0.setContentCompressionResistancePriority(.required, for: .vertical) } private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [ contentTextView, cardView, attachmentsView, pollView, ] as! [any StatusContentView], useTopSpacer: false).configure { $0.setContentHuggingPriority(.defaultLow, for: .vertical) } let contentTextView = StatusContentTextView().configure { $0.adjustsFontForContentSizeCategory = true $0.isScrollEnabled = false $0.backgroundColor = nil $0.isEditable = false $0.isSelectable = false $0.emojiFont = TimelineStatusCollectionViewCell.contentFont } let cardView = StatusCardView().configure { $0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true } let attachmentsView = AttachmentsContainerView() let pollView = StatusPollView() #if os(visionOS) private lazy var actionsContainer = UIStackView(arrangedSubviews: [ replyButton, favoriteButton, reblogButton, moreButton, ]).configure { $0.axis = .horizontal $0.spacing = 8 } #else 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), reblogButton.leadingAnchor.constraint(equalTo: replyButton.trailingAnchor), reblogButton.topAnchor.constraint(equalTo: $0.topAnchor), reblogButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor), favoriteButton.leadingAnchor.constraint(equalTo: reblogButton.trailingAnchor), favoriteButton.topAnchor.constraint(equalTo: $0.topAnchor), favoriteButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor), moreButton.leadingAnchor.constraint(equalTo: favoriteButton.trailingAnchor), moreButton.trailingAnchor.constraint(equalTo: $0.trailingAnchor), moreButton.topAnchor.constraint(equalTo: $0.topAnchor), moreButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor), ]) } #endif private(set) lazy var replyButton = UIButton().configure { #if os(visionOS) var config = UIButton.Configuration.borderedProminent() config.image = UIImage(systemName: "arrowshape.turn.up.left.fill") $0.configuration = config #else $0.setImage(UIImage(systemName: "arrowshape.turn.up.left.fill"), for: .normal) $0.addInteraction(UIPointerInteraction(delegate: self)) #endif $0.addTarget(self, action: #selector(replyPressed), for: .touchUpInside) } private(set) lazy var favoriteButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure { #if os(visionOS) var config = UIButton.Configuration.borderedProminent() config.image = UIImage(systemName: "star.fill") $0.configuration = config #else $0.setImage(UIImage(systemName: "star.fill"), for: .normal) $0.addInteraction(UIPointerInteraction(delegate: self)) #endif $0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside) } private(set) lazy var reblogButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure { #if os(visionOS) var config = UIButton.Configuration.borderedProminent() config.image = UIImage(systemName: "repeat") $0.configuration = config #else $0.setImage(UIImage(systemName: "repeat"), for: .normal) $0.addInteraction(UIPointerInteraction(delegate: self)) #endif $0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside) } private(set) lazy var moreButton = UIButton().configure { #if os(visionOS) var config = UIButton.Configuration.borderedProminent() config.image = UIImage(systemName: "ellipsis") $0.configuration = config #else $0.setImage(UIImage(systemName: "ellipsis"), for: .normal) $0.addInteraction(UIPointerInteraction(delegate: self)) #endif $0.showsMenuAsPrimaryAction = true } private var actionButtons: [UIButton] { [replyButton, favoriteButton, reblogButton, moreButton] } private var contentViewMode: ContentViewMode! private let statusContainer = UIView().configure { $0.translatesAutoresizingMaskIntoConstraints = false } private let filteredLabel = UILabel().configure { $0.textAlignment = .center $0.textColor = .secondaryLabel $0.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitItalic)!, size: 0) $0.adjustsFontForContentSizeCategory = true $0.translatesAutoresizingMaskIntoConstraints = false } var prevThreadLinkView: UIView? var nextThreadLinkView: UIView? // 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 = false var showReplyIndicator = true var showPinned: Bool = false var showFollowedHashtags: Bool = false var showAttachmentsInline = true // alas these need to be internal so they're accessible from the protocol extensions var statusID: String! var statusState: CollapseState! var accountID: String! private var reblogStatusID: String? private var rebloggerID: String? private var filterReason: String? #if !os(visionOS) private var firstLayout = true #endif var isGrayscale = false private var updateTimestampWorkItem: DispatchWorkItem? private var hasCreatedObservers = false var cancellables = Set() override init(frame: CGRect) { super.init(frame: frame) setContentViewMode(.status) for subview in [timelineReasonHStack, mainContainer, actionsContainer] { subview.translatesAutoresizingMaskIntoConstraints = false statusContainer.addSubview(subview) } mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4) mainContainerTopToReblogLabelConstraint.identifier = "MainContainerTopToReblog" mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 8) mainContainerTopToSelfConstraint.identifier = "MainContainerTopToSelf" mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4) mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6) NSLayoutConstraint.activate([ // why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced timelineReasonHStack.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 4), timelineReasonLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor), timelineReasonHStack.trailingAnchor.constraint(lessThanOrEqualTo: statusContainer.trailingAnchor, constant: -16), mainContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16), mainContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16), mainContainerBottomToActionsConstraint, // yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven actionsContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6), metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: statusContainer.bottomAnchor, constant: -6), ]) #if os(visionOS) NSLayoutConstraint.activate([ actionsContainer.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor), ]) #else NSLayoutConstraint.activate([ actionsContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16), actionsContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16), ]) #endif 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 !os(visionOS) 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 } #endif } override func updateConfiguration(using state: UICellConfigurationState) { backgroundConfiguration = .appListPlainCell(for: state) } // MARK: Accessibility override var isAccessibilityElement: Bool { get { true } set {} } override var accessibilityAttributedLabel: NSAttributedString? { get { if contentViewMode == .filtered, let filterReason { return NSAttributedString(string: "Filtered: \(filterReason)") } guard let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } var str: AttributedString = "" if let rebloggerID, let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { str += AttributedString("Reblogged by \(reblogger.displayNameWithoutCustomEmoji): ") } str += AttributedString(status.account.displayNameWithoutCustomEmoji) str += ", " 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 { let includeDescriptions: Bool switch Preferences.shared.attachmentBlurMode { case .useStatusSetting: includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || status.spoilerText.isEmpty case .always: includeDescriptions = true case .never: includeDescriptions = false } if includeDescriptions { if status.attachments.count == 1 { let attachment = status.attachments[0] let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description" str += AttributedString(", attachment: \(desc)") } else { for (index, attachment) in status.attachments.enumerated() { let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description" str += AttributedString(", attachment \(index + 1): \(desc)") } } } else { 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" } return NSAttributedString(str) } set {} } override var accessibilityHint: String? { get { if contentViewMode == .filtered { return "Double tap to show the post." } else if statusState.collapsed ?? false { return "Double tap to expand the post." } else { return nil } } set {} } override func accessibilityActivate() -> Bool { if contentViewMode == .filtered { delegate?.statusCellShowFiltered(self) } else 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, let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } var actions = [ UIAccessibilityCustomAction(name: "Show \(status.account.displayNameWithoutCustomEmoji)", actionHandler: { [unowned self] _ in self.delegate?.selected(account: status.account.id) return true }) ] 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 private func setContentViewMode(_ mode: ContentViewMode) { guard mode != contentViewMode else { return } contentViewMode = mode switch mode { case .status: // make the offscreen view transparent so the filtered -> status animation looks better statusContainer.layer.opacity = 1 filteredLabel.layer.opacity = 0 filteredLabel.removeFromSuperview() contentView.addSubview(statusContainer) NSLayoutConstraint.activate([ statusContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), statusContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), statusContainer.topAnchor.constraint(equalTo: contentView.topAnchor), statusContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) case .filtered: statusContainer.layer.opacity = 0 filteredLabel.layer.opacity = 1 statusContainer.removeFromSuperview() contentView.addSubview(filteredLabel) NSLayoutConstraint.activate([ filteredLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), filteredLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), filteredLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1), contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: filteredLabel.bottomAnchor, multiplier: 1), ]) } } func updateUI(statusID: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) { guard var status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() } self.statusState = state let reblogStatus: StatusMO? if let rebloggedStatus = status.reblog { reblogStatus = status reblogStatusID = statusID rebloggerID = status.account.id status = rebloggedStatus } else { reblogStatus = nil reblogStatusID = nil rebloggerID = nil } switch filterResult { case .allow: setContentViewMode(.status) case .warn(let filterTitle): filterReason = filterTitle let attrStr = NSMutableAttributedString(string: "Filtered: \(filterTitle) ") let showStr = NSAttributedString(string: "Show", attributes: [.foregroundColor: UIColor.tintColor]) attrStr.append(showStr) filteredLabel.attributedText = attrStr setContentViewMode(.filtered) // still update id properties, so that info for other methods (e.g., context menus) is correct self.statusID = status.id self.accountID = status.account.id return case .hide: fatalError("unreachable") } createObservers() var hideTimelineReason = true if let reblogStatus { hideTimelineReason = false updateRebloggerLabel(reblogger: reblogStatus.account) } else 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 // do this to make sure the currently active constraint is deactivated first if hideTimelineReason { mainContainerTopToReblogLabelConstraint.isActive = false mainContainerTopToSelfConstraint.isActive = true } else { mainContainerTopToSelfConstraint.isActive = false mainContainerTopToReblogLabelConstraint.isActive = true } let content = precomputedContent ?? TimelineStatusCollectionViewCell.htmlConverter.convert(status.content) doUpdateUI(status: status, content: content) doUpdateTimestamp(status: status) timestampLabel.isHidden = showPinned pinImageView.isHidden = !showPinned } private 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) timelineReasonIcon.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.timelineReasonIconSize } func updateStatusState(status: StatusMO) { baseUpdateStatusState(status: status) } func updateAttachmentsUI(status: StatusMO) { attachmentsView.delegate = self attachmentsView.updateUI(attachments: status.attachments, labelOnly: !showAttachmentsInline) } func estimateContentHeight() -> CGFloat { let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16 return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width) } 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() let timeAgo = status.createdAt.timeAgoString() timestampLabel.text = "\(timeAgo)\(status.editedAt != nil ? "*" : "")" 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) { timelineReasonIcon.update(for: reblogger.avatar, placeholder: reblogIcon) 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 { actionsContainer.isHidden = true mainContainerBottomToActionsConstraint.isActive = false mainContainerBottomToSelfConstraint.isActive = true } else if !Preferences.shared.hideActionsInTimeline && actionsContainer.isHidden { actionsContainer.isHidden = false mainContainerBottomToSelfConstraint.isActive = false mainContainerBottomToActionsConstraint.isActive = true } } @objc private func preferencesChanged() { guard let mastodonController, let statusID, let status = mastodonController.persistentContainer.status(for: statusID) else { return } updateUIForPreferences(status: status) if let rebloggerID, let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { updateRebloggerLabel(reblogger: reblogger) } 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 mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } return UIContextMenuConfiguration { ConversationViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController) } actionProvider: { _ in guard let delegate = self.delegate else { return nil } return UIMenu(children: delegate.actionsForStatus(status, source: .view(self))) } } } extension TimelineStatusCollectionViewCell { private enum ContentViewMode { case status case filtered } } 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 canReblog: Bool { reblogButton.isEnabled } func performReplyAction() { self.replyPressed() } }