From 90efee3f20da8e2269ca2c523b1390f86f6ce0da Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 7 May 2023 10:37:03 -0400 Subject: [PATCH] Convert action group notification to collection view cell --- Tusker.xcodeproj/project.pbxproj | 4 + ...nNotificationGroupCollectionViewCell.swift | 263 ++++++++++++++++++ ...otificationsCollectionViewController.swift | 7 + 3 files changed, 274 insertions(+) create mode 100644 Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 41ca4131..c4fc0241 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */; }; 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 */; }; 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 */; }; @@ -517,6 +518,7 @@ D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldValueView.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1072,6 +1074,7 @@ D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */, D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */, D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */, + D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */, ); path = Notifications; sourceTree = ""; @@ -2158,6 +2161,7 @@ D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, + D646DCD42A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift in Sources */, D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */, D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */, D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */, diff --git a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift new file mode 100644 index 00000000..d84958d6 --- /dev/null +++ b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift @@ -0,0 +1,263 @@ +// +// ActionNotificationGroupCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 5/6/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import SwiftSoup + +class ActionNotificationGroupCollectionViewCell: UICollectionViewCell { + + private let iconView = UIImageView().configure { + $0.tintColor = UIColor(red: 1, green: 204/255, blue: 0, alpha: 1) + $0.contentMode = .scaleAspectFit + NSLayoutConstraint.activate([ + $0.widthAnchor.constraint(equalToConstant: 30), + $0.heightAnchor.constraint(equalToConstant: 30), + ]) + } + + private let avatarStack = UIStackView().configure { + $0.axis = .horizontal + $0.alignment = .fill + $0.spacing = 4 + } + + 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 + $0.spacing = 8 + let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30) + // the collection view cell imposes a height constraint before it's calculated the actual height + // so let this constraint be broken temporarily to avoid unsatisfiable constraints log spam + 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 let statusContentLabel = UILabel().configure { + $0.textColor = .secondaryLabel + $0.font = .preferredFont(forTextStyle: .body) + $0.adjustsFontForContentSizeCategory = true + $0.numberOfLines = 3 + $0.lineBreakMode = .byTruncatingTail + } + + private lazy var vStack = UIStackView(arrangedSubviews: [ + hStack, + actionLabel, + statusContentLabel, + ]).configure { + $0.axis = .vertical + } + + weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)? + private var mastodonController: MastodonController { delegate!.apiController } + + private var group: NotificationGroup! + private var statusID: String! + + 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 == .favourite || group.kind == .reblog, + let firstNotification = group.notifications.first, + let status = firstNotification.status else { + fatalError() + } + self.group = group + self.statusID = status.id + + switch group.kind { + case .favourite: + iconView.image = UIImage(systemName: "star.fill") + case .reblog: + iconView.image = UIImage(systemName: "repeat") + default: + fatalError() + } + + updateTimestamp() + + let people = group.notifications.compactMap { + mastodonController.persistentContainer.account(for: $0.account.id) + } + + avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) { + let imageView = CachedImageView(cache: .avatars) + imageView.contentMode = .scaleAspectFit + imageView.layer.masksToBounds = true + imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 + avatarStack.addArrangedSubview(imageView) + imageView.update(for: avatarURL) + } + NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map { + $0.widthAnchor.constraint(equalTo: $0.heightAnchor) + }) + + actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id) + + // todo: use htmlconverter + let doc = try! SwiftSoup.parseBodyFragment(status.content) + statusContentLabel.text = try! doc.text() + } + + @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() + } + 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 + } + } + + private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString { + let verb: String + switch group.kind { + case .favourite: + verb = "Favorited" + case .reblog: + verb = "Reblogged" + default: + fatalError() + } + + // todo: figure out how to localize this + let str = NSMutableAttributedString(string: "\(verb) 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 + } + + override func prepareForReuse() { + super.prepareForReuse() + + updateTimestampWorkItem?.cancel() + } + + // MARK: Accessibility + + override var accessibilityLabel: String? { + get { + let first = group.notifications.first! + var str = "" + switch group.kind { + case .favourite: + str += "Favorited by " + case .reblog: + str += "Reblogged by " + default: + return nil + } + str += first.account.displayNameWithoutCustomEmoji + if group.notifications.count > 1 { + str += " and \(group.notifications.count - 1) more" + } + str += ", \(first.createdAt.formatted(.relative(presentation: .numeric))), " + str += statusContentLabel.text ?? "" + return str + } + set {} + } + +} diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index c0fab646..068abf65 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -83,6 +83,10 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle let statusState = itemIdentifier.statusState! cell.updateUI(statusID: statusID, state: statusState, filterResult: .allow, precomputedContent: nil) } + let actionGroupCell = 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" @@ -94,6 +98,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle switch group.kind { case .status, .mention: return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: group) + case .favourite, .reblog: + return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) default: return collectionView.dequeueConfiguredReusableCell(using: unknownCell, for: indexPath, item: ()) } @@ -169,6 +175,7 @@ extension NotificationsCollectionViewController { return Client.getNotifications(allowedTypes: allowedTypes, range: range) } else { var types = Set(Notification.Kind.allCases) + types.remove(.unknown) allowedTypes.forEach { types.remove($0) } return Client.getNotifications(excludedTypes: Array(types), range: range) }