From 00945a002843b323ddd37028f6c2d093f1ab3a67 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 7 May 2023 11:10:18 -0400 Subject: [PATCH] Convert follow request notification to collection view cell --- .../Sources/Pachyderm/Model/Account.swift | 8 +- Tusker.xcodeproj/project.pbxproj | 4 + ...equestNotificationCollectionViewCell.swift | 281 ++++++++++++++++++ ...otificationsCollectionViewController.swift | 6 + ...llowRequestNotificationTableViewCell.swift | 4 +- 5 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Account.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Account.swift index 47bee445..646254b8 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Account.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Account.swift @@ -70,12 +70,12 @@ public final class Account: AccountProtocol, Decodable, Sendable { } } - public static func authorizeFollowRequest(_ account: Account) -> Request { - return Request(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize") + public static func authorizeFollowRequest(_ accountID: String) -> Request { + return Request(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize") } - public static func rejectFollowRequest(_ account: Account) -> Request { - return Request(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject") + public static func rejectFollowRequest(_ accountID: String) -> Request { + return Request(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject") } public static func removeFromFollowRequests(_ account: Account) -> Request { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index f5b3cdcc..200c88df 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */; }; D646DCD42A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */; }; D646DCD62A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD52A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift */; }; + D646DCD82A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */; }; D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; }; D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; }; D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; }; @@ -521,6 +522,7 @@ D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCollectionViewController.swift; sourceTree = ""; }; D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupCollectionViewCell.swift; sourceTree = ""; }; D646DCD52A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationGroupCollectionViewCell.swift; sourceTree = ""; }; + D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationCollectionViewCell.swift; sourceTree = ""; }; D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = ""; }; D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = ""; }; @@ -1078,6 +1080,7 @@ D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */, D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */, D646DCD52A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift */, + D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */, ); path = Notifications; sourceTree = ""; @@ -2009,6 +2012,7 @@ D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */, D620483623D38075008A63EF /* ContentTextView.swift in Sources */, D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */, + D646DCD82A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift in Sources */, D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */, D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */, D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, diff --git a/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift b/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift new file mode 100644 index 00000000..427493fd --- /dev/null +++ b/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift @@ -0,0 +1,281 @@ +// +// FollowRequestNotificationCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 5/7/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class FollowRequestNotificationCollectionViewCell: UICollectionViewCell { + + private let iconView = UIImageView(image: UIImage(systemName: "person.fill")).configure { + $0.contentMode = .scaleAspectFit + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: 30), + $0.widthAnchor.constraint(equalToConstant: 30), + ]) + } + + private let avatarImageView = CachedImageView(cache: .avatars).configure { + $0.layer.masksToBounds = true + NSLayoutConstraint.activate([ + $0.widthAnchor.constraint(equalTo: $0.heightAnchor), + ]) + } + + private let timestampLabel = UILabel().configure { + $0.textColor = .secondaryLabel + $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ + .traits: [ + UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue, + ] + ]), size: 0) + $0.adjustsFontForContentSizeCategory = true + } + + private lazy var hStack = UIStackView(arrangedSubviews: [ + avatarImageView, + UIView().configure { + $0.backgroundColor = .clear + $0.setContentHuggingPriority(.init(249), for: .horizontal) + }, + timestampLabel, + ]).configure { + $0.axis = .horizontal + $0.alignment = .fill + let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30) + heightConstraint.priority = .init(999) + heightConstraint.isActive = true + } + + private lazy var actionLabel = EmojiLabel().configure { + $0.font = .preferredFont(forTextStyle: .body) + $0.adjustsFontForContentSizeCategory = true + $0.numberOfLines = 2 + $0.lineBreakMode = .byTruncatingTail + } + + private lazy var acceptButton = UIButton(configuration: { + var config = UIButton.Configuration.plain() + config.image = UIImage(systemName: "checkmark.circle.fill") + config.title = "Accept" + return config + }()).configure { + $0.addTarget(self, action: #selector(acceptButtonPressed), for: .touchUpInside) + } + + private lazy var rejectButton = UIButton(configuration: { + var config = UIButton.Configuration.plain() + config.image = UIImage(systemName: "xmark.circle.fill") + config.title = "Reject" + return config + }()).configure { + $0.addTarget(self, action: #selector(rejectButtonPressed), for: .touchUpInside) + } + + private lazy var actionButtonsStack = UIStackView(arrangedSubviews: [ + acceptButton, + rejectButton, + ]).configure { + $0.axis = .horizontal + $0.distribution = .fillEqually + } + + private lazy var vStack = UIStackView(arrangedSubviews: [ + hStack, + actionLabel, + actionButtonsStack, + ]).configure { + $0.axis = .vertical + $0.alignment = .fill + } + + weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)? + private var mastodonController: MastodonController { delegate!.apiController } + + private var notification: Pachyderm.Notification! + + private var updateTimestampWorkItem: DispatchWorkItem? + + deinit { + updateTimestampWorkItem?.cancel() + } + + override init(frame: CGRect) { + super.init(frame: frame) + + iconView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(iconView) + vStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(vStack) + NSLayoutConstraint.activate([ + iconView.topAnchor.constraint(equalTo: vStack.topAnchor), + iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50), + + vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), + vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + ]) + + NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateConfiguration(using state: UICellConfigurationState) { + backgroundConfiguration = .appListPlainCell(for: state) + } + + func updateUI(notification: Pachyderm.Notification) { + guard notification.kind == .followRequest, + let account = mastodonController.persistentContainer.account(for: notification.account.id) else { + fatalError() + } + self.notification = notification + + updateActionLabel(account: account) + avatarImageView.update(for: account.avatar) + updateTimestamp() + } + + @objc private func updateUIForPreferences() { + avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 + + if let account = mastodonController.persistentContainer.account(for: notification.account.id) { + updateActionLabel(account: account) + } + } + + private func updateActionLabel(account: AccountMO) { + if Preferences.shared.hideCustomEmojiInUsernames { + actionLabel.text = "Request to follow from \(account.displayNameWithoutCustomEmoji)" + } else { + actionLabel.text = "Request to follow from \(account.displayOrUserName)" + } + } + + private func updateTimestamp() { + guard let notification = notification else { return } + + timestampLabel.text = notification.createdAt.timeAgoString() + + let delay: DispatchTimeInterval? + switch notification.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 + } + } + + override func prepareForReuse() { + super.prepareForReuse() + updateTimestampWorkItem?.cancel() + } + + private func addLabel(_ text: String) { + let label = UILabel() + label.textAlignment = .center + label.font = .boldSystemFont(ofSize: 17) + label.adjustsFontForContentSizeCategory = true + label.text = text + self.vStack.addArrangedSubview(label) + } + + // MARK: Accessibility + + override var accessibilityLabel: String? { + get { + guard let notification else { return nil } + var str = "Follow requested by " + str += notification.account.displayNameWithoutCustomEmoji + str += ", \(notification.createdAt.formatted(.relative(presentation: .numeric)))" + return str + } + set {} + } + + override var accessibilityCustomActions: [UIAccessibilityCustomAction]? { + get { + return [ + UIAccessibilityCustomAction(name: "Accept Request", target: self, selector: #selector(acceptButtonPressed)), + UIAccessibilityCustomAction(name: "Reject Request", target: self, selector: #selector(acceptButtonPressed)), + ] + } + set {} + } + + // MARK: - Interaction + + @objc private func rejectButtonPressed() { + acceptButton.isEnabled = false + rejectButton.isEnabled = false + + Task { + let request = Account.rejectFollowRequest(notification.account.id) + do { + _ = try await mastodonController.run(request) + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + self.actionButtonsStack.isHidden = true + self.addLabel(NSLocalizedString("Rejected", comment: "rejected follow request label")) + } catch let error as Client.Error { + acceptButton.isEnabled = true + rejectButton.isEnabled = true + if let toastable = delegate?.toastableViewController { + let config = ToastConfiguration(from: error, with: "Rejecting Follow", in: toastable) { [weak self] toast in + toast.dismissToast(animated: true) + self?.rejectButtonPressed() + } + toastable.showToast(configuration: config, animated: true) + } + } + } + } + + @objc private func acceptButtonPressed() { + acceptButton.isEnabled = false + rejectButton.isEnabled = false + + Task { + let request = Account.authorizeFollowRequest(notification.account.id) + do { + _ = try await mastodonController.run(request) + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + self.actionButtonsStack.isHidden = true + self.addLabel(NSLocalizedString("Accepted", comment: "accepted follow request label")) + } catch let error as Client.Error { + acceptButton.isEnabled = true + rejectButton.isEnabled = true + + if let toastable = delegate?.toastableViewController { + let config = ToastConfiguration(from: error, with: "Accepting Follow", in: toastable) { [weak self] toast in + toast.dismissToast(animated: true) + self?.acceptButtonPressed() + } + toastable.showToast(configuration: config, animated: true) + } + } + } + } + +} diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index 757e75a3..d2ee5170 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -91,6 +91,10 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle cell.delegate = self cell.updateUI(group: itemIdentifier) } + let followRequestCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in + cell.delegate = self + cell.updateUI(notification: itemIdentifier) + } let unknownCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in var config = cell.defaultContentConfiguration() config.text = "Unknown Notification" @@ -106,6 +110,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) case .follow: return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group) + case .followRequest: + return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: group.notifications.first!) default: return collectionView.dequeueConfiguredReusableCell(using: unknownCell, for: indexPath, item: ()) } diff --git a/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift b/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift index 8f977862..c3734a7d 100644 --- a/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift +++ b/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift @@ -169,7 +169,7 @@ class FollowRequestNotificationTableViewCell: UITableViewCell { rejectButton.isEnabled = false Task { - let request = Account.rejectFollowRequest(account) + let request = Account.rejectFollowRequest(account.id) do { _ = try await mastodonController.run(request) @@ -195,7 +195,7 @@ class FollowRequestNotificationTableViewCell: UITableViewCell { rejectButton.isEnabled = false Task { - let request = Account.authorizeFollowRequest(account) + let request = Account.authorizeFollowRequest(account.id) do { _ = try await mastodonController.run(request)