diff --git a/Blackbook.xcodeproj/project.pbxproj b/Blackbook.xcodeproj/project.pbxproj index 64a4960..d5604c2 100644 --- a/Blackbook.xcodeproj/project.pbxproj +++ b/Blackbook.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 1D99B1A030BE9CA3B7CF9713 /* ContactDeduplicationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBCD54BD3218CA370A819170 /* ContactDeduplicationServiceTests.swift */; }; 1E261EB5A02886029E363553 /* SyncTypesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6169F3E4455CDCD77042D958 /* SyncTypesTests.swift */; }; 1F15A9907F62206EA97F4555 /* NetworkGraphEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682862BCC7F0F257DF35CEB8 /* NetworkGraphEngine.swift */; }; + 233530392EB2ADCFAA331164 /* AppNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FE0A50E27F3BABB99ABE /* AppNotification.swift */; }; 25A80D3929B5841C1D224E29 /* TagModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12AE2904F18728288E8C2218 /* TagModelTests.swift */; }; 25F4C43A32CF59B6B592D404 /* DateHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815CE74C56D359F1D0990254 /* DateHelpers.swift */; }; 264F82AF586E5CA45E98051A /* NetworkGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA16574DE27E1A8A48DC9564 /* NetworkGraphView.swift */; }; @@ -35,6 +36,7 @@ 3882488C9E1137855A4160D3 /* ContactSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AC89F208DED881F3BEFA245 /* ContactSuggestionEngine.swift */; }; 391963EE7FEDF8139B2F90F5 /* ContactSuggestionEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F30DE3186049895D31C581D /* ContactSuggestionEngineTests.swift */; }; 3C97130622FF6D29BACDEA77 /* Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21445D604CF5DB4A2F69B7DF /* Activity.swift */; }; + 3CDE87A5D86BE3E2306E8C77 /* AppNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FE0A50E27F3BABB99ABE /* AppNotification.swift */; }; 3D5BA26EDD7B1258E870B1D4 /* BonjourBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB12E01D4EB1C6E3A68182E /* BonjourBrowser.swift */; }; 3D6714171EE7057CBE848A07 /* BlackbookServerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49097698C68BE415965A964 /* BlackbookServerApp.swift */; }; 3DDF69D8B580022FFDE12FCA /* ServerModelContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB554426E75CDDDB20498E2 /* ServerModelContainer.swift */; }; @@ -60,6 +62,7 @@ 5C59F290C171167D7664BE76 /* ContactSyncApply.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE3F1034482B48A2B7CCF08 /* ContactSyncApply.swift */; }; 5D10E3A07EE83D1C213B9CBD /* IconAndColorPickers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C76958AF5D1BEA2F22D389A /* IconAndColorPickers.swift */; }; 5D65835C77649F257190DEB3 /* SyncTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49D100318268DC2B37475CA /* SyncTypes.swift */; }; + 621D41D8C9FA0212D6730A6E /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73CB8F7063A295D786A0459 /* NotificationService.swift */; }; 62218CD59C45E6A0B43B84AA /* RejectedCalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88153A388C33AA1F9FCA3424 /* RejectedCalendarEvent.swift */; }; 627F00BB1C55B4CD93AD362C /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B68EA01CC90E403184F8383 /* DashboardView.swift */; }; 63AED38B279341EDDCFAA16A /* GroupDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34F85542EF75DA29ECB0FBC2 /* GroupDetailView.swift */; }; @@ -152,6 +155,7 @@ F92545D544AD003815547A34 /* DeviceIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535A0087C38A75EF251D28DF /* DeviceIdentity.swift */; }; F9C8E6C6BBBDE1468720591E /* ContactListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBCBC491C2BFAA0AABA68B3 /* ContactListViewModelTests.swift */; }; FA0527F27C39C281C7287EBE /* IMessageSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 530F5990544546A519FE2390 /* IMessageSyncService.swift */; }; + FA992EA56F0A795E5F1E088F /* NotificationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0940DAFA5EFF5AEB3F54BE9 /* NotificationsTests.swift */; }; FC428EE351F1C7F125A3E819 /* AIInsightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C58C1200FB5DE674799040E /* AIInsightsView.swift */; }; FC4D35EB1F0F93963E0499E2 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B242CFE0D00227BB630A5801 /* DateHelpersTests.swift */; }; FD356F0646511FD58F59CCEF /* RelationshipScoreEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B07D8F95B7197892AB8D6 /* RelationshipScoreEngineTests.swift */; }; @@ -251,6 +255,7 @@ 8C76958AF5D1BEA2F22D389A /* IconAndColorPickers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconAndColorPickers.swift; sourceTree = ""; }; 9070FA35720DE1331E514BFF /* MoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreView.swift; sourceTree = ""; }; 9111F21061CF4BB14E6FE866 /* NetworkGraphViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkGraphViewModelTests.swift; sourceTree = ""; }; + 9466FE0A50E27F3BABB99ABE /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.swift; sourceTree = ""; }; 950AC9FC1E76731546573DEA /* ReminderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderService.swift; sourceTree = ""; }; 970CC06BF6BA7D37D1C593DC /* InteractionLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionLogView.swift; sourceTree = ""; }; 972BC195190AAFA50ABE3DC7 /* ContactGroupPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactGroupPickerView.swift; sourceTree = ""; }; @@ -298,9 +303,11 @@ ED64DF1DD19F6ADC3991B97A /* DuplicateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuplicateScanView.swift; sourceTree = ""; }; EF98EBF38277BBA19D0BA8C7 /* TagManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagManagerView.swift; sourceTree = ""; }; F00610DA298D9C87714AF36E /* RejectedCalendarEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectedCalendarEventsView.swift; sourceTree = ""; }; + F0940DAFA5EFF5AEB3F54BE9 /* NotificationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTests.swift; sourceTree = ""; }; F3DBA07B5E3D5EAA2BFA3446 /* BlackbookApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlackbookApp.swift; sourceTree = ""; }; F49097698C68BE415965A964 /* BlackbookServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlackbookServerApp.swift; sourceTree = ""; }; F49D100318268DC2B37475CA /* SyncTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTypes.swift; sourceTree = ""; }; + F73CB8F7063A295D786A0459 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; F767094E24F344D3643565AA /* LocationIconSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationIconSuggestionView.swift; sourceTree = ""; }; F8A766A33F57F94C9BDF7ACF /* ConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantsTests.swift; sourceTree = ""; }; F8CBD51D0C167CD7EB595D1F /* NetworkGraphEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkGraphEngineTests.swift; sourceTree = ""; }; @@ -442,6 +449,7 @@ 2382E25FE6BDF339DA44560D /* GoogleCalendarService.swift */, D876B79CE3D85BAABBEBD128 /* LocalServerSyncService.swift */, 682862BCC7F0F257DF35CEB8 /* NetworkGraphEngine.swift */, + F73CB8F7063A295D786A0459 /* NotificationService.swift */, 1D046FD999BC660D58C26E32 /* PhotoStorageService.swift */, 56EBAC598F8585225B1F09E4 /* RelationshipScoreEngine.swift */, 950AC9FC1E76731546573DEA /* ReminderService.swift */, @@ -476,6 +484,7 @@ isa = PBXGroup; children = ( 21445D604CF5DB4A2F69B7DF /* Activity.swift */, + 9466FE0A50E27F3BABB99ABE /* AppNotification.swift */, CDB72A4E15A1B06A6877A310 /* BackupMetadata.swift */, 8222BEE7020B6711480F644E /* Contact.swift */, 364729C23FA9F3F62DDA06EF /* ContactRelationship.swift */, @@ -535,6 +544,7 @@ F8CBD51D0C167CD7EB595D1F /* NetworkGraphEngineTests.swift */, 9111F21061CF4BB14E6FE866 /* NetworkGraphViewModelTests.swift */, AD6C2B5FA5933483BFCEC3B4 /* NoteModelTests.swift */, + F0940DAFA5EFF5AEB3F54BE9 /* NotificationsTests.swift */, AB7B07D8F95B7197892AB8D6 /* RelationshipScoreEngineTests.swift */, 4353ABA45B9630F4CF7DBF0C /* ReminderModelTests.swift */, B10282AE3F76E876ADDB3A4A /* ReminderServiceTests.swift */, @@ -794,6 +804,7 @@ C31D09F55E3735C61BE790F0 /* ActivityFormView.swift in Sources */, B164427D17DE85E450AEA4C1 /* ActivityListView.swift in Sources */, E93AF93D1470F43091DF3613 /* AddNoteView.swift in Sources */, + 3CDE87A5D86BE3E2306E8C77 /* AppNotification.swift in Sources */, 82E24EDF90B4FDD321E60DC3 /* AuthGateView.swift in Sources */, CFDEF705ED6BC7629FB51319 /* AuthenticationService.swift in Sources */, A9C0AC72BC7536D4CA4B2702 /* BackupDetailView.swift in Sources */, @@ -859,6 +870,7 @@ 264F82AF586E5CA45E98051A /* NetworkGraphView.swift in Sources */, F363626541A0478C9B36D5FC /* NetworkGraphViewModel.swift in Sources */, 56A7A484B95A560F451576D7 /* Note.swift in Sources */, + 621D41D8C9FA0212D6730A6E /* NotificationService.swift in Sources */, 85EC2CFCD1D9D03FC6B07DCC /* PhotoStorageService.swift in Sources */, CB7C648273262A5D3B49C2D4 /* RejectedCalendarEvent.swift in Sources */, C3B6C1B2D9C55DCFB285AB82 /* RejectedCalendarEventsView.swift in Sources */, @@ -910,6 +922,7 @@ B3D765199ED7FF84739621F0 /* NetworkGraphEngineTests.swift in Sources */, FD484B077257640144869BCF /* NetworkGraphViewModelTests.swift in Sources */, 17FC135DAFA6AACBD52DFD7D /* NoteModelTests.swift in Sources */, + FA992EA56F0A795E5F1E088F /* NotificationsTests.swift in Sources */, FD356F0646511FD58F59CCEF /* RelationshipScoreEngineTests.swift in Sources */, E331D0D1AF87D4E1FA420AAC /* ReminderModelTests.swift in Sources */, C853CFD3BB51EC978673715D /* ReminderServiceTests.swift in Sources */, @@ -926,6 +939,7 @@ buildActionMask = 2147483647; files = ( 54ED84A76C60399BC05F5C99 /* Activity.swift in Sources */, + 233530392EB2ADCFAA331164 /* AppNotification.swift in Sources */, E9C12D0FA76386A83E75A5A7 /* BackupMetadata.swift in Sources */, 9CFACF1D237FBFDB8BE35DA2 /* BackupServer.swift in Sources */, 3D6714171EE7057CBE848A07 /* BlackbookServerApp.swift in Sources */, diff --git a/Blackbook/App/BlackbookApp.swift b/Blackbook/App/BlackbookApp.swift index d38476f..1d20cbf 100644 --- a/Blackbook/App/BlackbookApp.swift +++ b/Blackbook/App/BlackbookApp.swift @@ -50,7 +50,8 @@ struct BlackbookApp: App { ContactRelationship.self, Reminder.self, Activity.self, - RejectedCalendarEvent.self + RejectedCalendarEvent.self, + AppNotification.self ]) // Wipe the store when the schema version changes so SwiftData never diff --git a/Blackbook/Models/AppNotification.swift b/Blackbook/Models/AppNotification.swift new file mode 100644 index 0000000..bdd0f39 --- /dev/null +++ b/Blackbook/Models/AppNotification.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftData + +/// A persisted, synced notification / suggested action shown in the Overview "Notifications" chiclet. +/// References its target contact by id (no SwiftData relationship — the id is enough to navigate and +/// keeps the model a simple leaf that syncs without dependency ordering). +@Model +final class AppNotification { + var id: UUID + var kindRaw: String + var title: String + var message: String + /// The contact this notification points to (for tap-to-navigate). Nil for non-contact notifications. + var contactId: UUID? + var createdAt: Date + var isRead: Bool + var isDismissed: Bool + + var updatedAt: Date = Date() + var syncStatus: String = SyncStatus.pending.rawValue + var lastSyncedAt: Date? + + // MARK: - Source-device provenance + + var createdByDeviceId: String? + var createdByPlatform: String? + var createdByDeviceName: String? + var lastEditedByDeviceId: String? + var lastEditedByPlatform: String? + var lastEditedByDeviceName: String? + + /// The category of this notification, driving its icon and any inline action. + var kind: AppNotificationKind { + get { AppNotificationKind(rawValue: kindRaw) ?? .fadingRelationship } + set { kindRaw = newValue.rawValue } + } + + init( + kind: AppNotificationKind, + title: String, + message: String, + contactId: UUID? = nil, + createdAt: Date = Date() + ) { + self.id = UUID() + self.kindRaw = kind.rawValue + self.title = title + self.message = message + self.contactId = contactId + self.createdAt = createdAt + self.isRead = false + self.isDismissed = false + self.updatedAt = Date() + self.createdByDeviceId = DeviceIdentity.installId + self.createdByPlatform = DeviceIdentity.platform + self.createdByDeviceName = DeviceIdentity.deviceName + self.lastEditedByDeviceId = DeviceIdentity.installId + self.lastEditedByPlatform = DeviceIdentity.platform + self.lastEditedByDeviceName = DeviceIdentity.deviceName + } + + func markLocallyEdited() { + updatedAt = Date() + if syncStatus != SyncStatus.deleted.rawValue { + syncStatus = SyncStatus.pending.rawValue + } + lastEditedByDeviceId = DeviceIdentity.installId + lastEditedByPlatform = DeviceIdentity.platform + lastEditedByDeviceName = DeviceIdentity.deviceName + } +} + +/// The source/category of an `AppNotification`. +enum AppNotificationKind: String, Codable, CaseIterable { + /// A relationship whose score has dropped — suggest reaching out. + case fadingRelationship + /// A previously-imported contact no longer found in the address book — suggest archiving. + case archiveSuggestion + + var icon: String { + switch self { + case .fadingRelationship: return "arrow.down.right.circle.fill" + case .archiveSuggestion: return "archivebox.fill" + } + } +} diff --git a/Blackbook/Services/ContactSyncService.swift b/Blackbook/Services/ContactSyncService.swift index 6e8116a..a1e979e 100644 --- a/Blackbook/Services/ContactSyncService.swift +++ b/Blackbook/Services/ContactSyncService.swift @@ -107,7 +107,7 @@ final class ContactSyncService { // the SwiftData inverse fault that crashed "Import All" (mirrors the PR #43 pull fix). let bgContext = ModelContext(modelContext.container) bgContext.autosaveEnabled = false - let outcome = try mergeOrInsert(cnContacts, into: bgContext) + let outcome = try mergeOrInsert(cnContacts, into: bgContext, detectArchives: true) try bgContext.save() lastSyncDate = Date() NotificationCenter.default.post(name: .blackbookSyncDidComplete, object: nil) @@ -223,7 +223,7 @@ final class ContactSyncService { /// Resolves each CNContact to an existing Blackbook Contact (reattaching by name + /// email/phone if the cnContactIdentifier no longer matches), or inserts a new one. /// Returns counts of each outcome. - private func mergeOrInsert(_ cnContacts: [CNContact], into modelContext: ModelContext) throws -> MergeOutcome { + private func mergeOrInsert(_ cnContacts: [CNContact], into modelContext: ModelContext, detectArchives: Bool = false) throws -> MergeOutcome { var outcome = MergeOutcome() let allActive = try modelContext.fetch( @@ -288,6 +288,20 @@ final class ContactSyncService { "cnIdentifier": cnContact.identifier ]) } + + // After a full import, flag contacts that were imported before but are no longer in the + // address book (their cnContactIdentifier is gone from the live set) as archive suggestions. + if detectArchives { + for c in allActive where !c.isHidden { + guard let cn = c.cnContactIdentifier, !liveIdentifiers.contains(cn) else { continue } + if NotificationService.suggestArchive(contactId: c.id, displayName: Self.displayName(c), context: modelContext) { + Log.action("contact.import.archiveSuggested", metadata: [ + "contactId": c.id.uuidString, + "displayName": Self.displayName(c) + ]) + } + } + } return outcome } diff --git a/Blackbook/Services/LocalServerSyncService.swift b/Blackbook/Services/LocalServerSyncService.swift index bcfa810..2980b7b 100644 --- a/Blackbook/Services/LocalServerSyncService.swift +++ b/Blackbook/Services/LocalServerSyncService.swift @@ -153,6 +153,7 @@ final class LocalServerSyncService { flip(Reminder.self, #Predicate { $0.syncStatus == synced }) { $0.syncStatus = pending } flip(ContactRelationship.self, #Predicate { $0.syncStatus == synced }) { $0.syncStatus = pending } flip(RejectedCalendarEvent.self, #Predicate { $0.syncStatus == synced }) { $0.syncStatus = pending } + flip(AppNotification.self, #Predicate { $0.syncStatus == synced }) { $0.syncStatus = pending } try? context.save() logger.info("Bootstrap marked \(flippedCount) record(s) pending") Log.action("sync.bootstrap.markPending", metadata: ["count": "\(flippedCount)"]) @@ -318,6 +319,8 @@ final class LocalServerSyncService { total += (try? context.fetchCount(reminderDescriptor)) ?? 0 let relationshipDescriptor = FetchDescriptor(predicate: #Predicate { $0.syncStatus != synced }) total += (try? context.fetchCount(relationshipDescriptor)) ?? 0 + let appNotificationDescriptor = FetchDescriptor(predicate: #Predicate { $0.syncStatus != synced }) + total += (try? context.fetchCount(appNotificationDescriptor)) ?? 0 return total } @@ -334,10 +337,12 @@ final class LocalServerSyncService { let pendingNotes = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.syncStatus != syncedStatus })) let pendingReminders = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.syncStatus != syncedStatus })) let pendingRelationships = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.syncStatus != syncedStatus })) + let pendingAppNotifications = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.syncStatus != syncedStatus })) let totalPending = pendingTags.count + pendingGroups.count + pendingLocations.count + pendingActivities.count + pendingContacts.count + pendingInteractions.count + - pendingNotes.count + pendingReminders.count + pendingRelationships.count + pendingNotes.count + pendingReminders.count + pendingRelationships.count + + pendingAppNotifications.count logger.info("Pushing \(totalPending) record(s)") guard totalPending > 0 else { return } @@ -358,6 +363,7 @@ final class LocalServerSyncService { if !pendingNotes.isEmpty { body["notes"] = pendingNotes.map { ModelSyncApply.noteToDict($0) } } if !pendingReminders.isEmpty { body["reminders"] = pendingReminders.map { ModelSyncApply.reminderToDict($0) } } if !pendingRelationships.isEmpty { body["contactRelationships"] = pendingRelationships.map { ModelSyncApply.contactRelationshipToDict($0) } } + if !pendingAppNotifications.isEmpty { body["appNotifications"] = pendingAppNotifications.map { ModelSyncApply.appNotificationToDict($0) } } guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return } @@ -387,6 +393,7 @@ final class LocalServerSyncService { for note in pendingNotes { note.syncStatus = SyncStatus.synced.rawValue; note.lastSyncedAt = now } for reminder in pendingReminders { reminder.syncStatus = SyncStatus.synced.rawValue; reminder.lastSyncedAt = now } for rel in pendingRelationships { rel.syncStatus = SyncStatus.synced.rawValue; rel.lastSyncedAt = now } + for n in pendingAppNotifications { n.syncStatus = SyncStatus.synced.rawValue; n.lastSyncedAt = now } try context.save() } @@ -464,6 +471,10 @@ final class LocalServerSyncService { logger.info("Pulled \(rels.count) relationship(s)") for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: bgContext) } } + if let appNotifications = top["appNotifications"] as? [[String: Any]] { + logger.info("Pulled \(appNotifications.count) notification(s)") + for dict in appNotifications { try ModelSyncApply.applyRemoteAppNotification(dict, to: bgContext) } + } try bgContext.save() @@ -472,6 +483,9 @@ final class LocalServerSyncService { // when the Dashboard isn't the visible tab (on macOS its @State recalc wouldn't run). // Runs on the background context → the main context's @Queries see one settled commit. RelationshipScoreEngine().recalculateAll(context: bgContext) + + // Generate "reconnect" suggestions for newly-fading contacts (deduped). Same settled commit. + NotificationService.generateFadingNotifications(context: bgContext) } private func fetchPendingContacts(context: ModelContext) throws -> [Contact] { diff --git a/Blackbook/Services/NotificationService.swift b/Blackbook/Services/NotificationService.swift new file mode 100644 index 0000000..a35a971 --- /dev/null +++ b/Blackbook/Services/NotificationService.swift @@ -0,0 +1,64 @@ +import Foundation +import SwiftData + +/// Generates `AppNotification` records (suggested actions) from app state. Notifications are deduped +/// one-per-contact-per-kind, so re-running is idempotent and a dismissed notification is not recreated. +/// Generation runs on a background context during sync / import; callers save the context. +enum NotificationService { + /// Cap on new fading notifications created per run, so the chiclet stays a focused suggested-action + /// list rather than mirroring every below-threshold contact. + private static let maxNewFadingPerRun = 10 + + /// Creates fading-relationship notifications for contacts whose score has slipped below the fading + /// threshold (but is still > 0 — i.e. previously engaged, now cooling). Mirrors the Dashboard + /// "Fading Relationships" definition. Returns the number created. + @discardableResult + static func generateFadingNotifications(context: ModelContext) -> Int { + let alreadyNotified = existingContactIDs(ofKind: .fadingRelationship, in: context) + + let threshold = AppConstants.Scoring.fadingThreshold + let predicate = #Predicate { + !$0.isHidden && !$0.isMergedAway && $0.relationshipScore > 0 && $0.relationshipScore < threshold + } + guard let fading = try? context.fetch(FetchDescriptor(predicate: predicate)) else { return 0 } + + let candidates = fading + .filter { !alreadyNotified.contains($0.id) } + .sorted { $0.relationshipScore < $1.relationshipScore } // most faded first + .prefix(maxNewFadingPerRun) + + var created = 0 + for contact in candidates { + context.insert(AppNotification( + kind: .fadingRelationship, + title: "Reconnect with \(contact.displayName)", + message: "This relationship is fading — reach out to stay in touch.", + contactId: contact.id + )) + created += 1 + } + if created > 0 { try? context.save() } + return created + } + + /// Creates an archive-suggestion notification for a contact no longer found in the address book, + /// unless one already exists for that contact. Does not save (the import flow saves the context). + @discardableResult + static func suggestArchive(contactId: UUID, displayName: String, context: ModelContext) -> Bool { + guard !existingContactIDs(ofKind: .archiveSuggestion, in: context).contains(contactId) else { return false } + context.insert(AppNotification( + kind: .archiveSuggestion, + title: "Archive \(displayName)?", + message: "No longer in your address book — archive to hide them from your lists.", + contactId: contactId + )) + return true + } + + /// Contact IDs that already have a notification of the given kind in any state (active or dismissed), + /// so a dismissed suggestion is never recreated. + private static func existingContactIDs(ofKind kind: AppNotificationKind, in context: ModelContext) -> Set { + guard let all = try? context.fetch(FetchDescriptor()) else { return [] } + return Set(all.filter { $0.kind == kind }.compactMap { $0.contactId }) + } +} diff --git a/Blackbook/Utilities/ModelSyncApply.swift b/Blackbook/Utilities/ModelSyncApply.swift index fe8a93b..18d45b4 100644 --- a/Blackbook/Utilities/ModelSyncApply.swift +++ b/Blackbook/Utilities/ModelSyncApply.swift @@ -548,6 +548,79 @@ enum ModelSyncApply { // MARK: - ContactRelationship + // MARK: - AppNotification + + static func appNotificationToDict(_ n: AppNotification) -> [String: Any] { + var dict: [String: Any] = [ + "id": n.id.uuidString, + "kindRaw": n.kindRaw, + "title": n.title, + "message": n.message, + "isRead": n.isRead, + "isDismissed": n.isDismissed, + "createdAt": iso8601.string(from: n.createdAt), + "updatedAt": iso8601.string(from: n.updatedAt) + ] + if let cid = n.contactId { dict["contactId"] = cid.uuidString } + appendProvenance(&dict, + createdByDeviceId: n.createdByDeviceId, + createdByPlatform: n.createdByPlatform, + createdByDeviceName: n.createdByDeviceName, + lastEditedByDeviceId: n.lastEditedByDeviceId, + lastEditedByPlatform: n.lastEditedByPlatform, + lastEditedByDeviceName: n.lastEditedByDeviceName) + return dict + } + + static func applyRemoteAppNotification(_ dict: [String: Any], to context: ModelContext) throws { + guard let idString = dict["id"] as? String, + let remoteId = UUID(uuidString: idString) else { return } + + let predicate = #Predicate { $0.id == remoteId } + let existing = try context.fetch(FetchDescriptor(predicate: predicate)).first + + guard let remoteUpdatedStr = dict["updatedAt"] as? String, + let remoteUpdated = iso8601.date(from: remoteUpdatedStr) else { return } + + if let local = existing { + if local.updatedAt > remoteUpdated && local.syncStatus != SyncStatus.synced.rawValue { return } + applyDictToAppNotification(dict, notification: local) + local.updatedAt = remoteUpdated + local.syncStatus = SyncStatus.synced.rawValue + local.lastSyncedAt = Date() + } else { + let n = AppNotification( + kind: AppNotificationKind(rawValue: (dict["kindRaw"] as? String) ?? "") ?? .fadingRelationship, + title: (dict["title"] as? String) ?? "", + message: (dict["message"] as? String) ?? "" + ) + n.id = remoteId + applyDictToAppNotification(dict, notification: n) + n.updatedAt = remoteUpdated + n.syncStatus = SyncStatus.synced.rawValue + n.lastSyncedAt = Date() + context.insert(n) + } + } + + private static func applyDictToAppNotification(_ dict: [String: Any], notification n: AppNotification) { + if let v = dict["kindRaw"] as? String { n.kindRaw = v } + if let v = dict["title"] as? String { n.title = v } + if let v = dict["message"] as? String { n.message = v } + if let v = dict["isRead"] as? Bool { n.isRead = v } + if let v = dict["isDismissed"] as? Bool { n.isDismissed = v } + if let v = dict["createdAt"] as? String, let d = iso8601.date(from: v) { n.createdAt = d } + n.contactId = (dict["contactId"] as? String).flatMap { UUID(uuidString: $0) } + + let p = readProvenance(dict) + n.createdByDeviceId = p.createdByDeviceId + n.createdByPlatform = p.createdByPlatform + n.createdByDeviceName = p.createdByDeviceName + n.lastEditedByDeviceId = p.lastEditedByDeviceId + n.lastEditedByPlatform = p.lastEditedByPlatform + n.lastEditedByDeviceName = p.lastEditedByDeviceName + } + static func contactRelationshipToDict(_ rel: ContactRelationship) -> [String: Any] { var dict: [String: Any] = [ "id": rel.id.uuidString, diff --git a/Blackbook/Views/Dashboard/DashboardView.swift b/Blackbook/Views/Dashboard/DashboardView.swift index 591816a..653e955 100644 --- a/Blackbook/Views/Dashboard/DashboardView.swift +++ b/Blackbook/Views/Dashboard/DashboardView.swift @@ -15,6 +15,9 @@ struct DashboardView: View { @State private var viewModel = DashboardViewModel() @State private var showingPrioritizePicker = false @State private var weeklyStats = WeeklyStats(totalInteractions: 0, uniqueContacts: 0, byType: [:]) + @State private var notifications: [AppNotification] = [] + @State private var showNotifications = false + @State private var navTarget: DashNavTarget? private var contacts: [Contact] { allContacts.filter { !$0.isHidden && !$0.isMergedAway } } private var contactsByID: [UUID: Contact] { @@ -35,9 +38,39 @@ struct DashboardView: View { } } .navigationTitle("Overview") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showNotifications = true } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + if !notifications.isEmpty { + Text("\(notifications.count)") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(AppConstants.UI.fadingRed, in: Capsule()) + .offset(x: 11, y: -9) + } + } + } + .accessibilityLabel("Notifications, \(notifications.count) active") + } + } .navigationDestination(for: UUID.self) { id in if let c = contactsByID[id] { ContactDetailView(contact: c) } } + .navigationDestination(item: $navTarget) { target in + if let c = contactsByID[target.id] { ContactDetailView(contact: c) } + } + .sheet(isPresented: $showNotifications) { + NotificationsView( + notifications: notifications, + onOpen: openNotification, + onDismiss: dismissNotification, + onArchive: archiveFromNotification + ) + } .task { // Defer the first fetch briefly so any in-flight pull-apply finishes saving // before we touch SwiftData on the main thread. @@ -72,9 +105,44 @@ struct DashboardView: View { if let reminders = try? modelContext.fetch(reminderDescriptor) { self.reminders = reminders } + let notifDescriptor = FetchDescriptor( + predicate: #Predicate { !$0.isDismissed }, + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + if let notifs = try? modelContext.fetch(notifDescriptor) { + notifications = notifs + } weeklyStats = viewModel.computeWeeklyStats(context: modelContext) } + // MARK: - Notification actions + + private func openNotification(_ n: AppNotification) { + if !n.isRead { n.isRead = true; n.markLocallyEdited() } + try? modelContext.save() + showNotifications = false + if let cid = n.contactId { navTarget = DashNavTarget(id: cid) } + Task { await refreshFromStore() } + } + + private func dismissNotification(_ n: AppNotification) { + n.isDismissed = true + n.markLocallyEdited() + try? modelContext.save() + Task { await refreshFromStore() } + } + + private func archiveFromNotification(_ n: AppNotification) { + if let cid = n.contactId, let c = contactsByID[cid] { + c.isHidden = true + c.markLocallyEdited() + } + n.isDismissed = true + n.markLocallyEdited() + try? modelContext.save() + Task { await refreshFromStore() } + } + private var weeklyStatsCard: some View { DashboardCard(title: "This Week", icon: "chart.bar.fill") { HStack(spacing: 24) { StatBubble(value: "\(weeklyStats.totalInteractions)", label: "Interactions"); StatBubble(value: "\(weeklyStats.uniqueContacts)", label: "People") } @@ -344,3 +412,98 @@ struct PrioritizeContactPicker: View { #endif } } + +// MARK: - Notifications + +/// Hashable navigation target for opening a contact from a notification (distinct from the +/// value-based `UUID` destination so the two `navigationDestination`s don't collide). +struct DashNavTarget: Hashable, Identifiable { + let id: UUID +} + +/// The Notifications / suggested-actions list presented from the Overview bell chiclet. Sorted +/// newest-first. Tap or swipe-leading opens the related contact; swipe-trailing dismisses; archive +/// suggestions get an inline "Archive" action. +struct NotificationsView: View { + @Environment(\.dismiss) private var dismiss + let notifications: [AppNotification] + let onOpen: (AppNotification) -> Void + let onDismiss: (AppNotification) -> Void + let onArchive: (AppNotification) -> Void + + var body: some View { + NavigationStack { + SwiftUI.Group { + if notifications.isEmpty { + ContentUnavailableView { + Label("All caught up", systemImage: "bell.slash") + } description: { + Text("Suggested actions will appear here as your relationships change.") + } + } else { + List { + ForEach(notifications) { notification in + Button { onOpen(notification) } label: { + NotificationRow(notification: notification) + } + .buttonStyle(.plain) + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { onOpen(notification) } label: { Label("Open", systemImage: "arrow.right.circle") } + .tint(AppConstants.UI.accentGold) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { onDismiss(notification) } label: { + Label("Dismiss", systemImage: "xmark") + } + if notification.kind == .archiveSuggestion { + Button { onArchive(notification) } label: { Label("Archive", systemImage: "archivebox") } + .tint(.gray) + } + } + } + } + } + } + .navigationTitle("Notifications") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + #if os(macOS) + .frame(minWidth: 380, idealWidth: 440, minHeight: 360, idealHeight: 500) + #endif + } +} + +struct NotificationRow: View { + let notification: AppNotification + + var body: some View { + HStack(spacing: 12) { + Image(systemName: notification.kind.icon) + .font(.body) + .foregroundStyle(notification.kind == .fadingRelationship ? AppConstants.UI.fadingRed : AppConstants.UI.accentGold) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(notification.title) + .font(.body.weight(notification.isRead ? .regular : .semibold)) + .lineLimit(1) + Text(notification.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 8) + Text(notification.createdAt.relativeDescription) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } +} diff --git a/BlackbookServer/App/BackupServer.swift b/BlackbookServer/App/BackupServer.swift index f26b06f..14681df 100644 --- a/BlackbookServer/App/BackupServer.swift +++ b/BlackbookServer/App/BackupServer.swift @@ -674,6 +674,10 @@ final class BackupServer: @unchecked Sendable { let relPred = #Predicate { $0.updatedAt > since } var relDesc = FetchDescriptor(predicate: relPred); relDesc.fetchLimit = 2000 json["contactRelationships"] = try context.fetch(relDesc).map { ModelSyncApply.contactRelationshipToDict($0) } + + let notifPred = #Predicate { $0.updatedAt > since } + var notifDesc = FetchDescriptor(predicate: notifPred); notifDesc.fetchLimit = 2000 + json["appNotifications"] = try context.fetch(notifDesc).map { ModelSyncApply.appNotificationToDict($0) } } catch { logger.error("Sync pull fetch failed: \(error.localizedDescription)") } @@ -738,6 +742,9 @@ final class BackupServer: @unchecked Sendable { if let rels = top["contactRelationships"] as? [[String: Any]] { for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: context) } } + if let appNotifications = top["appNotifications"] as? [[String: Any]] { + for dict in appNotifications { try ModelSyncApply.applyRemoteAppNotification(dict, to: context) } + } // Deletes (structured per model type) if let deletes = top["deletes"] as? [String: Any] { @@ -825,6 +832,12 @@ final class BackupServer: @unchecked Sendable { try context.delete(model: RejectedCalendarEvent.self, where: #Predicate { $0.id == id }) } } + if let ids = deletes["appNotifications"] as? [String] { + for idStr in ids { + guard let id = UUID(uuidString: idStr) else { continue } + try context.delete(model: AppNotification.self, where: #Predicate { $0.id == id }) + } + } } // MARK: - Logs Storage diff --git a/BlackbookServer/App/ServerModelContainer.swift b/BlackbookServer/App/ServerModelContainer.swift index 928cf51..e659ab5 100644 --- a/BlackbookServer/App/ServerModelContainer.swift +++ b/BlackbookServer/App/ServerModelContainer.swift @@ -56,7 +56,8 @@ enum ServerModelContainer { ContactRelationship.self, Reminder.self, Activity.self, - RejectedCalendarEvent.self + RejectedCalendarEvent.self, + AppNotification.self ]) } diff --git a/BlackbookTests/NotificationsTests.swift b/BlackbookTests/NotificationsTests.swift new file mode 100644 index 0000000..baeb1ac --- /dev/null +++ b/BlackbookTests/NotificationsTests.swift @@ -0,0 +1,120 @@ +import XCTest +import SwiftData +@testable import Blackbook + +/// Tests for the notifications subsystem: `NotificationService` generation/dedup and the +/// `AppNotification` sync round-trip + conflict resolution (mirrors `SyncApplyTests`). +@MainActor +final class NotificationsTests: XCTestCase { + + private var container: ModelContainer! + private var context: ModelContext! + + override func setUpWithError() throws { + container = try TestHelpers.makeContainer() + context = ModelContext(container) + } + + override func tearDown() { + context = nil + container = nil + } + + // MARK: - Fading generation + + func testGeneratesFadingForCoolingContactsOnly() throws { + TestHelpers.makeContact(firstName: "Cooling", lastName: "A", score: 15, in: context) // 0 < 15 < 30 → yes + TestHelpers.makeContact(firstName: "Dormant", lastName: "B", score: 0, in: context) // score 0 → no + TestHelpers.makeContact(firstName: "Healthy", lastName: "C", score: 80, in: context) // >= 30 → no + TestHelpers.makeContact(firstName: "Hidden", lastName: "D", score: 12, isHidden: true, in: context) // hidden → no + try context.save() + + let created = NotificationService.generateFadingNotifications(context: context) + XCTAssertEqual(created, 1) + + let notifs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(notifs.count, 1) + XCTAssertEqual(notifs.first?.kind, .fadingRelationship) + XCTAssertEqual(notifs.first?.title.contains("Cooling"), true) + } + + func testFadingGenerationIsIdempotent() throws { + TestHelpers.makeContact(firstName: "Cooling", lastName: "A", score: 15, in: context) + try context.save() + + XCTAssertEqual(NotificationService.generateFadingNotifications(context: context), 1) + XCTAssertEqual(NotificationService.generateFadingNotifications(context: context), 0, "must not recreate an existing notification") + XCTAssertEqual(try context.fetch(FetchDescriptor()).count, 1) + } + + func testDismissedFadingNotificationIsNotRecreated() throws { + let c = TestHelpers.makeContact(firstName: "Cooling", lastName: "A", score: 15, in: context) + try context.save() + XCTAssertEqual(NotificationService.generateFadingNotifications(context: context), 1) + + let notif = try XCTUnwrap(try context.fetch(FetchDescriptor()).first) + XCTAssertEqual(notif.contactId, c.id) + notif.isDismissed = true + try context.save() + + XCTAssertEqual(NotificationService.generateFadingNotifications(context: context), 0, "dismissed suggestion stays dismissed") + } + + // MARK: - Archive suggestion + + func testSuggestArchiveCreatesOnceThenDedups() throws { + let id = UUID() + XCTAssertTrue(NotificationService.suggestArchive(contactId: id, displayName: "Gone Person", context: context)) + try context.save() + XCTAssertFalse(NotificationService.suggestArchive(contactId: id, displayName: "Gone Person", context: context)) + + let notifs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(notifs.count, 1) + XCTAssertEqual(notifs.first?.kind, .archiveSuggestion) + } + + // MARK: - Sync round-trip + + func testAppNotificationRoundTrips() throws { + let contactId = UUID() + let original = AppNotification(kind: .archiveSuggestion, title: "Archive Bob?", message: "Gone", contactId: contactId) + context.insert(original) + try context.save() + + let dict = ModelSyncApply.appNotificationToDict(original) + + // Apply into a fresh store to prove the payload reconstructs the record from scratch. + let freshContainer = try TestHelpers.makeContainer() + let other = ModelContext(freshContainer) + try ModelSyncApply.applyRemoteAppNotification(dict, to: other) + try other.save() + + let fetched = try XCTUnwrap(try other.fetch(FetchDescriptor()).first { $0.id == original.id }) + XCTAssertEqual(fetched.kind, .archiveSuggestion) + XCTAssertEqual(fetched.title, "Archive Bob?") + XCTAssertEqual(fetched.contactId, contactId) + XCTAssertEqual(fetched.syncStatus, SyncStatus.synced.rawValue) + } + + func testApplyRemoteRespectsConflictResolution() throws { + let n = AppNotification(kind: .fadingRelationship, title: "Local", message: "local") + context.insert(n) + n.markLocallyEdited() // newer + pending → protected from an older remote + try context.save() + + // Older remote payload for the same id should be ignored while local is pending. + var olderDict = ModelSyncApply.appNotificationToDict(n) + olderDict["title"] = "Stale Remote" + let iso = ISO8601DateFormatter() + olderDict["updatedAt"] = iso.string(from: n.updatedAt.addingTimeInterval(-3600)) + try ModelSyncApply.applyRemoteAppNotification(olderDict, to: context) + XCTAssertEqual(n.title, "Local", "older remote must not clobber a newer pending local record") + + // A newer remote payload wins. + var newerDict = ModelSyncApply.appNotificationToDict(n) + newerDict["title"] = "Fresh Remote" + newerDict["updatedAt"] = iso.string(from: n.updatedAt.addingTimeInterval(3600)) + try ModelSyncApply.applyRemoteAppNotification(newerDict, to: context) + XCTAssertEqual(n.title, "Fresh Remote", "newer remote should win") + } +} diff --git a/BlackbookTests/TestHelpers.swift b/BlackbookTests/TestHelpers.swift index 0d79e2b..977cb98 100644 --- a/BlackbookTests/TestHelpers.swift +++ b/BlackbookTests/TestHelpers.swift @@ -20,7 +20,8 @@ enum TestHelpers { ContactRelationship.self, Reminder.self, Activity.self, - RejectedCalendarEvent.self + RejectedCalendarEvent.self, + AppNotification.self ]) let config = ModelConfiguration(isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [config]) diff --git a/docs/test-scenarios/TEST_SCENARIOS.md b/docs/test-scenarios/TEST_SCENARIOS.md index 0b3a78c..56e44f1 100644 --- a/docs/test-scenarios/TEST_SCENARIOS.md +++ b/docs/test-scenarios/TEST_SCENARIOS.md @@ -469,3 +469,32 @@ Server-side one-time SQL heal applied directly to the master store while the dae - The fix is per-edit-going-forward. The existing 643-record backlog clears only as users naturally touch each contact (or via a deliberate one-time migration, which we deliberately skipped because it would overwrite iOS edits for any field where the two devices disagree). - BlackbookServer is local-deploy only (not TestFlight). After this PR lands, rebuild + reinstall `/Applications/BlackbookServer.app` from main so the iMessage-lookup fix takes effect. - Verified: iOS Simulator + macOS clean builds, BlackbookServer clean build, 13 Swift Testing tests pass. + +--- + +## 2026-06-03 — Import-crash hotfix + 5 features (PRs #48 → #49 → #50) + +### Hotfix (#48) +- **Import All no longer crashes:** Settings → Import from Contacts → **Import All**. The import completes; the app does not crash and relaunching works (no crash loop). +- **Recent interactions raise the score:** for a priority contact stuck at 20/"Fading" with recent texts (e.g. Hugo Dooner), after the next sync the score rises above 20 to reflect the recency. Works on whichever tab is visible (recalc runs after each sync pull). + +### Suggested records (#49) +- Open a contact → **Introduced to** (or **Met via**) edit. With the search box empty, a **Suggested** section shows 3 contacts that share tags/groups/locations with the subject; the rest appear under **All Contacts**. Typing switches to a plain filtered search. + +### Click-to-sort columns (#49) +- In Contacts (macOS / iPad regular width), click a column header (Name, Groups, Locations, Tags, Met via, Introduced to, Score). The list sorts by that column; the active column shows ▲/▼. Click the same header again to reverse. String columns put blanks last; Score sorts highest-first on first click. + +### Hidden contacts everywhere (#49) +- Hide a contact (swipe → Hide, or Settings → Hidden Contacts). It must not appear in any picker: Introduced to, Met via, Merge, Met-via in the contact form, group/tag/location "add contact", network graph. (If it still shows on a *different* device, that's sync propagation — re-touch it or wait for the next sync.) + +### Notifications chiclet (#50) +- Overview shows a **bell** chiclet (top-right) with a red count badge when there are active notifications. +- Tap it → a sheet lists suggestions newest-first. Tap a row (or swipe right/leading) → navigates to the related contact. Swipe left/trailing → **Dismiss** (it disappears and stays gone, across relaunch and sync). +- Fading suggestions appear for contacts whose score dropped into the fading band (0 < score < 30). + +### Archive-on-import (#50) +- Remove a previously-imported contact from the system address book, then run **Import All**. An **Archive** suggestion appears in the bell list for that contact. Swipe → **Archive** hides them. Dismiss instead to keep them. + +### Notes / required steps +- iOS Simulator couldn't run in the dev env (CoreSimulator drift); suite run on the macOS destination (232 tests green) — CI runs the iOS path. +- **After #50 merges:** rebuild + reinstall `/Applications/BlackbookServer.app` (Release) so the server relays `appNotifications`. The new `AppNotification` schema is additive (no store wipe).