1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
import Foundation
/// social.grain.notification.defs#notificationItem
struct GrainNotification: Codable, Sendable, Identifiable {
let uri: String
let reason: String
let createdAt: String
/// When the index first held it: what the list is ordered by.
var indexedAt: String?
/// Whether the viewer had marked their notifications seen since it came in.
var isRead: Bool?
let author: GrainProfile
var galleryUri: String?
var galleryTitle: String?
var galleryThumb: String?
var storyUri: String?
var storyThumb: String?
var commentText: String?
var replyToText: String?
var id: String {
uri
}
var reasonType: NotificationReason {
NotificationReason(rawValue: reason) ?? .unknown
}
}
enum NotificationReason: String, Sendable {
case galleryFavorite = "gallery-favorite"
case galleryComment = "gallery-comment"
case galleryCommentMention = "gallery-comment-mention"
case galleryMention = "gallery-mention"
case commentFavorite = "comment-favorite"
case storyFavorite = "story-favorite"
case storyComment = "story-comment"
case reply
case follow
case unknown
var isGroupable: Bool {
switch self {
case .galleryFavorite, .storyFavorite, .commentFavorite, .follow: true
default: false
}
}
}
struct GroupedNotification: Identifiable, Equatable, Hashable {
static func == (lhs: GroupedNotification, rhs: GroupedNotification) -> Bool {
lhs.notification.uri == rhs.notification.uri
&& lhs.cachedAuthors.count == rhs.cachedAuthors.count
}
func hash(into hasher: inout Hasher) {
hasher.combine(notification.uri)
}
let notification: GrainNotification
var additional: [GrainNotification]
/// Pre-computed on creation so SwiftUI doesn't recompute during layout.
private(set) var cachedAuthors: [GrainProfile]
var id: String {
notification.uri
}
var authorCount: Int {
cachedAuthors.count
}
var isGrouped: Bool {
!additional.isEmpty
}
var allAuthors: [GrainProfile] {
cachedAuthors
}
init(notification: GrainNotification, additional: [GrainNotification]) {
self.notification = notification
self.additional = additional
var seen = Set<String>()
var authors: [GrainProfile] = []
for notif in [notification] + additional where seen.insert(notif.author.did).inserted {
authors.append(notif.author)
}
cachedAuthors = authors
}
mutating func addAuthor(_ notif: GrainNotification) {
additional.append(notif)
if !cachedAuthors.contains(where: { $0.did == notif.author.did }) {
cachedAuthors.append(notif.author)
}
}
private nonisolated(unsafe) static let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
@MainActor
private static func parseDate(_ str: String) -> TimeInterval {
dateFormatter.date(from: str)?.timeIntervalSince1970 ?? 0
}
@MainActor
static func group(_ notifications: [GrainNotification]) -> [GroupedNotification] {
var groups: [GroupedNotification] = []
mergeNewPage(notifications, into: &groups)
return groups
}
/// Merge a new page into existing groups without regrouping the entire list.
@MainActor
static func mergeNewPage(_ newNotifs: [GrainNotification], into groups: inout [GroupedNotification]) {
for notif in newNotifs {
if notif.reasonType.isGroupable, absorb(notif, into: &groups) {
continue
}
groups.append(GroupedNotification(notification: notif, additional: []))
}
}
/// How far apart two notifications can be and still read as one event.
private static let groupingWindow: TimeInterval = 48 * 60 * 60
/// Fold `notif` into the first group it belongs to, returning false when no
/// group will take it and it needs a row of its own.
///
/// Somebody already on the row — whether as its head or in its facepile —
/// is absorbed without being counted twice, rather than being turned away
/// into a duplicate row. That second case is what an unfavorite followed by
/// a refavorite looks like coming back from the appview.
@MainActor
private static func absorb(_ notif: GrainNotification, into groups: inout [GroupedNotification]) -> Bool {
let timestamp = parseDate(notif.createdAt)
let subject = subjectKey(notif)
for i in groups.indices {
let group = groups[i]
guard abs(parseDate(group.notification.createdAt) - timestamp) < groupingWindow,
notif.reasonType == group.notification.reasonType,
subjectKey(group.notification) == subject
else { continue }
let alreadyOnTheRow = notif.author.did == group.notification.author.did
|| group.additional.contains { $0.author.did == notif.author.did }
if !alreadyOnTheRow {
groups[i].addAuthor(notif)
}
return true
}
return false
}
private static func subjectKey(_ notif: GrainNotification) -> String {
if notif.reasonType == .follow {
return "__follow__"
}
return notif.galleryUri ?? notif.storyUri ?? notif.uri
}
}