diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift index 7294e0fc..2053b4a4 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift @@ -7,6 +7,7 @@ // import Foundation +import WebURL public struct Notification: Decodable, Sendable { public let id: String @@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable { public let createdAt: Date public let account: Account public let status: Status? + // Only present for pleroma emoji reactions + // Either an emoji or :shortcode: (for akkoma custom emoji reactions) + public let emoji: String? + public let emojiURL: WebURL? public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -27,6 +32,8 @@ public struct Notification: Decodable, Sendable { self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.account = try container.decode(Account.self, forKey: .account) self.status = try container.decodeIfPresent(Status.self, forKey: .status) + self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji) + self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL) } public static func dismiss(id notificationID: String) -> Request { @@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable { case createdAt = "created_at" case account case status + case emoji + case emojiURL = "emoji_url" } } @@ -52,6 +61,7 @@ extension Notification { case poll case update case status + case emojiReaction = "pleroma:emoji_reaction" case unknown } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index da96d644..33e26e3d 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -124,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable { return request } + public static func getReactions(_ statusID: String, emoji: String, range: RequestRange = .default) -> Request<[Account]> { + var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reactions/\(emoji)") + request.range = range + return request + } + public static func delete(_ statusID: String) -> Request { return Request(method: .delete, path: "/api/v1/statuses/\(statusID)") } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift index ae633c4b..47a9e5b1 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift @@ -7,17 +7,18 @@ // import Foundation +import WebURL public struct NotificationGroup: Identifiable, Hashable, Sendable { public private(set) var notifications: [Notification] public let id: String - public let kind: Notification.Kind + public let kind: Kind - public init?(notifications: [Notification]) { + public init?(notifications: [Notification], kind: Kind) { guard !notifications.isEmpty else { return nil } self.notifications = notifications self.id = notifications.first!.id - self.kind = notifications.first!.kind + self.kind = kind } public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool { @@ -43,31 +44,62 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable { private mutating func append(group: NotificationGroup) { notifications.append(contentsOf: group.notifications) } + + private static func groupKind(for notification: Notification) -> Kind { + switch notification.kind { + case .mention: + return .mention + case .reblog: + return .reblog + case .favourite: + return .favourite + case .follow: + return .follow + case .followRequest: + return .followRequest + case .poll: + return .poll + case .update: + return .update + case .status: + return .status + case .emojiReaction: + if let emoji = notification.emoji { + return .emojiReaction(emoji, notification.emojiURL) + } else { + return .unknown + } + case .unknown: + return .unknown + } + } @MainActor public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { var groups = [NotificationGroup]() for notification in notifications { + let groupKind = groupKind(for: notification) + if allowedTypes.contains(notification.kind) { - if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) { + if let lastGroup = groups.last, canMerge(notification: notification, kind: groupKind, into: lastGroup) { groups[groups.count - 1].append(notification) continue } else if groups.count >= 2 { let secondToLastGroup = groups[groups.count - 2] - if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) { + if allowedTypes.contains(notification.kind), canMerge(notification: notification, kind: groupKind, into: secondToLastGroup) { groups[groups.count - 2].append(notification) continue } } } - groups.append(NotificationGroup(notifications: [notification])!) + groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!) } return groups } - private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool { - return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id + private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool { + return kind == group.kind && notification.status?.id == group.notifications.first!.status?.id } public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { @@ -82,21 +114,21 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable { var second = second merged.reserveCapacity(second.count) while let firstGroupFromSecond = second.first, - allowedTypes.contains(firstGroupFromSecond.kind) { + allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) { second.removeFirst() guard let lastGroup = merged.last, - allowedTypes.contains(lastGroup.kind) else { + allowedTypes.contains(lastGroup.kind.notificationKind) else { merged.append(firstGroupFromSecond) break } - if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) { + if canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: lastGroup) { merged[merged.count - 1].append(group: firstGroupFromSecond) } else if merged.count >= 2 { let secondToLastGroup = merged[merged.count - 2] - if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) { + if allowedTypes.contains(secondToLastGroup.kind.notificationKind), canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: secondToLastGroup) { merged[merged.count - 2].append(group: firstGroupFromSecond) } else { merged.append(firstGroupFromSecond) @@ -109,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable { return merged } + public enum Kind: Sendable, Equatable { + case mention + case reblog + case favourite + case follow + case followRequest + case poll + case update + case status + case emojiReaction(String, WebURL?) + case unknown + + var notificationKind: Notification.Kind { + switch self { + case .mention: + .mention + case .reblog: + .reblog + case .favourite: + .favourite + case .follow: + .follow + case .followRequest: + .followRequest + case .poll: + .poll + case .update: + .update + case .status: + .status + case .emojiReaction(_, _): + .emojiReaction + case .unknown: + .unknown + } + } + } + } diff --git a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift index 3eb7c46c..1494c4fa 100644 --- a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift @@ -12,7 +12,16 @@ import HTMLStreamer class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { - private let iconView = UIImageView().configure { + 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([ @@ -21,6 +30,10 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { ]) } + private let iconLabel = UILabel().configure { + $0.font = .systemFont(ofSize: 30) + } + private let avatarStack = UIStackView().configure { $0.axis = .horizontal $0.alignment = .fill @@ -81,6 +94,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { private var group: NotificationGroup! private var statusID: String! + private var fetchCustomEmojiImage: (URL, Task)? private var updateTimestampWorkItem: DispatchWorkItem? deinit { @@ -90,15 +104,21 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { override init(frame: CGRect) { super.init(frame: frame) - iconView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(iconView) + iconImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(iconImageView) + iconLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(iconLabel) 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), + 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), @@ -116,7 +136,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { } func updateUI(group: NotificationGroup) { - guard group.kind == .favourite || group.kind == .reblog, + guard ActionNotificationGroupCollectionViewCell.canDisplay(group.kind), let firstNotification = group.notifications.first, let status = firstNotification.status else { fatalError() @@ -126,9 +146,29 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { switch group.kind { case .favourite: - iconView.image = UIImage(systemName: "star.fill") + iconImageView.image = UIImage(systemName: "star.fill") + iconLabel.text = "" + fetchCustomEmojiImage?.1.cancel() case .reblog: - iconView.image = UIImage(systemName: "repeat") + 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() } @@ -207,6 +247,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { verb = "Favorited" case .reblog: verb = "Reblogged" + case .emojiReaction(_, _): + verb = "Reacted to" default: fatalError() } @@ -252,6 +294,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { str += "Favorited by " case .reblog: str += "Reblogged by " + case .emojiReaction(let emoji, _): + str += "Reacted \(emoji) by " default: return nil } diff --git a/Tusker/Screens/Notifications/NotificationLoadingViewController.swift b/Tusker/Screens/Notifications/NotificationLoadingViewController.swift index 3a19f7fe..5bb28e7e 100644 --- a/Tusker/Screens/Notifications/NotificationLoadingViewController.swift +++ b/Tusker/Screens/Notifications/NotificationLoadingViewController.swift @@ -75,6 +75,17 @@ class NotificationLoadingViewController: UIViewController { } let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController) + case .emojiReaction: + guard let statusID = notification.status?.id else { + showLoadingError(Error.missingStatus) + return + } + guard let emoji = notification.emoji else { + showLoadingError(Error.unknownType) + return + } + let actionType = StatusActionAccountListViewController.ActionType.emojiReaction(emoji, notification.emojiURL) + vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController) case .follow: vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController) case .followRequest: diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index 14ac89c8..561c9534 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -178,7 +178,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle case .hide: return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) } - case .favourite, .reblog: + case .favourite, .reblog, .emojiReaction: return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) case .follow: return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group) @@ -317,7 +317,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle snapshot.deleteItems([.group(group, collapseState, filterState)]) } else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count { let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] } - snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState)) + snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed, kind: group.kind)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState)) snapshot.deleteItems([.group(group, collapseState, filterState)]) } await apply(snapshot, animatingDifferences: true) @@ -624,8 +624,8 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate { let state = collapseState?.copy() ?? .unknown selected(status: statusID, state: state) } - case .favourite, .reblog: - let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog + case .favourite, .reblog, .emojiReaction(_, _): + let type = StatusActionAccountListViewController.ActionType(group.kind)! let statusID = group.notifications.first!.status!.id let accountIDs = group.notifications.map(\.account.id).uniques() let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController) @@ -666,9 +666,9 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate { } actionProvider: { _ in UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update)) } - case .favourite, .reblog: + case .favourite, .reblog, .emojiReaction(_, _): return UIContextMenuConfiguration(previewProvider: { - let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog + let type = StatusActionAccountListViewController.ActionType(group.kind)! let statusID = group.notifications.first!.status!.id let accountIDs = group.notifications.map(\.account.id).uniques() return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController) @@ -751,7 +751,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] - case .favourite, .reblog: + case .favourite, .reblog, .emojiReaction(_, _): return [] case .follow, .followRequest: guard group.notifications.count == 1 else { diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift index ed6af405..6fe62d02 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift @@ -172,6 +172,8 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect return Status.getFavourites(statusID, range: range.withCount(Self.pageSize)) case .reblog: return Status.getReblogs(statusID, range: range.withCount(Self.pageSize)) + case .emojiReaction(let name, _): + return Status.getReactions(statusID, emoji: name, range: range.withCount(Self.pageSize)) } } diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift index d1e4a501..b2ea25a3 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import WebURL class StatusActionAccountListViewController: UIViewController { @@ -80,6 +81,8 @@ class StatusActionAccountListViewController: UIViewController { title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title") case .reblog: title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title") + case .emojiReaction(_, _): + title = "Reacted To By" } view.backgroundColor = .appBackground @@ -178,7 +181,22 @@ extension StatusActionAccountListViewController { extension StatusActionAccountListViewController { enum ActionType { - case favorite, reblog + case favorite + case reblog + case emojiReaction(String, WebURL?) + + init?(_ groupKind: NotificationGroup.Kind) { + switch groupKind { + case .reblog: + self = .reblog + case .favourite: + self = .favorite + case .emojiReaction(let emoji, let url): + self = .emojiReaction(emoji, url) + default: + return nil + } + } } }