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
import Foundation
import Nuke
import SwiftUI
import UserNotifications
@Observable
@MainActor
final class NotificationsViewModel {
var notifications: [GrainNotification] = []
var grouped: [GroupedNotification] = []
var unseenCount: Int = 0 {
didSet { UNUserNotificationCenter.current().setBadgeCount(unseenCount) }
}
var isLoading = false
var error: Error?
private var cursor: String?
private var hasMore = true
private var client: XRPCClient
private var prefetcher = ImagePrefetcher()
init(client: XRPCClient) {
self.client = client
}
func updateClient(_ client: XRPCClient) {
self.client = client
}
func loadInitial(auth: AuthContext? = nil) async {
guard !isLoading else { return }
isLoading = true
error = nil
cursor = nil
hasMore = true
do {
async let page = client.getNotifications(auth: auth)
async let unread = try? client.getUnreadCount(auth: auth)
// The count first: it cannot throw, so both are awaited before
// a failed page leaves the scope.
let count = await unread
let response = try await page
withAnimation(nil) {
notifications = response.notifications
grouped = GroupedNotification.group(notifications)
unseenCount = count ?? response.notifications.filter { $0.isRead == false }.count
cursor = response.cursor
hasMore = response.cursor != nil
}
prefetchImages(response.notifications)
} catch {
self.error = error
}
isLoading = false
}
func loadMore(auth: AuthContext? = nil) async {
guard !isLoading, hasMore, let cursor else { return }
isLoading = true
do {
let response = try await client.getNotifications(cursor: cursor, auth: auth)
var updatedGroups = grouped
GroupedNotification.mergeNewPage(response.notifications, into: &updatedGroups)
withAnimation(nil) {
notifications.append(contentsOf: response.notifications)
grouped = updatedGroups
}
self.cursor = response.cursor
hasMore = response.cursor != nil
prefetchImages(response.notifications)
} catch {
self.error = error
}
isLoading = false
}
func markAsSeen(auth: AuthContext? = nil) async {
guard unseenCount > 0 else { return }
let previousCount = unseenCount
unseenCount = 0
do {
try await client.markNotificationsSeen(auth: auth)
} catch {
unseenCount = previousCount
}
}
private static let thumbSize: CGFloat = 44
private static let thumbSquareSize: CGSize = {
let px = thumbSize * UIScreen.main.scale
return CGSize(width: px, height: px)
}()
private static let thumbPortraitSize: CGSize = {
let scale = UIScreen.main.scale
return CGSize(width: thumbSize * scale, height: thumbSize * 4 / 3 * scale)
}()
private func prefetchImages(_ notifs: [GrainNotification]) {
let avatarURLs = notifs.compactMap(\.author.avatar).compactMap { URL(string: $0) }
var requests = avatarURLs.map { ImageRequest(url: $0) }
for notif in notifs {
if let thumb = notif.galleryThumb, let url = URL(string: thumb) {
requests.append(ImageRequest(url: url, processors: [.resize(size: Self.thumbSquareSize, contentMode: .aspectFill)]))
}
if let thumb = notif.storyThumb, let url = URL(string: thumb) {
requests.append(ImageRequest(url: url, processors: [.resize(size: Self.thumbPortraitSize, contentMode: .aspectFill)]))
}
}
prefetcher.startPrefetching(with: requests)
}
func fetchUnseenCount(auth: AuthContext? = nil) async {
do {
unseenCount = try await client.getUnreadCount(auth: auth)
} catch {
self.error = error
}
}
}