Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Sources/ColumbaApp/ViewModels/ContactsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion Sources/ColumbaApp/Views/Contacts/ContactsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
49 changes: 49 additions & 0 deletions Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand Down Expand Up @@ -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()
}
Expand All @@ -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.
Expand Down
Loading