// // 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 timestampLabel: UILabel! @IBOutlet weak var pinImageView: UIImageView! @IBOutlet weak var replyImageView: UIImageView! 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() reblogLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed))) isAccessibilityElement = true // todo: double check this on RTL layouts replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true contentTextView.defaultFont = .systemFont(ofSize: 16) avatarImageView.addInteraction(UIContextMenuInteraction(delegate: self)) } 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 updateRebloggerLabel(reblogger: status.account) status = rebloggedStatus statusID = rebloggedStatus.id } else { reblogStatusID = nil rebloggerID = nil reblogLabel.isHidden = true } super.doUpdateUI(status: status, state: state) doUpdateTimestamp(status: status) let pinned = showPinned && (status.pinned ?? false) timestampLabel.isHidden = pinned pinImageView.isHidden = !pinned } 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) { super.updateStatusIconsForPreferences(status) replyImageView.isHidden = !Preferences.shared.showIsStatusReplyIcon || !showReplyIndicator || status.inReplyToID == nil } 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() } override func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> BaseStatusTableViewCell.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.actionsForStatus(status, sourceView: self) } ) } // MARK: - Accessibility override var accessibilityLabel: String? { get { guard let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } var str = "\(status.account.displayName), \(contentTextView.text ?? "")" if status.attachments.count > 0 { // todo: localize me str += ", \(status.attachments.count) attachments" } if status.poll != nil { str += ", poll" } str += ", \(TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date()))" if let rebloggerID = rebloggerID, let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { str += ", reblogged by \(reblogger.displayName)" } return str } set {} } override func accessibilityActivate() -> Bool { didSelectCell() return true } } extension TimelineStatusTableViewCell: SelectableTableViewCell { func didSelectCell() { delegate?.selected(status: statusID, state: statusState.copy()) } } extension TimelineStatusTableViewCell: TableViewSwipeActionProvider { func leadingSwipeActionsConfiguration() -> UISwipeActionsConfiguration? { guard let mastodonController = mastodonController, mastodonController.loggedIn else { return nil } guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } let favoriteTitle: String let favoriteRequest: Request let favoriteColor: UIColor if status.favourited { favoriteTitle = "Unfavorite" favoriteRequest = Status.unfavourite(status.id) favoriteColor = UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) } else { favoriteTitle = "Favorite" favoriteRequest = Status.favourite(status.id) favoriteColor = UIColor(displayP3Red: 1, green: 204/255, blue: 0, alpha: 1) } let favorite = UIContextualAction(style: .normal, title: favoriteTitle) { (action, view, completion) in mastodonController.run(favoriteRequest, completion: { response in DispatchQueue.main.async { guard case let .success(status, _) = response else { completion(false) return } completion(true) mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) } }) } favorite.image = UIImage(systemName: "star.fill") favorite.backgroundColor = favoriteColor let reblogTitle: String let reblogRequest: Request let reblogColor: UIColor if status.reblogged { reblogTitle = "Unreblog" reblogRequest = Status.unreblog(status.id) reblogColor = UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) } else { reblogTitle = "Reblog" reblogRequest = Status.reblog(status.id) reblogColor = tintColor } let reblog = UIContextualAction(style: .normal, title: reblogTitle) { (action, view, completion) in mastodonController.run(reblogRequest, completion: { response in DispatchQueue.main.async { guard case let .success(status, _) = response else { completion(false) return } completion(true) mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) } }) } reblog.image = UIImage(systemName: "repeat") reblog.backgroundColor = reblogColor return UISwipeActionsConfiguration(actions: [favorite, reblog]) } func trailingSwipeActionsConfiguration() -> UISwipeActionsConfiguration? { let share = UIContextualAction(style: .normal, title: "Share") { (action, view, completion) in completion(true) self.delegate?.showMoreOptions(forStatus: self.statusID, sourceView: self) } // Bold to more closely match the other action symbols let config = UIImage.SymbolConfiguration(weight: .bold) share.image = UIImage(systemName: "square.and.arrow.up")!.applyingSymbolConfiguration(config)! share.backgroundColor = .lightGray guard mastodonController.loggedIn else { return UISwipeActionsConfiguration(actions: [share]) } let reply = UIContextualAction(style: .normal, title: "Reply") { (action, view, completion) in completion(true) self.reply() } reply.image = UIImage(systemName: "arrowshape.turn.up.left.fill") reply.backgroundColor = tintColor return UISwipeActionsConfiguration(actions: [reply, share]) } } 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) 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.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView)) } } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { if let viewController = animator.previewViewController, let delegate = navigationDelegate { animator.preferredCommitStyle = .pop animator.addCompletion { if let customPresenting = viewController as? CustomPreviewPresenting { customPresenting.presentFromPreview(presenter: delegate) } else { delegate.show(viewController) } } } } }