diff --git a/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift b/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift index 9b0457e..f789633 100644 --- a/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift @@ -717,6 +717,34 @@ public final class ContactsViewModel { } } + /// Toggle favorite/contact membership when the full `Contact` is known + /// (e.g. from NodeDetailsView's toolbar star). + /// + /// Behaves like `toggleFavorite(for: contactId)` for the remove case, but + /// when the peer is not currently in `myContacts` it adds it directly via + /// `addToContacts(_:)` rather than relying on the peer also being present in + /// `networkAnnounces`. This keeps the detail-screen star correct for a + /// My-Contacts-only peer that was just removed and re-added, or any peer + /// reached by a route that hasn't populated the announce arrays. + /// + /// Returns the authoritative saved state after persistence completes (true = + /// the peer is now in `myContacts`). Callers with optimistic UI (e.g. the + /// NodeDetailsView star) reconcile to this so a failed `addToContacts` or a + /// rapid double-tap can't leave the UI showing "saved" when it isn't. + @MainActor @discardableResult + public func toggleFavorite(for contact: Contact) async -> Bool { + if myContacts.contains(where: { $0.id == contact.id }) { + // Already a saved contact — reuse the id-based remove path. + toggleFavorite(for: contact.id) + } else { + // Not saved yet — add (creates conversation + setFavorite(true)). + // addToContacts leaves myContacts untouched and sets errorMessage on + // failure, so the membership check below reflects the real outcome. + await addToContacts(contact) + } + return myContacts.contains(where: { $0.id == contact.id }) + } + /// Toggle pin status for a contact and persist to database. @MainActor public func togglePin(for contactId: String) { diff --git a/Sources/ColumbaApp/Views/Contacts/ContactsView.swift b/Sources/ColumbaApp/Views/Contacts/ContactsView.swift index 4addf6b..3dc16e1 100644 --- a/Sources/ColumbaApp/Views/Contacts/ContactsView.swift +++ b/Sources/ColumbaApp/Views/Contacts/ContactsView.swift @@ -135,7 +135,16 @@ public struct ContactsView: View { }, onBrowseSite: contact.badgeType == .node ? { contact in browseSite(for: contact) - } : nil + } : nil, + // Star = add-to-contacts / favorite, mirroring + // Android's add (from a network announce) and remove + // (already a contact) semantics. The Contact-typed + // overload reuses the same persistence as the other + // stars while staying correct even when the peer + // isn't currently in the in-memory announce arrays. + onToggleFavorite: { contact in + await vm.toggleFavorite(for: contact) + } ) case .chat(let conversation): MessagingView( diff --git a/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift b/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift index e4b56f1..62d7567 100644 --- a/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift +++ b/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift @@ -37,6 +37,15 @@ struct NodeDetailsView: View { /// Only rendered for `.node` badge contacts when this callback is non-nil. var onBrowseSite: ((Contact) -> Void)? + /// Called when the top-right star is tapped; receives the displayed contact + /// and returns the **authoritative** favorite/saved state after persistence + /// completes. The view reconciles its optimistic star to this return value, + /// so a failed save (no rollback) or a rapid double-tap can't leave the star + /// out of sync with what was actually persisted. The star is only rendered + /// when this callback is non-nil, so each parent decides which ViewModel + /// persists the change — mirroring `onStartChat`/`onBrowseSite`. + var onToggleFavorite: ((Contact) async -> Bool)? + // MARK: - State @State private var expiresDate: Date? @@ -48,6 +57,15 @@ struct NodeDetailsView: View { /// All bindings in the view read from this so the badge transitions from /// "Expired" to "Online" the moment an announce arrives. @State private var liveContact: Contact? + /// Source of truth for the toolbar star, kept separate from `liveContact` + /// because `mergedContact`/`applyOfflineState` always re-derive + /// `isFavorite` from the original seed; binding the star to + /// `displayedContact.isFavorite` would make it snap back on the next path + /// poll. Seeded in `.task` and toggled optimistically. + @State private var isFavorite: Bool = false + /// Guards against rapid double-taps: the star is disabled while a toggle is + /// in flight, so two queued persistence tasks can't race the optimistic flip. + @State private var isTogglingFavorite: Bool = false @Environment(\.dismiss) private var dismiss /// Effective contact for view bindings: live snapshot if available, @@ -87,6 +105,34 @@ struct NodeDetailsView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .toolbar { + if onToggleFavorite != nil { + // `.primaryAction` renders top-right on iOS (like Android's + // TopAppBar star) while staying valid on the macOS target; + // `.topBarTrailing` is iOS-only. + ToolbarItem(placement: .primaryAction) { + Button { + guard !isTogglingFavorite, let onToggleFavorite else { return } + // Flip optimistically for snappiness, then reconcile to + // the authoritative persisted state the callback returns + // (covers add-failure rollback + double-tap races). + isFavorite.toggle() + isTogglingFavorite = true + let c = displayedContact + Task { + let persisted = await onToggleFavorite(c) + isFavorite = persisted + isTogglingFavorite = false + } + } label: { + Image(systemName: isFavorite ? "star.fill" : "star") + .foregroundStyle(isFavorite ? Theme.accentColor : Theme.textSecondary) + } + .disabled(isTogglingFavorite) + .accessibilityLabel(isFavorite ? "Remove from contacts" : "Save contact") + } + } + } .refreshable { await refreshFromNetwork() } @@ -98,6 +144,9 @@ struct NodeDetailsView: View { // `liveContact` still holds the previous contact's data and would // bleed through until the polling loop's first apply. liveContact = contact + // Seed the toolbar star from the parent-supplied contact. Keyed on + // `contact.id`, so navigating to a different node re-seeds it. + isFavorite = contact.isFavorite // Also reset auxiliary state so the previous contact's metadata // (relay button, interface name, expiry date) doesn't render for // contact B during the first path-table lookup.