From 2b9d384f8fe070abca8df4065b3dba564aa29cfe Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 7 May 2023 11:02:37 -0400 Subject: [PATCH] Convert follow notification to collection view cell --- Tusker.xcodeproj/project.pbxproj | 4 + ...wNotificationGroupCollectionViewCell.swift | 212 ++++++++++++++++++ ...otificationsCollectionViewController.swift | 6 + 3 files changed, 222 insertions(+) create mode 100644 Tusker/Screens/Notifications/FollowNotificationGroupCollectionViewCell.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index c4fc0241..f5b3cdcc 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -122,6 +122,7 @@ D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */; }; 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 */; }; 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 */; }; @@ -519,6 +520,7 @@ D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldVerificationView.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1075,6 +1077,7 @@ D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */, D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */, D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */, + D646DCD52A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift */, ); path = Notifications; sourceTree = ""; @@ -2073,6 +2076,7 @@ D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */, D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */, + D646DCD62A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift in Sources */, D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */, D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, diff --git a/Tusker/Screens/Notifications/FollowNotificationGroupCollectionViewCell.swift b/Tusker/Screens/Notifications/FollowNotificationGroupCollectionViewCell.swift new file mode 100644 index 00000000..00e4503b --- /dev/null +++ b/Tusker/Screens/Notifications/FollowNotificationGroupCollectionViewCell.swift @@ -0,0 +1,212 @@ +// +// FollowNotificationGroupCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 5/7/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class FollowNotificationGroupCollectionViewCell: UICollectionViewCell { + + private let iconView = UIImageView(image: UIImage(systemName: "person.fill.badge.plus")).configure { + $0.contentMode = .scaleAspectFit + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: 30), + $0.widthAnchor.constraint(equalToConstant: 30), + ]) + } + + private let avatarStack = UIStackView().configure { + $0.axis = .horizontal + $0.alignment = .fill + $0.spacing = 8 + } + + 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: [ + avatarStack, + 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 = MultiSourceEmojiLabel().configure { + $0.font = .preferredFont(forTextStyle: .body) + $0.adjustsFontForContentSizeCategory = true + $0.numberOfLines = 2 + $0.lineBreakMode = .byTruncatingTail + $0.combiner = { [unowned self] in self.updateActionLabel(names: $0) } + } + + private lazy var vStack = UIStackView(arrangedSubviews: [ + hStack, + actionLabel, + ]).configure { + $0.axis = .vertical + $0.alignment = .fill + } + + weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)? + private var mastodonController: MastodonController { delegate!.apiController } + + private var group: NotificationGroup! + + 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(group: NotificationGroup) { + guard group.kind == .follow else { + fatalError() + } + self.group = group + + let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } + + actionLabel.setEmojis(pairs: people.map { + ($0.displayOrUserName, $0.emojis) + }, identifier: group.id) + updateTimestamp() + + avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) { + let imageView = CachedImageView(cache: .avatars) + imageView.layer.masksToBounds = true + imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 + imageView.update(for: avatarURL) + avatarStack.addArrangedSubview(imageView) + } + NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map { + $0.widthAnchor.constraint(equalTo: $0.heightAnchor) + }) + } + + private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString { + // todo: figure out how to localize this + let str = NSMutableAttributedString(string: "Followed by ") + switch names.count { + case 1: + str.append(names.first!) + case 2: + str.append(names.first!) + str.append(NSAttributedString(string: " and ")) + str.append(names.last!) + default: + for (index, name) in names.enumerated() { + str.append(name) + if index < names.count - 2 { + str.append(NSAttributedString(string: ", ")) + } else if index == names.count - 2 { + str.append(NSAttributedString(string: ", and ")) + } + } + } + return str + } + + @objc private func updateUIForPreferences() { + for view in avatarStack.arrangedSubviews { + view.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: view) + } + } + + private func updateTimestamp() { + guard let notification = group.notifications.first else { + fatalError("Missing cached notification") + } + + 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() + } + + // MARK: Accessibility + + override var accessibilityLabel: String? { + get { + let first = group.notifications.first! + var str = "Followed by " + str += first.account.displayNameWithoutCustomEmoji + if group.notifications.count > 1 { + str += " and \(group.notifications.count - 1) more" + } + str += ", \(first.createdAt.formatted(.relative(presentation: .numeric)))" + return str + } + set {} + } + +} diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index 068abf65..757e75a3 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -87,6 +87,10 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle cell.delegate = self cell.updateUI(group: itemIdentifier) } + let followCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in + cell.delegate = self + cell.updateUI(group: itemIdentifier) + } let unknownCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in var config = cell.defaultContentConfiguration() config.text = "Unknown Notification" @@ -100,6 +104,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: group) case .favourite, .reblog: return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) + case .follow: + return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group) default: return collectionView.dequeueConfiguredReusableCell(using: unknownCell, for: indexPath, item: ()) }