From eb9a5aeb4215f7acd58b346de1461f3cc0d1b645 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 26 Apr 2022 22:57:46 -0400 Subject: [PATCH] Perform grouping with existing notifications when refreshing Closes #88 --- .../Utilities/NotificationGroup.swift | 76 +++++- .../NotificationGroupTests.swift | 235 ++++++++++++++++++ .../xcshareddata/xcschemes/Pachyderm.xcscheme | 10 + .../NotificationsTableViewController.swift | 22 +- 4 files changed, 323 insertions(+), 20 deletions(-) create mode 100644 Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift 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)) } }