// // 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 = .short 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))) accessibilityElements!.insert(reblogLabel!, at: 0) // todo: double check this on RTL layouts replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true contentTextView.defaultFont = .systemFont(ofSize: 16) } override func createObserversIfNecessary() { super.createObserversIfNecessary() if rebloggerAccountUpdater == nil { rebloggerAccountUpdater = mastodonController.persistentContainer.accountSubject .filter { [unowned self] in $0 == self.rebloggerID } .receive(on: DispatchQueue.main) .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() timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date()) 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 getStatusCellPreviewProviders(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) } ) } } 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] { guard 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) provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] } }