// // TimelineStatusTableViewCell.swift // Tusker // // Created by Shadowfacts on 8/16/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Combine import Pachyderm class TimelineStatusTableViewCell: BaseStatusTableViewCell { static let relativeDateFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.dateTimeStyle = .numeric formatter.unitsStyle = .full return formatter }() @IBOutlet weak var reblogLabel: EmojiLabel! @IBOutlet weak var reblogSpacer: UIView! @IBOutlet weak var timestampLabel: UILabel! @IBOutlet weak var pinImageView: UIImageView! @IBOutlet weak var actionsContainerView: UIView! @IBOutlet weak var actionsContainerHeightConstraint: NSLayoutConstraint! @IBOutlet weak var verticalStackToActionsContainerConstraint: NSLayoutConstraint! @IBOutlet weak var verticalStackToSuperviewConstraint: NSLayoutConstraint! var reblogStatusID: String? var rebloggerID: String? var showPinned = false var showReplyIndicator = true var updateTimestampWorkItem: DispatchWorkItem? var rebloggerAccountUpdater: Cancellable? deinit { rebloggerAccountUpdater?.cancel() updateTimestampWorkItem?.cancel() } override func awakeFromNib() { super.awakeFromNib() isAccessibilityElement = true reblogLabel.font = .preferredFont(forTextStyle: .body) reblogLabel.adjustsFontForContentSizeCategory = true reblogLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed))) avatarImageView.addInteraction(UIContextMenuInteraction(delegate: self)) displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue, ] ]), size: 0) displayNameLabel.adjustsFontForContentSizeCategory = true usernameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue, ] ]), size: 0) usernameLabel.adjustsFontForContentSizeCategory = true timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue, ] ]), size: 0) timestampLabel.adjustsFontForContentSizeCategory = true metaIndicatorsView.primaryAxis = .vertical metaIndicatorsView.secondaryAxisAlignment = .trailing contentWarningLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold.rawValue, ] ]), size: 0) contentWarningLabel.adjustsFontForContentSizeCategory = true contentTextView.defaultFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 16)) contentTextView.adjustsFontForContentSizeCategory = true // todo: double check this on RTL layouts replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true updateActionsVisibility() } override func createObserversIfNecessary() { super.createObserversIfNecessary() if rebloggerAccountUpdater == nil { rebloggerAccountUpdater = 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) } } } } override func doUpdateUI(status: StatusMO, state: StatusState) { var status = status if let rebloggedStatus = status.reblog { reblogStatusID = statusID rebloggerID = status.account.id reblogLabel.isHidden = false reblogSpacer.isHidden = false updateRebloggerLabel(reblogger: status.account) status = rebloggedStatus // necessary b/c statusID is initially set to the reblog status ID in updateUI(statusID:state:) statusID = rebloggedStatus.id } else { reblogStatusID = nil rebloggerID = nil reblogLabel.isHidden = true reblogSpacer.isHidden = true } super.doUpdateUI(status: status, state: state) doUpdateTimestamp(status: status) timestampLabel.isHidden = showPinned pinImageView.isHidden = !showPinned } override func updateGrayscaleableUI(account: AccountMO, status: StatusMO) { super.updateGrayscaleableUI(account: account, status: status) if let rebloggerID = rebloggerID, reblogLabel.hasEmojis, let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { updateRebloggerLabel(reblogger: reblogger) } } private func updateRebloggerLabel(reblogger: AccountMO) { if Preferences.shared.hideCustomEmojiInUsernames { reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)" reblogLabel.removeEmojis() } else { reblogLabel.text = "Reblogged by \(reblogger.displayOrUserName)" reblogLabel.setEmojis(reblogger.emojis, identifier: reblogger.id) } } override func updateStatusIconsForPreferences(_ status: StatusMO) { if showReplyIndicator { metaIndicatorsView.allowedIndicators = .all } else { metaIndicatorsView.allowedIndicators = .all.subtracting(.reply) } let oldState = actionsContainerView.isHidden if oldState != Preferences.shared.hideActionsInTimeline { updateActionsVisibility() if #available(iOS 16.0, *) { invalidateIntrinsicContentSize() } else { // not really accurate, but it notifies the vc our height has changed delegate?.statusCellCollapsedStateChanged(self) } } super.updateStatusIconsForPreferences(status) } private func updateActionsVisibility() { if Preferences.shared.hideActionsInTimeline { actionsContainerView.isHidden = true } else { actionsContainerView.isHidden = false } } private func updateTimestamp() { // if the mastodonController is nil (i.e. the delegate is nil), then the screen this cell was a part of has been deallocated // so we bail out immediately, since there's nothing to update // if the status cannot be found, it may have already been discarded due to not being on screen, so we do nothing guard let mastodonController = mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) else { return } doUpdateTimestamp(status: status) } private func doUpdateTimestamp(status: StatusMO) { 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 = delay { if updateTimestampWorkItem == nil { updateTimestampWorkItem = DispatchWorkItem { [weak self] in self?.updateTimestamp() } } DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!) } else { updateTimestampWorkItem = nil } } func reply() { 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) } } override func prepareForReuse() { super.prepareForReuse() updateTimestampWorkItem?.cancel() updateTimestampWorkItem = nil showPinned = false } @objc func reblogLabelPressed() { guard let rebloggerID = rebloggerID else { return } delegate?.selected(account: rebloggerID) } override func replyPressed() { reply() } // MARK: - Accessibility 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 { collapseButtonPressed() } else { didSelectCell() } 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 {} } } extension TimelineStatusTableViewCell: SelectableTableViewCell { func didSelectCell() { delegate?.selected(status: statusID, state: statusState.copy()) } } extension TimelineStatusTableViewCell: TableViewSwipeActionProvider { func leadingSwipeActionsConfiguration() -> 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 trailingSwipeActionsConfiguration() -> 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) }) } } extension TimelineStatusTableViewCell: DraggableTableViewCell { 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 let status = mastodonController.persistentContainer.status(for: statusID), let accountID = mastodonController.accountInfo?.id, !pollView.isTracking 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)] } } extension TimelineStatusTableViewCell: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration(identifier: nil) { ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) } actionProvider: { (_) in return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? []) } } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { if let viewController = animator.previewViewController, let delegate = delegate { animator.preferredCommitStyle = .pop animator.addCompletion { if let customPresenting = viewController as? CustomPreviewPresenting { customPresenting.presentFromPreview(presenter: delegate) } else { delegate.show(viewController) } } } } } extension TimelineStatusTableViewCell: MenuPreviewProvider { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { guard let mastodonController = mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } return ( content: { ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) }, actions: { self.delegate?.actionsForStatus(status, source: .view(self)) ?? [] } ) } } extension TimelineStatusTableViewCell: StatusSwipeActionContainer { var navigationDelegate: TuskerNavigationDelegate { delegate! } var toastableViewController: ToastableViewController? { delegate } func performReplyAction() { self.replyPressed() } }