From 5a4e387026159f880bad13e02f1351791ab4173d Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 7 May 2023 13:52:31 -0400 Subject: [PATCH] Convert poll finished notification to collection view cell --- Tusker.xcodeproj/project.pbxproj | 4 + ...otificationsCollectionViewController.swift | 6 + ...nishedNotificationCollectionViewCell.swift | 205 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 Tusker/Screens/Notifications/PollFinishedNotificationCollectionViewCell.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 200c88df..e37d0aa1 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 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 */; }; + D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.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 */; }; @@ -523,6 +524,7 @@ 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 = ""; }; + D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedNotificationCollectionViewCell.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 = ""; }; @@ -1081,6 +1083,7 @@ D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */, D646DCD52A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift */, D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */, + D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */, ); path = Notifications; sourceTree = ""; @@ -2057,6 +2060,7 @@ D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */, + D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */, D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index d2ee5170..339c038f 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -95,6 +95,10 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle cell.delegate = self cell.updateUI(notification: itemIdentifier) } + let pollCell = 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" @@ -112,6 +116,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group) case .followRequest: return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: group.notifications.first!) + case .poll: + return collectionView.dequeueConfiguredReusableCell(using: pollCell, for: indexPath, item: group.notifications.first!) default: return collectionView.dequeueConfiguredReusableCell(using: unknownCell, for: indexPath, item: ()) } diff --git a/Tusker/Screens/Notifications/PollFinishedNotificationCollectionViewCell.swift b/Tusker/Screens/Notifications/PollFinishedNotificationCollectionViewCell.swift new file mode 100644 index 00000000..41ab1ef5 --- /dev/null +++ b/Tusker/Screens/Notifications/PollFinishedNotificationCollectionViewCell.swift @@ -0,0 +1,205 @@ +// +// PollFinishedNotificationCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 5/7/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import SwiftSoup + +class PollFinishedNotificationCollectionViewCell: UICollectionViewCell { + + private let iconView = UIImageView(image: UIImage(systemName: "checkmark.square.fill")).configure { + $0.contentMode = .scaleAspectFit + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: 30), + $0.widthAnchor.constraint(equalToConstant: 30), + ]) + } + + private let descriptionLabel = UILabel().configure { + $0.text = "A poll has finished" + $0.font = .preferredFont(forTextStyle: .body) + $0.adjustsFontForContentSizeCategory = true + $0.setContentHuggingPriority(.init(249), for: .horizontal) + } + + 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: [ + descriptionLabel, + timestampLabel, + ]).configure { + $0.axis = .horizontal + $0.alignment = .fill + } + + private let displayNameLabel = EmojiLabel().configure { + $0.textColor = .secondaryLabel + $0.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)! + $0.adjustsFontForContentSizeCategory = true + $0.numberOfLines = 2 + $0.lineBreakMode = .byTruncatingTail + } + + private let contentLabel = UILabel().configure { + $0.textColor = .secondaryLabel + $0.font = .preferredFont(forTextStyle: .body) + $0.adjustsFontForContentSizeCategory = true + $0.numberOfLines = 2 + $0.lineBreakMode = .byTruncatingTail + } + + private let pollView = StatusPollView() + + private lazy var vStack = UIStackView(arrangedSubviews: [ + hStack, + displayNameLabel, + contentLabel, + pollView, + ]).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) + let vStackBottomConstraint = vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8) + // need something to break during intermediate layouts when the cell imposes a 44pt height :S + vStackBottomConstraint.priority = .init(999) + 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), + vStackBottomConstraint, + ]) + + 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 let statusID = notification.status?.id, + let status = mastodonController.persistentContainer.status(for: statusID), + let account = mastodonController.persistentContainer.account(for: notification.account.id), + let poll = status.poll else { + return + } + self.notification = notification + + updateTimestamp() + updateDisplayName(account: account) + + // todo: use htmlconverter + let doc = try! SwiftSoup.parseBodyFragment(status.content) + contentLabel.text = try! doc.text() + + pollView.mastodonController = mastodonController + pollView.toastableViewController = delegate + pollView.updateUI(status: status, poll: poll) + } + + @objc private func updateUIForPreferences() { + if let account = mastodonController.persistentContainer.account(for: notification.account.id) { + self.updateDisplayName(account: account) + } + } + + private func updateDisplayName(account: AccountMO) { + if Preferences.shared.hideCustomEmojiInUsernames { + displayNameLabel.text = account.displayNameWithoutCustomEmoji + displayNameLabel.removeEmojis() + } else { + displayNameLabel.text = account.displayOrUserName + displayNameLabel.setEmojis(account.emojis, identifier: account.id) + } + } + + 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() + } + + // MARK: Accessibility + + override var accessibilityLabel: String? { + get { + guard let notification else { return nil } + var str = "Poll from " + str += notification.account.displayNameWithoutCustomEmoji + str += " finished " + str += notification.createdAt.formatted(.relative(presentation: .numeric)) + if let poll = notification.status?.poll, + poll.options.contains(where: { ($0.votesCount ?? 0) > 0 }) { + let winner = poll.options.max(by: { ($0.votesCount ?? 0) < ($1.votesCount ?? 0) })! + str += ", winning option: \(winner.title)" + } + return str + } + set {} + } + +}