// // FollowRequestNotificationCollectionViewCell.swift // Tusker // // Created by Shadowfacts on 5/7/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell { 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.contentMode = .scaleAspectFill $0.layer.masksToBounds = true $0.layer.cornerCurve = .continuous $0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 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 $0.heightAnchor.constraint(equalToConstant: 30).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 func rejectButtonPressed() { acceptButton.isEnabled = false rejectButton.isEnabled = false Task { let request = Account.rejectFollowRequest(notification.account.id) do { _ = try await mastodonController.run(request) #if !os(visionOS) UIImpactFeedbackGenerator(style: .light).impactOccurred() #endif 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 delegate = delegate { let config = ToastConfiguration(from: error, with: "Rejecting Follow", in: delegate) { [weak self] toast in toast.dismissToast(animated: true) self?.rejectButtonPressed() } delegate.showToast(configuration: config, animated: true) } } } } @objc func acceptButtonPressed() { acceptButton.isEnabled = false rejectButton.isEnabled = false Task { let request = Account.authorizeFollowRequest(notification.account.id) do { _ = try await mastodonController.run(request) #if !os(visionOS) UIImpactFeedbackGenerator(style: .light).impactOccurred() #endif 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 delegate = delegate { let config = ToastConfiguration(from: error, with: "Accepting Follow", in: delegate) { [weak self] toast in toast.dismissToast(animated: true) self?.acceptButtonPressed() } delegate.showToast(configuration: config, animated: true) } } } } }