Status cell interaction

This commit is contained in:
Shadowfacts 2022-10-05 22:28:10 -04:00
parent 780e8b09b7
commit 24e90de672
2 changed files with 255 additions and 26 deletions

View File

@ -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)
}
}
}

View File

@ -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 {
@ -242,6 +241,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<Status>
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)]
}
}