// // ActionNotificationGroupCollectionViewCell.swift // Tusker // // Created by Shadowfacts on 5/6/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import HTMLStreamer class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { private static func canDisplay(_ kind: NotificationGroup.Kind) -> Bool { switch kind { case .favourite, .reblog, .emojiReaction: return true default: return false } } private let iconImageView = 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 iconLabel = UILabel().configure { $0.font = .systemFont(ofSize: 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 $0.heightAnchor.constraint(equalToConstant: 30).isActive = true } private lazy var actionLabel = MultiSourceEmojiLabel().configure { $0.font = .preferredFont(forTextStyle: .body) $0.adjustsFontForContentSizeCategory = true $0.numberOfLines = 2 $0.lineBreakMode = .byTruncatingTail $0.combiner = { [weak self] in self?.updateActionLabel(names: $0) ?? NSAttributedString() } } 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 fetchCustomEmojiImage: (URL, Task)? private var updateTimestampWorkItem: DispatchWorkItem? deinit { updateTimestampWorkItem?.cancel() } override init(frame: CGRect) { super.init(frame: frame) iconImageView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(iconImageView) iconLabel.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(iconLabel) vStack.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(vStack) NSLayoutConstraint.activate([ iconImageView.topAnchor.constraint(equalTo: vStack.topAnchor), iconImageView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50), iconLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor), iconLabel.bottomAnchor.constraint(equalTo: iconImageView.bottomAnchor), iconLabel.leadingAnchor.constraint(equalTo: iconImageView.leadingAnchor), iconLabel.trailingAnchor.constraint(equalTo: iconImageView.trailingAnchor), vStack.leadingAnchor.constraint(equalTo: iconImageView.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 ActionNotificationGroupCollectionViewCell.canDisplay(group.kind), let firstNotification = group.notifications.first, let status = firstNotification.status else { fatalError() } self.group = group self.statusID = status.id switch group.kind { case .favourite: iconImageView.image = UIImage(systemName: "star.fill") iconLabel.text = "" fetchCustomEmojiImage?.1.cancel() case .reblog: iconImageView.image = UIImage(systemName: "repeat") iconLabel.text = "" fetchCustomEmojiImage?.1.cancel() case .emojiReaction(let emojiOrShortcode, let url): iconImageView.image = nil if let url = url.flatMap({ URL($0) }), fetchCustomEmojiImage?.0 != url { fetchCustomEmojiImage?.1.cancel() let task = Task { let (_, image) = await ImageCache.emojis.get(url) if !Task.isCancelled { self.iconImageView.image = image } } fetchCustomEmojiImage = (url, task) } else { iconLabel.text = emojiOrShortcode fetchCustomEmojiImage?.1.cancel() } default: fatalError() } updateTimestamp() let people = group.notifications .uniques(by: \.account.id) .compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } let visibleAvatars = Array(people.lazy.compactMap(\.avatar).prefix(10)) for (index, avatarURL) in visibleAvatars.enumerated() { let imageView: CachedImageView if index < avatarStack.arrangedSubviews.count { imageView = avatarStack.arrangedSubviews[index] as! CachedImageView } else { imageView = CachedImageView(cache: .avatars) imageView.contentMode = .scaleAspectFill imageView.layer.masksToBounds = true imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerCurve = .continuous avatarStack.addArrangedSubview(imageView) imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true } imageView.update(for: avatarURL) } while avatarStack.arrangedSubviews.count > visibleAvatars.count { avatarStack.arrangedSubviews.last!.removeFromSuperview() } actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id) let converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) statusContentLabel.text = converter.convert(html: status.content) } @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" case .emojiReaction(_, _): verb = "Reacted to" 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 { guard let first = group.notifications.first else { return nil } var str = "" switch group.kind { case .favourite: str += "Favorited by " case .reblog: str += "Reblogged by " case .emojiReaction(let emoji, _): str += "Reacted \(emoji) 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 {} } }