Compare commits
3 Commits
b89df3f27b
...
a589bb2863
Author | SHA1 | Date |
---|---|---|
Shadowfacts | a589bb2863 | |
Shadowfacts | 6f35fd2676 | |
Shadowfacts | e83cef1c8c |
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -44,30 +45,61 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
iconLabel.bottomAnchor.constraint(equalTo: iconImageView.bottomAnchor),
|
||||||
|
iconLabel.leadingAnchor.constraint(equalTo: iconImageView.leadingAnchor),
|
||||||
|
iconLabel.trailingAnchor.constraint(equalTo: iconImageView.trailingAnchor),
|
||||||
|
|
||||||
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue