~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
125 lines · 4.1 KB Raw History
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
        }
    }
}