Compare commits

...

3 Commits

Author SHA1 Message Date
Shadowfacts a589bb2863 Support emoji reaction push notifications on pleroma/akkoma 2024-04-18 13:17:55 -04:00
Shadowfacts 6f35fd2676 Show pleroma/akkoma emoji notifications
Closes #159
2024-04-18 12:59:44 -04:00
Shadowfacts e83cef1c8c Fix overzealously attempting to migrate local data to cloud store
Fix error when actually migrating due to not opening the store with
NSPersistentHistoryTrackingKey set to true.
2024-04-18 11:45:32 -04:00
15 changed files with 237 additions and 40 deletions

View File

@ -109,6 +109,12 @@ class NotificationService: UNNotificationServiceExtension {
kindStr = "📊 Poll finished" kindStr = "📊 Poll finished"
case .update: case .update:
kindStr = "✏️ Edited" kindStr = "✏️ Edited"
case .emojiReaction:
if let emoji = notification.emoji {
kindStr = "\(emoji) Reacted"
} else {
kindStr = nil
}
default: default:
kindStr = nil kindStr = nil
} }

View File

@ -213,6 +213,10 @@ public final class InstanceFeatures: ObservableObject {
hasMastodonVersion(3, 1, 0) hasMastodonVersion(3, 1, 0)
} }
public var emojiReactionNotifications: Bool {
instanceType.isPleroma
}
public init() { public init() {
} }

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public struct Notification: Decodable, Sendable { public struct Notification: Decodable, Sendable {
public let id: String public let id: String
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
public let createdAt: Date public let createdAt: Date
public let account: Account public let account: Account
public let status: Status? 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 { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) 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.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.account = try container.decode(Account.self, forKey: .account) self.account = try container.decode(Account.self, forKey: .account)
self.status = try container.decodeIfPresent(Status.self, forKey: .status) 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<Empty> { public static func dismiss(id notificationID: String) -> Request<Empty> {
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
case createdAt = "created_at" case createdAt = "created_at"
case account case account
case status case status
case emoji
case emojiURL = "emoji_url"
} }
} }
@ -52,6 +61,7 @@ extension Notification {
case poll case poll
case update case update
case status case status
case emojiReaction = "pleroma:emoji_reaction"
case unknown case unknown
} }
} }

View File

@ -44,6 +44,7 @@ public struct PushSubscription: Decodable, Sendable {
"data[alerts][favourite]" => alerts.favourite, "data[alerts][favourite]" => alerts.favourite,
"data[alerts][poll]" => alerts.poll, "data[alerts][poll]" => alerts.poll,
"data[alerts][update]" => alerts.update, "data[alerts][update]" => alerts.update,
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
"data[policy]" => policy.rawValue, "data[policy]" => policy.rawValue,
])) ]))
} }
@ -58,6 +59,7 @@ public struct PushSubscription: Decodable, Sendable {
"data[alerts][favourite]" => alerts.favourite, "data[alerts][favourite]" => alerts.favourite,
"data[alerts][poll]" => alerts.poll, "data[alerts][poll]" => alerts.poll,
"data[alerts][update]" => alerts.update, "data[alerts][update]" => alerts.update,
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
"data[policy]" => policy.rawValue, "data[policy]" => policy.rawValue,
])) ]))
} }
@ -85,8 +87,19 @@ extension PushSubscription {
public let favourite: Bool public let favourite: Bool
public let poll: Bool public let poll: Bool
public let update: Bool public let update: Bool
public let emojiReaction: Bool
public init(mention: Bool, status: Bool, reblog: Bool, follow: Bool, followRequest: Bool, favourite: Bool, poll: Bool, update: Bool) {
public init(
mention: Bool,
status: Bool,
reblog: Bool,
follow: Bool,
followRequest: Bool,
favourite: Bool,
poll: Bool,
update: Bool,
emojiReaction: Bool
) {
self.mention = mention self.mention = mention
self.status = status self.status = status
self.reblog = reblog self.reblog = reblog
@ -95,6 +108,7 @@ extension PushSubscription {
self.favourite = favourite self.favourite = favourite
self.poll = poll self.poll = poll
self.update = update self.update = update
self.emojiReaction = emojiReaction
} }
public init(from decoder: any Decoder) throws { public init(from decoder: any Decoder) throws {
@ -110,6 +124,8 @@ extension PushSubscription {
self.poll = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.poll) self.poll = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.poll)
// update added in mastodon 3.5.0 // update added in mastodon 3.5.0
self.update = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.update) ?? false self.update = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.update) ?? false
// pleroma/akkoma only
self.emojiReaction = try container.decodeIfPresent(Bool.self, forKey: .emojiReaction) ?? false
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@ -121,6 +137,7 @@ extension PushSubscription {
case favourite case favourite
case poll case poll
case update case update
case emojiReaction = "pleroma:emoji_reaction"
} }
} }
} }

View File

@ -124,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return request 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<Empty> { public static func delete(_ statusID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)") return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
} }

View File

@ -7,17 +7,18 @@
// //
import Foundation import Foundation
import WebURL
public struct NotificationGroup: Identifiable, Hashable, Sendable { public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification] public private(set) var notifications: [Notification]
public let id: String 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 } guard !notifications.isEmpty else { return nil }
self.notifications = notifications self.notifications = notifications
self.id = notifications.first!.id self.id = notifications.first!.id
self.kind = notifications.first!.kind self.kind = kind
} }
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool { public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
@ -43,31 +44,62 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
private mutating func append(group: NotificationGroup) { private mutating func append(group: NotificationGroup) {
notifications.append(contentsOf: group.notifications) 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 @MainActor
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [NotificationGroup]() var groups = [NotificationGroup]()
for notification in notifications { for notification in notifications {
let groupKind = groupKind(for: notification)
if allowedTypes.contains(notification.kind) { 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) groups[groups.count - 1].append(notification)
continue continue
} else if groups.count >= 2 { } else if groups.count >= 2 {
let secondToLastGroup = groups[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) groups[groups.count - 2].append(notification)
continue continue
} }
} }
} }
groups.append(NotificationGroup(notifications: [notification])!) groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
} }
return groups return groups
} }
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool { private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id 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] { 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 var second = second
merged.reserveCapacity(second.count) merged.reserveCapacity(second.count)
while let firstGroupFromSecond = second.first, while let firstGroupFromSecond = second.first,
allowedTypes.contains(firstGroupFromSecond.kind) { allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
second.removeFirst() second.removeFirst()
guard let lastGroup = merged.last, guard let lastGroup = merged.last,
allowedTypes.contains(lastGroup.kind) else { allowedTypes.contains(lastGroup.kind.notificationKind) else {
merged.append(firstGroupFromSecond) merged.append(firstGroupFromSecond)
break 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) merged[merged.count - 1].append(group: firstGroupFromSecond)
} else if merged.count >= 2 { } else if merged.count >= 2 {
let secondToLastGroup = merged[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) merged[merged.count - 2].append(group: firstGroupFromSecond)
} else { } else {
merged.append(firstGroupFromSecond) merged.append(firstGroupFromSecond)
@ -109,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
return merged 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
}
}
}
} }

View File

@ -70,6 +70,7 @@ public struct PushSubscription {
public static let favorite = Alerts(rawValue: 1 << 5) public static let favorite = Alerts(rawValue: 1 << 5)
public static let poll = Alerts(rawValue: 1 << 6) public static let poll = Alerts(rawValue: 1 << 6)
public static let update = Alerts(rawValue: 1 << 7) public static let update = Alerts(rawValue: 1 << 7)
public static let emojiReaction = Alerts(rawValue: 1 << 8)
public let rawValue: Int public let rawValue: Int

View File

@ -58,7 +58,8 @@ private extension Pachyderm.PushSubscription.Alerts {
followRequest: alerts.contains(.followRequest), followRequest: alerts.contains(.followRequest),
favourite: alerts.contains(.favorite), favourite: alerts.contains(.favorite),
poll: alerts.contains(.poll), poll: alerts.contains(.poll),
update: alerts.contains(.update) update: alerts.contains(.update),
emojiReaction: alerts.contains(.emojiReaction)
) )
} }
} }

View File

@ -124,12 +124,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
// migrate saved data from local store to cloud store // migrate saved data from local store to cloud store
// this can be removed pre-app store release // this can be removed pre-app store release
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! if !FileManager.default.fileExists(atPath: cloudStoreLocation.path) {
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
if FileManager.default.fileExists(atPath: defaultPath.path) {
group.enter() group.enter()
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
let defaultDesc = NSPersistentStoreDescription(url: defaultPath) let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
defaultDesc.configuration = "Default" defaultDesc.configuration = "Default"
defaultDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel) let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
defaultPSC.persistentStoreDescriptions = [defaultDesc] defaultPSC.persistentStoreDescriptions = [defaultDesc]
defaultPSC.loadPersistentStores { _, error in defaultPSC.loadPersistentStores { _, error in

View File

@ -12,7 +12,16 @@ import HTMLStreamer
class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { 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.tintColor = UIColor(red: 1, green: 204/255, blue: 0, alpha: 1)
$0.contentMode = .scaleAspectFit $0.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -21,6 +30,10 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
]) ])
} }
private let iconLabel = UILabel().configure {
$0.font = .systemFont(ofSize: 30)
}
private let avatarStack = UIStackView().configure { private let avatarStack = UIStackView().configure {
$0.axis = .horizontal $0.axis = .horizontal
$0.alignment = .fill $0.alignment = .fill
@ -81,6 +94,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
private var group: NotificationGroup! private var group: NotificationGroup!
private var statusID: String! private var statusID: String!
private var fetchCustomEmojiImage: (URL, Task<Void, Never>)?
private var updateTimestampWorkItem: DispatchWorkItem? private var updateTimestampWorkItem: DispatchWorkItem?
deinit { deinit {
@ -90,15 +104,21 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
iconView.translatesAutoresizingMaskIntoConstraints = false iconImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconView) contentView.addSubview(iconImageView)
iconLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconLabel)
vStack.translatesAutoresizingMaskIntoConstraints = false vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack) contentView.addSubview(vStack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor), iconImageView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50), iconImageView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
iconLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor),
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), 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.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
@ -116,7 +136,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
} }
func updateUI(group: NotificationGroup) { func updateUI(group: NotificationGroup) {
guard group.kind == .favourite || group.kind == .reblog, guard ActionNotificationGroupCollectionViewCell.canDisplay(group.kind),
let firstNotification = group.notifications.first, let firstNotification = group.notifications.first,
let status = firstNotification.status else { let status = firstNotification.status else {
fatalError() fatalError()
@ -126,9 +146,29 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
switch group.kind { switch group.kind {
case .favourite: case .favourite:
iconView.image = UIImage(systemName: "star.fill") iconImageView.image = UIImage(systemName: "star.fill")
iconLabel.text = ""
fetchCustomEmojiImage?.1.cancel()
case .reblog: 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: default:
fatalError() fatalError()
} }
@ -207,6 +247,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
verb = "Favorited" verb = "Favorited"
case .reblog: case .reblog:
verb = "Reblogged" verb = "Reblogged"
case .emojiReaction(_, _):
verb = "Reacted to"
default: default:
fatalError() fatalError()
} }
@ -252,6 +294,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
str += "Favorited by " str += "Favorited by "
case .reblog: case .reblog:
str += "Reblogged by " str += "Reblogged by "
case .emojiReaction(let emoji, _):
str += "Reacted \(emoji) by "
default: default:
return nil return nil
} }

View File

@ -75,6 +75,17 @@ class NotificationLoadingViewController: UIViewController {
} }
let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite
vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController) 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: case .follow:
vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController) vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController)
case .followRequest: case .followRequest:

View File

@ -178,7 +178,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
case .hide: case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
} }
case .favourite, .reblog: case .favourite, .reblog, .emojiReaction:
return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group)
case .follow: case .follow:
return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group) return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group)
@ -317,7 +317,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
snapshot.deleteItems([.group(group, collapseState, filterState)]) snapshot.deleteItems([.group(group, collapseState, filterState)])
} else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count { } else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {
let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] } 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)]) snapshot.deleteItems([.group(group, collapseState, filterState)])
} }
await apply(snapshot, animatingDifferences: true) await apply(snapshot, animatingDifferences: true)
@ -624,8 +624,8 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
let state = collapseState?.copy() ?? .unknown let state = collapseState?.copy() ?? .unknown
selected(status: statusID, state: state) selected(status: statusID, state: state)
} }
case .favourite, .reblog: case .favourite, .reblog, .emojiReaction(_, _):
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog let type = StatusActionAccountListViewController.ActionType(group.kind)!
let statusID = group.notifications.first!.status!.id let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id).uniques() let accountIDs = group.notifications.map(\.account.id).uniques()
let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController) let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
@ -666,9 +666,9 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
} actionProvider: { _ in } actionProvider: { _ in
UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update)) 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: { 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 statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id).uniques() let accountIDs = group.notifications.map(\.account.id).uniques()
return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController) return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
@ -751,7 +751,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
case .favourite, .reblog: case .favourite, .reblog, .emojiReaction(_, _):
return [] return []
case .follow, .followRequest: case .follow, .followRequest:
guard group.notifications.count == 1 else { guard group.notifications.count == 1 else {

View File

@ -74,6 +74,9 @@ private struct PushSubscriptionSettingsView: View {
if mastodonController.instanceFeatures.pushNotificationTypeUpdate { if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
toggle("Edits", alert: .update) toggle("Edits", alert: .update)
} }
if mastodonController.instanceFeatures.emojiReactionNotifications {
toggle("Reactions", alert: .emojiReaction)
}
// status notifications not supported until we can enable/disable them in the app // status notifications not supported until we can enable/disable them in the app
} }
} }
@ -81,14 +84,17 @@ private struct PushSubscriptionSettingsView: View {
} }
private var allSupportedAlertTypes: PushSubscription.Alerts { private var allSupportedAlertTypes: PushSubscription.Alerts {
var alerts: PushSubscription.Alerts = [.mention, .favorite, .reblog, .follow, .poll] var all: PushSubscription.Alerts = [.mention, .favorite, .reblog, .follow, .poll]
if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest { if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest {
alerts.insert(.followRequest) all.insert(.followRequest)
} }
if mastodonController.instanceFeatures.pushNotificationTypeUpdate { if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
alerts.insert(.update) all.insert(.update)
} }
return alerts if mastodonController.instanceFeatures.emojiReactionNotifications {
all.insert(.emojiReaction)
}
return all
} }
private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View { private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View {

View File

@ -172,6 +172,8 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
return Status.getFavourites(statusID, range: range.withCount(Self.pageSize)) return Status.getFavourites(statusID, range: range.withCount(Self.pageSize))
case .reblog: case .reblog:
return Status.getReblogs(statusID, range: range.withCount(Self.pageSize)) return Status.getReblogs(statusID, range: range.withCount(Self.pageSize))
case .emojiReaction(let name, _):
return Status.getReactions(statusID, emoji: name, range: range.withCount(Self.pageSize))
} }
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURL
class StatusActionAccountListViewController: UIViewController { class StatusActionAccountListViewController: UIViewController {
@ -80,6 +81,8 @@ class StatusActionAccountListViewController: UIViewController {
title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title") title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title")
case .reblog: case .reblog:
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title") title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
case .emojiReaction(_, _):
title = "Reacted To By"
} }
view.backgroundColor = .appBackground view.backgroundColor = .appBackground
@ -178,7 +181,22 @@ extension StatusActionAccountListViewController {
extension StatusActionAccountListViewController { extension StatusActionAccountListViewController {
enum ActionType { 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
}
}
} }
} }