diff --git a/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift b/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift
index 84efc16e..9f5b80c2 100644
--- a/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift
+++ b/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift
@@ -8,8 +8,8 @@
import Foundation
-public class NotificationGroup: Identifiable, Hashable {
- public let notifications: [Notification]
+public struct NotificationGroup: Identifiable, Hashable {
+ public private(set) var notifications: [Notification]
public let id: String
public let kind: Notification.Kind
public let statusState: StatusState?
@@ -27,34 +27,90 @@ public class NotificationGroup: Identifiable, Hashable {
}
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
- return lhs.id == rhs.id
+ guard lhs.notifications.count == rhs.notifications.count else {
+ return false
+ }
+ for (a, b) in zip(lhs.notifications, rhs.notifications) where a.id != b.id {
+ return false
+ }
+ return true
}
public func hash(into hasher: inout Hasher) {
- hasher.combine(id)
+ for notification in notifications {
+ hasher.combine(notification.id)
+ }
+ }
+
+ private mutating func append(_ notification: Notification) {
+ notifications.append(notification)
+ }
+
+ private mutating func append(group: NotificationGroup) {
+ notifications.append(contentsOf: group.notifications)
}
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
- var groups = [[Notification]]()
+ var groups = [NotificationGroup]()
for notification in notifications {
if allowedTypes.contains(notification.kind) {
- if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
+ if let lastGroup = groups.last, canMerge(notification: notification, 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][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
+ if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
groups[groups.count - 2].append(notification)
continue
}
}
}
- groups.append([notification])
+ groups.append(NotificationGroup(notifications: [notification])!)
}
- return groups.map {
- NotificationGroup(notifications: $0)!
+ 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
+ }
+
+ public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
+ guard !first.isEmpty else {
+ return second
}
+ guard !second.isEmpty else {
+ return first
+ }
+
+ var merged = first
+ var second = second
+ merged.reserveCapacity(second.count)
+ while let firstGroupFromSecond = second.first,
+ allowedTypes.contains(firstGroupFromSecond.kind) {
+
+ second.removeFirst()
+
+ guard let lastGroup = merged.last,
+ allowedTypes.contains(lastGroup.kind) else {
+ merged.append(firstGroupFromSecond)
+ break
+ }
+
+
+ if canMerge(notification: firstGroupFromSecond.notifications.first!, 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) {
+ merged[merged.count - 2].append(group: firstGroupFromSecond)
+ }
+ } else {
+ merged.append(firstGroupFromSecond)
+ }
+ }
+ merged.append(contentsOf: second)
+ return merged
}
}
diff --git a/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift b/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift
new file mode 100644
index 00000000..a2069cbd
--- /dev/null
+++ b/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift
@@ -0,0 +1,235 @@
+//
+// NotificationGroupTests.swift
+//
+//
+// Created by Shadowfacts on 4/26/22.
+//
+
+import XCTest
+@testable import Pachyderm
+
+class NotificationGroupTests: XCTestCase {
+
+ let decoder: JSONDecoder = {
+ let d = JSONDecoder()
+ d.dateDecodingStrategy = .iso8601
+ return d
+ }()
+
+ let statusA = """
+ {
+ "id": "1",
+ "created_at": "2019-11-23T07:28:34Z",
+ "account": {
+ "id": "2",
+ "username": "bar",
+ "acct": "bar",
+ "display_name": "bar",
+ "locked": false,
+ "created_at": "2019-11-01T01:01:01Z",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 0,
+ "note": "",
+ "url": "https://example.com/@bar",
+ "uri": "https://example.com/@bar",
+ },
+ "url": "https://example.com/@bar/1",
+ "uri": "https://example.com/@bar/1",
+ "content": "",
+ "emojis": [],
+ "reblogs_count": 0,
+ "favourites_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "visibility": "public",
+ "media_attachments": [],
+ "mentions": [],
+ "tags": [],
+ }
+ }
+ """
+ lazy var likeA1Data = """
+ {
+ "id": "1",
+ "type": "favourite",
+ "created_at": "2019-11-23T07:29:18Z",
+ "account": {
+ "id": "1",
+ "username": "foo",
+ "acct": "foo",
+ "display_name": "foo",
+ "locked": false,
+ "created_at": "2019-11-01T01:01:01Z",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 0,
+ "note": "",
+ "url": "https://example.com/@foo",
+ "uri": "https://example.com/@foo",
+ },
+ "status": \(statusA)
+ """.data(using: .utf8)!
+ lazy var likeA1 = try! decoder.decode(Notification.self, from: likeA1Data)
+ lazy var likeA2Data = """
+ {
+ "id": "2",
+ "type": "favourite",
+ "created_at": "2019-11-23T07:30:00Z",
+ "account": {
+ "id": "2",
+ "username": "baz",
+ "acct": "baz",
+ "display_name": "baz",
+ "locked": false,
+ "created_at": "2019-11-01T01:01:01Z",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 0,
+ "note": "",
+ "url": "https://example.com/@baz",
+ "uri": "https://example.com/@baz",
+ },
+ "status": \(statusA)
+ """.data(using: .utf8)!
+ lazy var likeA2 = try! decoder.decode(Notification.self, from: likeA2Data)
+ let statusB = """
+ {
+ "id": "2",
+ "created_at": "2019-11-23T07:28:34Z",
+ "account": {
+ "id": "2",
+ "username": "bar",
+ "acct": "bar",
+ "display_name": "bar",
+ "locked": false,
+ "created_at": "2019-11-01T01:01:01Z",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 0,
+ "note": "",
+ "url": "https://example.com/@bar",
+ "uri": "https://example.com/@bar",
+ },
+ "url": "https://example.com/@bar/1",
+ "uri": "https://example.com/@bar/1",
+ "content": "",
+ "emojis": [],
+ "reblogs_count": 0,
+ "favourites_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "visibility": "public",
+ "media_attachments": [],
+ "mentions": [],
+ "tags": [],
+ }
+ }
+ """
+ lazy var likeBData = """
+ {
+ "id": "3",
+ "type": "favourite",
+ "created_at": "2019-11-23T07:29:18Z",
+ "account": {
+ "id": "1",
+ "username": "foo",
+ "acct": "foo",
+ "display_name": "foo",
+ "locked": false,
+ "created_at": "2019-11-01T01:01:01Z",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 0,
+ "note": "",
+ "url": "https://example.com/@foo",
+ "uri": "https://example.com/@foo",
+ },
+ "status": \(statusB)
+ """.data(using: .utf8)!
+ lazy var likeB = try! decoder.decode(Notification.self, from: likeBData)
+ lazy var mentionBData = """
+ {
+ "id": "4",
+ "type": "mention",
+ "created_at": "2019-11-23T07:29:18Z",
+ "account": {
+ "id": "1",
+ "username": "foo",
+ "acct": "foo",
+ "display_name": "foo",
+ "locked": false,
+ "created_at": "2019-11-01T01:01:01Z",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 0,
+ "note": "",
+ "url": "https://example.com/@foo",
+ "uri": "https://example.com/@foo",
+ },
+ "status": \(statusB)
+ """.data(using: .utf8)!
+ lazy var mentionB = try! decoder.decode(Notification.self, from: mentionBData)
+
+
+ func testGroupSimple() {
+ let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
+ XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
+ }
+
+ func testGroupWithOtherGroupableInBetween() {
+ let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
+ XCTAssertEqual(groups, [
+ NotificationGroup(notifications: [likeA1, likeA2])!,
+ NotificationGroup(notifications: [likeB])!,
+ ])
+ }
+
+ func testDontGroupWithUngroupableInBetween() {
+ let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
+ XCTAssertEqual(groups, [
+ NotificationGroup(notifications: [likeA1])!,
+ NotificationGroup(notifications: [mentionB])!,
+ NotificationGroup(notifications: [likeA2])!,
+ ])
+ }
+
+ func testMergeSimpleGroups() {
+ let group1 = NotificationGroup(notifications: [likeA1])!
+ let group2 = NotificationGroup(notifications: [likeA2])!
+ let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
+ XCTAssertEqual(merged, [
+ NotificationGroup(notifications: [likeA1, likeA2])!
+ ])
+ }
+
+ func testMergeGroupsWithOtherGroupableInBetween() {
+ let group1 = NotificationGroup(notifications: [likeA1])!
+ let group2 = NotificationGroup(notifications: [likeB])!
+ let group3 = NotificationGroup(notifications: [likeA2])!
+ let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
+ XCTAssertEqual(merged, [
+ NotificationGroup(notifications: [likeA1, likeA2])!,
+ NotificationGroup(notifications: [likeB])!,
+ ])
+
+ let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
+ XCTAssertEqual(merged2, [
+ NotificationGroup(notifications: [likeA1, likeA2])!,
+ NotificationGroup(notifications: [likeB])!,
+ ])
+ }
+
+ func testDontMergeWithUngroupableInBetween() {
+ let group1 = NotificationGroup(notifications: [likeA1])!
+ let group2 = NotificationGroup(notifications: [mentionB])!
+ let group3 = NotificationGroup(notifications: [likeA2])!
+ let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
+ XCTAssertEqual(merged, [
+ NotificationGroup(notifications: [likeA1])!,
+ NotificationGroup(notifications: [mentionB])!,
+ NotificationGroup(notifications: [likeA2])!,
+ ])
+ }
+
+}
diff --git a/Tusker.xcodeproj/xcshareddata/xcschemes/Pachyderm.xcscheme b/Tusker.xcodeproj/xcshareddata/xcschemes/Pachyderm.xcscheme
index 03a51284..b1d524c5 100644
--- a/Tusker.xcodeproj/xcshareddata/xcschemes/Pachyderm.xcscheme
+++ b/Tusker.xcodeproj/xcshareddata/xcschemes/Pachyderm.xcscheme
@@ -28,6 +28,16 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
+
+
+
+
()
+ snapshot.appendSections([.notifications])
+ snapshot.appendItems(merged, toSection: .notifications)
completion(.success(snapshot))
}
}
@@ -173,15 +176,14 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
self.newer = newer
}
- let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
+ let newerGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
- var snapshot = currentSnapshot()
- if let first = snapshot.itemIdentifiers(inSection: .notifications).first {
- snapshot.insertItems(groups, beforeItem: first)
- } else {
- snapshot.appendItems(groups, toSection: .notifications)
- }
+ let existingGroups = currentSnapshot().itemIdentifiers
+ let merged = NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([.notifications])
+ snapshot.appendItems(merged, toSection: .notifications)
completion(.success(snapshot))
}
}