Tusker/Tusker/Views/Status/TimelineStatusCollectionVie...

957 lines
40 KiB
Swift

//
// 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<AnyCancellable>()
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()
}
}