diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 7fb04570..1d212f70 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -149,6 +149,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + collectionView.indexPathsForSelectedItems?.forEach { + collectionView.deselectItem(at: $0, animated: true) + } + Task { await controller.loadInitial() } @@ -220,6 +224,15 @@ extension TimelineViewController { return false } } + + var isSelectable: Bool { + switch self { + case .publicTimelineDescription, .status(id: _, state: _): + return true + default: + return false + } + } } } @@ -310,6 +323,10 @@ extension TimelineViewController: UICollectionViewDelegate { } } + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false + } + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return @@ -317,10 +334,10 @@ extension TimelineViewController: UICollectionViewDelegate { switch item { case .publicTimelineDescription: removeTimelineDescriptionCell() - - default: - // TODO: cell selection - break + case .status(id: let id, state: let state): + selected(status: id, state: state.copy()) + case .loadingIndicator, .confirmLoadMore: + fatalError("unreachable") } } @@ -339,11 +356,11 @@ extension TimelineViewController: MenuActionProvider { } extension TimelineViewController: TimelineStatusCollectionViewCellDelegate { - func statusCellCollapsedStateChanged(_ cell: TimelineStatusCollectionViewCell) { + func statusCellNeedsReconfigure(_ cell: TimelineStatusCollectionViewCell, animated: Bool) { if let indexPath = collectionView.indexPath(for: cell) { var snapshot = dataSource.snapshot() snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) - dataSource.apply(snapshot, animatingDifferences: true) + dataSource.apply(snapshot, animatingDifferences: animated) } } } diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index 35244d66..73c8a936 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -11,19 +11,18 @@ import Pachyderm @MainActor protocol TimelineStatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider { - func statusCellCollapsedStateChanged(_ cell: TimelineStatusCollectionViewCell) + func statusCellNeedsReconfigure(_ cell: TimelineStatusCollectionViewCell, animated: Bool) } class TimelineStatusCollectionViewCell: UICollectionViewListCell { // MARK: Subviews - private let reblogLabel = EmojiLabel().configure { + private lazy var reblogLabel = EmojiLabel().configure { $0.textColor = .secondaryLabel // this needs to have a higher priorty than the content container's zero height constraint $0.setContentHuggingPriority(.defaultHigh, for: .vertical) - // TODO: tap gesture -// $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed))) + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed))) } private lazy var mainContainer = UIView().configure { @@ -47,18 +46,16 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } private static let avatarImageViewSize: CGFloat = 50 - private let avatarImageView = CachedImageView(cache: .avatars).configure { + private lazy var avatarImageView = CachedImageView(cache: .avatars).configure { $0.layer.masksToBounds = true NSLayoutConstraint.activate([ $0.heightAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize), $0.widthAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize), ]) - // TODO: context menu -// $0.addInteraction(UIContextMenuInteraction(delegate: self)) - // TODO: drag gesture -// $0.addInteraction(UIDragInteraction(delegate: self)) - // TODO: tap gesture -// $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) + $0.isUserInteractionEnabled = true + $0.addInteraction(UIContextMenuInteraction(delegate: self)) + $0.addInteraction(UIDragInteraction(delegate: self)) + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) } private let metaIndicatorsView = StatusMetaIndicatorsView() @@ -82,8 +79,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { ]).configure { $0.axis = .horizontal $0.spacing = 4 - // TODO: tap gesture -// $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) } private let displayNameLabel = EmojiLabel().configure { @@ -194,16 +190,19 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { ]) } - private let replyButton = UIButton().configure { + private lazy var replyButton = UIButton().configure { $0.setImage(UIImage(systemName: "arrowshape.turn.up.left.fill"), for: .normal) + $0.addTarget(self, action: #selector(replyPressed), for: .touchUpInside) } - private let favoriteButton = UIButton().configure { + private lazy var favoriteButton = UIButton().configure { $0.setImage(UIImage(systemName: "star.fill"), for: .normal) + $0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside) } - private let reblogButton = UIButton().configure { + private lazy var reblogButton = UIButton().configure { $0.setImage(UIImage(systemName: "repeat"), for: .normal) + $0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside) } private let moreButton = UIButton().configure { @@ -241,6 +240,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { private var firstLayout = true private var isGrayscale = false + + private var updateTimestampWorkItem: DispatchWorkItem? override init(frame: CGRect) { super.init(frame: frame) @@ -316,6 +317,39 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } } + // MARK: Accessibility + + override var accessibilityLabel: String? { + get { + guard let status = mastodonController.persistentContainer.status(for: statusID) else { + return nil + } + var str = "\(status.account.displayOrUserName), \(contentTextView.text ?? "")" + + if status.attachments.count > 0 { + // TODO: localize me + str += ", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")" + } + if status.poll != nil { + str += ", poll" + } + str += ", \(status.createdAt.formatted(.relative(presentation: .numeric)))" + if let rebloggerID, + let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { + str += ", reblogged by \(reblogger.displayOrUserName)" + } + return str + } + set {} + } + + override func accessibilityActivate() -> Bool { + delegate?.selected(status: statusID, state: statusState.copy()) + return true + } + + // MARK: Configure UI + func updateUI(statusID: String, state: StatusState) { guard var status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() @@ -353,6 +387,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { updateGrayscaleableUI(account: account, status: status) updateUIForPreferences(account: account, status: status) + doUpdateTimestamp(status: status) + + timestampLabel.isHidden = showPinned + pinImageView.isHidden = !showPinned + cardView.card = status.card cardView.isHidden = status.card == nil cardView.navigationDelegate = delegate @@ -395,7 +434,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } } - func updateUIForPreferences(account: AccountMO, status: StatusMO) { + private func updateUIForPreferences(account: AccountMO, status: StatusMO) { avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * TimelineStatusCollectionViewCell.avatarImageViewSize attachmentsView.contentHidden = Preferences.shared.blurAllMedia || status.sensitive @@ -415,6 +454,38 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } } + private func updateTimestamp() { + guard let 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 { + 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) { if Preferences.shared.hideCustomEmojiInUsernames { reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)" @@ -484,16 +555,157 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { let oldState = actionsContainer.isHidden if oldState != Preferences.shared.hideActionsInTimeline { updateActionsVisibility() - delegate?.statusCellCollapsedStateChanged(self) + delegate?.statusCellNeedsReconfigure(self, animated: true) } } // MARK: Interaction - @objc func collapseButtonPressed() { + @objc private func reblogLabelPressed() { + guard let rebloggerID else { + return + } + delegate?.selected(account: rebloggerID) + } + + @objc private func accountPressed() { + delegate?.selected(account: accountID) + } + + @objc private func collapseButtonPressed() { statusState.collapsed!.toggle() // this delegate call causes the collection view to reconfigure this cell, at which point (and inside of the collection view's animation handling) we'll update the contentContainer - delegate?.statusCellCollapsedStateChanged(self) + delegate?.statusCellNeedsReconfigure(self, animated: true) + } + + @objc private func replyPressed() { + fatalError() + } + + @objc private func favoritePressed() { + guard let status = mastodonController.persistentContainer.status(for: statusID) else { + fatalError() + } + let oldValue = status.favourited + status.favourited.toggle() + // update ui before network request to make things appear speedy + updateStatusState(status: status) + + let request = (status.favourited ? Status.favourite : Status.unfavourite)(statusID) + Task { + do { + let (newStatus, _) = try await mastodonController.run(request) + mastodonController.persistentContainer.addOrUpdate(status: newStatus) + // TODO: should this before the network request + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } catch { + status.favourited = oldValue + // TODO: display error message + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } + } + + @objc private func reblogPressed() { + guard let status = mastodonController.persistentContainer.status(for: statusID) else { + fatalError() + } + + if !status.reblogged, + Preferences.shared.confirmBeforeReblog { + let image: UIImage? + let reblogVisibilityActions: [CustomAlertController.MenuAction]? + if mastodonController.instanceFeatures.reblogVisibility { + image = UIImage(systemName: Status.Visibility.public.unfilledImageName) + reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in + CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in + self.doReblog(status: status, visibility: visibility) + } + } + } else { + image = nil + reblogVisibilityActions = [] + } + + let preview = ConfirmReblogStatusPreviewView(status: status) + var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [ + CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil), + CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in + self.doReblog(status: status, visibility: nil) + }) + ]) + if let reblogVisibilityActions { + var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil) + menuAction.isSecondaryMenu = true + config.actions.append(menuAction) + } + let alert = CustomAlertController(config: config) + delegate?.present(alert, animated: true) + } else { + doReblog(status: status, visibility: nil) + } + } + + private func doReblog(status: StatusMO, visibility: Status.Visibility?) { + let oldValue = status.reblogged + status.reblogged.toggle() + updateStatusState(status: status) + + let request: Request + if status.reblogged { + request = Status.reblog(statusID, visibility: visibility) + } else { + request = Status.unreblog(statusID) + } + Task { + do { + let (newStatus, _) = try await mastodonController.run(request) + mastodonController.persistentContainer.addOrUpdate(status: newStatus) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } catch { + status.reblogged = oldValue + // TODO: display error message + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } } } + +extension TimelineStatusCollectionViewCell: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + return UIContextMenuConfiguration() { + ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) + } actionProvider: { _ in + return UIMenu(children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: interaction.view!) ?? []) + } + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + if let viewController = animator.previewViewController, + let delegate { + animator.preferredCommitStyle = .pop + animator.addCompletion { + if let customPresenting = viewController as? CustomPreviewPresenting { + customPresenting.presentFromPreview(presenter: delegate) + } else { + delegate.show(viewController) + } + } + } + } +} + +extension TimelineStatusCollectionViewCell: UIDragInteractionDelegate { + func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] { + guard let currentAccountID = mastodonController.accountInfo?.id, + let account = mastodonController.persistentContainer.account(for: accountID) else { + return [] + } + let provider = NSItemProvider(object: account.url as NSURL) + let activity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID) + activity.displaysAuxiliaryScene = true + provider.registerObject(activity, visibility: .all) + return [UIDragItem(itemProvider: provider)] + } +}