Files
org-social-ios/App/ViewModels/NotificationsViewModel.swift
andros aa14fa990b Add unread badge to notifications tab; bump 1.5 (1)
Tracks which notification IDs the user has seen (persisted in
UserDefaults). RootView loads notifications in the background on
launch so the badge is ready before the user opens the tab. Opening
the notifications screen marks all as read and clears the badge.
First launch: badge stays at zero to avoid overwhelming new users
with historical notifications.
2026-05-24 09:23:47 +02:00

108 lines
4.0 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class NotificationsViewModel {
var notifications: [OrgSocialNotification] = []
var authorNicks: [URL: String] = [:]
var isLoading = false
var errorMessage: String?
private(set) var unreadCount: Int = 0
private let client = RelayClient()
private static let seenIDsKey = "notif.seenIDs"
private static let hasViewedKey = "notif.hasViewed"
var feedURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"),
let url = URL(string: raw),
url.scheme?.hasPrefix("http") == true else { return nil }
return url
}
var relayURL: URL {
let raw = UserDefaults.standard.string(forKey: "relayURL") ?? "https://relay.org-social.org"
return URL(string: raw) ?? URL(string: "https://relay.org-social.org")!
}
var useRelay: Bool {
UserDefaults.standard.object(forKey: "useRelay") as? Bool ?? true
}
func load() async {
guard !isLoading else { return }
guard useRelay else {
errorMessage = "Notifications require a relay. Enable Use Relay in Settings."
notifications = []
return
}
guard let feedURL else {
errorMessage = "Configure your Public Feed URL in Settings."
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let fetched = try await client.fetchNotifications(for: feedURL, from: relayURL)
// Deduplicate by ID to prevent ForEach from receiving duplicate keys,
// which causes UICollectionView item-count mismatches and crashes.
var seen = Set<String>()
let blocks = BlockList.shared
notifications = fetched
.filter { seen.insert($0.id).inserted }
.filter { notif in
guard let url = notif.authorFeedURL else { return true }
return !blocks.isBlocked(url)
}
recomputeUnread()
Task { await enrichNicks() }
} catch RelayError.httpError(let code) where code == 404 {
try? await client.registerFeed(feedURL, with: relayURL)
errorMessage = "Your feed isn't indexed by the relay yet. It will be available in a few minutes."
} catch {
errorMessage = error.localizedDescription
}
}
// MARK: - Unread badge
/// Marks all currently loaded notifications as seen and resets the badge to zero.
func markAllRead() {
guard !notifications.isEmpty else { return }
let ids = notifications.map(\.id)
UserDefaults.standard.set(ids, forKey: Self.seenIDsKey)
UserDefaults.standard.set(true, forKey: Self.hasViewedKey)
unreadCount = 0
}
/// Recomputes `unreadCount` from the current notifications vs. the persisted seen-IDs set.
/// On the very first launch (hasViewed == false) the badge stays at zero to avoid
/// overwhelming a new user with historical notifications.
private func recomputeUnread() {
guard UserDefaults.standard.bool(forKey: Self.hasViewedKey) else { return }
let seen = Set(UserDefaults.standard.stringArray(forKey: Self.seenIDsKey) ?? [])
unreadCount = notifications.filter { !seen.contains($0.id) }.count
}
private func enrichNicks() async {
let unknownURLs = Set(notifications.compactMap(\.authorFeedURL))
.filter { authorNicks[$0] == nil }
await withTaskGroup(of: (URL, String?).self) { group in
for url in unknownURLs {
group.addTask {
let profile = await PartialFeedFetcher().fetchProfileHeader(from: url)
let nick: String? = profile?.nick.flatMap { $0.isEmpty ? nil : $0 }
return (url, nick)
}
}
for await (url, nick) in group {
if let nick { authorNicks[url] = nick }
}
}
}
}