diff --git a/Packages/Models/Sources/Models/ConsolidatedNotification.swift b/Packages/Models/Sources/Models/ConsolidatedNotification.swift new file mode 100644 index 00000000..78ac0357 --- /dev/null +++ b/Packages/Models/Sources/Models/ConsolidatedNotification.swift @@ -0,0 +1,43 @@ +// +// ConsolidatedNotification.swift +// +// +// Created by Jérôme Danthinne on 31/01/2023. +// + +import Foundation + +public struct ConsolidatedNotification: Identifiable { + public let notifications: [Notification] + public let type: Notification.NotificationType + public let createdAt: ServerDate + public let accounts: [Account] + public let status: Status? + + public var id: String? { notifications.first?.id } + + public init(notifications: [Notification], + type: Notification.NotificationType, + createdAt: ServerDate, + accounts: [Account], + status: Status?) + { + self.notifications = notifications + self.type = type + self.createdAt = createdAt + self.accounts = accounts + self.status = status + } + + public static func placeholder() -> ConsolidatedNotification { + .init(notifications: [Notification.placeholder()], + type: .favourite, + createdAt: "2022-12-16T10:20:54.000Z", + accounts: [.placeholder()], + status: .placeholder()) + } + + public static func placeholders() -> [ConsolidatedNotification] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } +} diff --git a/Packages/Models/Sources/Models/Notification.swift b/Packages/Models/Sources/Models/Notification.swift index 872fe1a3..a82c119a 100644 --- a/Packages/Models/Sources/Models/Notification.swift +++ b/Packages/Models/Sources/Models/Notification.swift @@ -14,4 +14,12 @@ public struct Notification: Decodable, Identifiable { public var supportedType: NotificationType? { .init(rawValue: type) } + + public static func placeholder() -> Notification { + .init(id: UUID().uuidString, + type: NotificationType.favourite.rawValue, + createdAt: "2022-12-16T10:20:54.000Z", + account: .placeholder(), + status: .placeholder()) + } } diff --git a/Packages/Notifications/Sources/Notifications/ConsolidatedNotificationExt.swift b/Packages/Notifications/Sources/Notifications/ConsolidatedNotificationExt.swift new file mode 100644 index 00000000..fa91668c --- /dev/null +++ b/Packages/Notifications/Sources/Notifications/ConsolidatedNotificationExt.swift @@ -0,0 +1,18 @@ +// +// ConsolidatedNotificationExt.swift +// +// +// Created by Jérôme Danthinne on 31/01/2023. +// + +import Models + +extension ConsolidatedNotification { + var notificationIds: [String] { notifications.map(\.id) } +} + +extension Array where Element == ConsolidatedNotification { + var notificationCount: Int { + reduce(0) { $0 + ($1.accounts.isEmpty ? 1 : $1.accounts.count) } + } +} diff --git a/Packages/Notifications/Sources/Notifications/Notification+Consolidated.swift b/Packages/Notifications/Sources/Notifications/Notification+Consolidated.swift new file mode 100644 index 00000000..3a2c343e --- /dev/null +++ b/Packages/Notifications/Sources/Notifications/Notification+Consolidated.swift @@ -0,0 +1,29 @@ +// +// Notification+Consolidated.swift +// +// +// Created by Jérôme Danthinne on 31/01/2023. +// + +import Models + +extension Array where Element == Notification { + func consolidated(selectedType: Notification.NotificationType?) -> [ConsolidatedNotification] { + Dictionary(grouping: self) { $0.consolidationId(selectedType: selectedType) } + .values + .compactMap { notifications in + guard let notification = notifications.first, + let supportedType = notification.supportedType + else { return nil } + + return ConsolidatedNotification(notifications: notifications, + type: supportedType, + createdAt: notification.createdAt, + accounts: notifications.map(\.account), + status: notification.status) + } + .sorted { + $0.createdAt > $1.createdAt + } + } +} diff --git a/Packages/Notifications/Sources/Notifications/NotificationExt.swift b/Packages/Notifications/Sources/Notifications/NotificationExt.swift new file mode 100644 index 00000000..a9e70d22 --- /dev/null +++ b/Packages/Notifications/Sources/Notifications/NotificationExt.swift @@ -0,0 +1,31 @@ +// +// NotificationExt.swift +// +// +// Created by Jérôme Danthinne on 31/01/2023. +// + +import Models + +extension Notification { + func consolidationId(selectedType: Models.Notification.NotificationType?) -> String? { + guard let supportedType else { return nil } + + switch supportedType { + case .follow where selectedType != .follow: + // Always group followers, so use the type to group + return supportedType.rawValue + case .reblog, .favourite: + // Group boosts and favourites by status, so use the type + the related status id + return "\(supportedType.rawValue)-\(status?.id ?? "")" + default: + // Never group remaining ones, so use the notification id itself + return id + } + } + + func isConsolidable(selectedType: Models.Notification.NotificationType?) -> Bool { + // Notification is consolidable onlt if the consolidation id is not the notication id (unique) itself + consolidationId(selectedType: selectedType) != id + } +} diff --git a/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift b/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift index ea616d91..6c33a8d1 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift @@ -23,7 +23,6 @@ class NotificationsViewModel: ObservableObject { var client: Client? { didSet { if oldValue != client { - notifications = [] consolidatedNotifications = [] } } @@ -33,7 +32,6 @@ class NotificationsViewModel: ObservableObject { @Published var selectedType: Models.Notification.NotificationType? { didSet { if oldValue != selectedType { - notifications = [] consolidatedNotifications = [] Task { await fetchNotifications() @@ -51,7 +49,6 @@ class NotificationsViewModel: ObservableObject { return nil } - private var notifications: [Models.Notification] = [] private var consolidatedNotifications: [ConsolidatedNotification] = [] func fetchNotifications() async { @@ -64,7 +61,6 @@ class NotificationsViewModel: ObservableObject { try await client.get(endpoint: Notifications.notifications(sinceId: nil, maxId: nil, types: queryTypes)) - self.notifications = notifications consolidatedNotifications = notifications.consolidated(selectedType: selectedType) nextPageState = notifications.count < 15 ? .none : .hasNextPage } else if let first = consolidatedNotifications.first { @@ -76,7 +72,6 @@ class NotificationsViewModel: ObservableObject { newNotifications = newNotifications.filter { notification in !consolidatedNotifications.contains(where: { $0.id == notification.id }) } - notifications.append(contentsOf: newNotifications) consolidatedNotifications.insert( contentsOf: newNotifications.consolidated(selectedType: selectedType), at: 0 @@ -101,7 +96,6 @@ class NotificationsViewModel: ObservableObject { maxId: lastId, types: queryTypes)) consolidatedNotifications.append(contentsOf: newNotifications.consolidated(selectedType: selectedType)) - notifications.append(contentsOf: newNotifications) state = .display(notifications: consolidatedNotifications, nextPageState: newNotifications.count < 15 ? .none : .hasNextPage) } catch { state = .error(error: error) @@ -117,16 +111,29 @@ class NotificationsViewModel: ObservableObject { func handleEvent(event: any StreamEvent) { Task { + // Check if the event is a notification, + // if it is not already in the list, + // and if it can be shown (no selected type or the same as the received notification type) if let event = event as? StreamEventNotification, - !consolidatedNotifications.contains(where: { $0.id == event.notification.id }) + !consolidatedNotifications.flatMap(\.notificationIds).contains(event.notification.id), + selectedType == nil || selectedType?.rawValue == event.notification.type { - if let selectedType, event.notification.type == selectedType.rawValue { - notifications.insert(event.notification, at: 0) - consolidatedNotifications = notifications.consolidated(selectedType: selectedType) - } else if selectedType == nil { - notifications.insert(event.notification, at: 0) - consolidatedNotifications = notifications.consolidated(selectedType: selectedType) + if event.notification.isConsolidable(selectedType: selectedType) { + // If the notification type can be consolidated, try to consolidate with the latest row + let latestConsolidatedNotification = consolidatedNotifications.removeFirst() + consolidatedNotifications.insert( + contentsOf: ([event.notification] + latestConsolidatedNotification.notifications) + .consolidated(selectedType: selectedType), + at: 0 + ) + } else { + // Otherwise, just insert the new notification + consolidatedNotifications.insert( + contentsOf: [event.notification].consolidated(selectedType: selectedType), + at: 0 + ) } + withAnimation { state = .display(notifications: consolidatedNotifications, nextPageState: .hasNextPage) } @@ -134,66 +141,3 @@ class NotificationsViewModel: ObservableObject { } } } - -struct ConsolidatedNotification: Identifiable { - let notificationIds: [String] - let type: Models.Notification.NotificationType - let createdAt: ServerDate - let accounts: [Account] - let status: Status? - - var id: String? { notificationIds.first } - - static func placeholder() -> ConsolidatedNotification { - .init(notificationIds: [UUID().uuidString], - type: .favourite, - createdAt: "2022-12-16T10:20:54.000Z", - accounts: [.placeholder()], - status: .placeholder()) - } - - static func placeholders() -> [ConsolidatedNotification] { - [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] - } -} - -extension Array where Element == Models.Notification { - func consolidated(selectedType: Models.Notification.NotificationType?) -> [ConsolidatedNotification] { - Dictionary(grouping: self) { notification -> String? in - guard let supportedType = notification.supportedType else { return nil } - - switch supportedType { - case .follow where selectedType != .follow: - // Always group followers - return supportedType.rawValue - case .reblog, .favourite: - // Group boosts and favourites by status - return "\(supportedType.rawValue)-\(notification.status?.id ?? "")" - default: - // Never group remaining ones - return notification.id - } - } - .values - .compactMap { notifications in - guard let notification = notifications.first, - let supportedType = notification.supportedType - else { return nil } - - return ConsolidatedNotification(notificationIds: notifications.map(\.id), - type: supportedType, - createdAt: notification.createdAt, - accounts: notifications.map(\.account), - status: notification.status) - } - .sorted { - $0.createdAt > $1.createdAt - } - } -} - -extension Array where Element == ConsolidatedNotification { - var notificationCount: Int { - reduce(0) { $0 + ($1.accounts.isEmpty ? 1 : $1.accounts.count) } - } -}