From 8708e4e7daa17ebceb55ceb4c1b5f9902f145da5 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:13:31 -0400 Subject: [PATCH 1/2] feat(contacts): add favorite/save star to Node Details screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror Android's top-right star (AnnounceDetailScreen) on the iOS NodeDetailsView so a peer can be saved/favorited — and unsaved — directly from node details, instead of only from the contact/announce lists. - NodeDetailsView: optional onToggleFavorite callback (parent-owned, like onStartChat/onBrowseSite) plus a .primaryAction toolbar star. The star's lit state is local @State seeded in .task, deliberately decoupled from liveContact whose isFavorite is re-derived from the seed on every path poll (binding to displayedContact.isFavorite would snap the star back). - ContactsView: wire the node-details destination to the ViewModel. - ContactsViewModel: Contact-typed toggleFavorite overload that removes a saved contact or falls back to addToContacts, so the star stays correct even when the peer isn't currently in the in-memory announce arrays. On iOS, favorite == contact membership in conversations.is_favorite, so a single tap reproduces Android's add/remove semantics through the existing persistence path — no new storage is introduced. Instant toggle (no confirm dialog) to match every other favorite star in the app. Co-Authored-By: Claude Opus 4.8 --- .../ViewModels/ContactsViewModel.swift | 20 +++++++++++ .../Views/Contacts/ContactsView.swift | 11 +++++- .../Views/Contacts/NodeDetailsView.swift | 34 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift b/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift index 9b0457eb..2ee10fe9 100644 --- a/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift @@ -717,6 +717,26 @@ 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. + @MainActor + public func toggleFavorite(for contact: Contact) { + 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)). + Task { await addToContacts(contact) } + } + } + /// 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 4addf6be..46c76d6c 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 + 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 e4b56f10..70a983f3 100644 --- a/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift +++ b/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift @@ -37,6 +37,12 @@ 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. + /// The star (and its add-to-contacts/favorite behaviour) is only rendered + /// when this callback is non-nil, so each parent decides which ViewModel + /// persists the change — mirroring `onStartChat`/`onBrowseSite`. + var onToggleFavorite: ((Contact) -> Void)? + // MARK: - State @State private var expiresDate: Date? @@ -48,6 +54,12 @@ 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 @Environment(\.dismiss) private var dismiss /// Effective contact for view bindings: live snapshot if available, @@ -87,6 +99,25 @@ 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 { + // Optimistic flip — mirrors the in-place mutation in + // ContactsViewModel.toggleFavorite. The parent persists. + isFavorite.toggle() + onToggleFavorite?(displayedContact) + } label: { + Image(systemName: isFavorite ? "star.fill" : "star") + .foregroundStyle(isFavorite ? Theme.accentColor : Theme.textSecondary) + } + .accessibilityLabel(isFavorite ? "Remove from contacts" : "Save contact") + } + } + } .refreshable { await refreshFromNetwork() } @@ -98,6 +129,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. From 4c842fd5c5f9762fd125c513e0e726eabed542de Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:39:36 -0400 Subject: [PATCH 2/2] fix(contacts): reconcile node-details star to authoritative saved state (greptile #98 iter 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P2s: the optimistic star could desync from what was actually persisted — (1) no rollback if addToContacts throws (star stays filled, contact never saved), and (2) a rapid double-tap could end with star empty but contact saved. - ContactsViewModel.toggleFavorite(for: Contact) is now async @discardableResult, returning the authoritative post-op membership (myContacts.contains) after awaiting addToContacts. On failure addToContacts leaves myContacts untouched, so the return reflects reality. - NodeDetailsView: onToggleFavorite is now (Contact) async -> Bool. The star flips optimistically then reconciles to the returned state, and an isTogglingFavorite guard + .disabled() blocks in-flight double-taps. Co-Authored-By: Claude Opus 4.8 --- .../ViewModels/ContactsViewModel.swift | 14 +++++++--- .../Views/Contacts/ContactsView.swift | 2 +- .../Views/Contacts/NodeDetailsView.swift | 27 ++++++++++++++----- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift b/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift index 2ee10fe9..f7896338 100644 --- a/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift @@ -726,15 +726,23 @@ public final class ContactsViewModel { /// `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. - @MainActor - public func toggleFavorite(for contact: Contact) { + /// + /// 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)). - Task { await addToContacts(contact) } + // 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. diff --git a/Sources/ColumbaApp/Views/Contacts/ContactsView.swift b/Sources/ColumbaApp/Views/Contacts/ContactsView.swift index 46c76d6c..3dc16e12 100644 --- a/Sources/ColumbaApp/Views/Contacts/ContactsView.swift +++ b/Sources/ColumbaApp/Views/Contacts/ContactsView.swift @@ -143,7 +143,7 @@ public struct ContactsView: View { // stars while staying correct even when the peer // isn't currently in the in-memory announce arrays. onToggleFavorite: { contact in - vm.toggleFavorite(for: contact) + await vm.toggleFavorite(for: contact) } ) case .chat(let conversation): diff --git a/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift b/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift index 70a983f3..62d75676 100644 --- a/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift +++ b/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift @@ -37,11 +37,14 @@ 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. - /// The star (and its add-to-contacts/favorite behaviour) is only rendered + /// 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) -> Void)? + var onToggleFavorite: ((Contact) async -> Bool)? // MARK: - State @@ -60,6 +63,9 @@ struct NodeDetailsView: View { /// `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, @@ -106,14 +112,23 @@ struct NodeDetailsView: View { // `.topBarTrailing` is iOS-only. ToolbarItem(placement: .primaryAction) { Button { - // Optimistic flip — mirrors the in-place mutation in - // ContactsViewModel.toggleFavorite. The parent persists. + 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() - onToggleFavorite?(displayedContact) + 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") } }