Skip to content

feat(contacts): add favorite/save star to Node Details screen#98

Merged
torlando-tech merged 2 commits into
mainfrom
feat/ios-node-detail-favorite-star
Jun 21, 2026
Merged

feat(contacts): add favorite/save star to Node Details screen#98
torlando-tech merged 2 commits into
mainfrom
feat/ios-node-detail-favorite-star

Conversation

@torlando-tech

Copy link
Copy Markdown
Owner

What

Adds a top-right star to the iOS Node Details screen so a peer can be saved/favorited (and unsaved) directly from node details — matching Android Columba's AnnounceDetailScreen, which already has this affordance. Previously iOS could only favorite from the contact/announce lists, never from the detail view.

Why

Parity gap reported in the field: the node details screen on iOS had no .toolbar and no favorite control at all, only preserving isFavorite through live-merges.

How

  • NodeDetailsView — new optional onToggleFavorite callback (parent-owned, mirroring onStartChat/onBrowseSite) and a .primaryAction toolbar star. Its lit state is a local @State seeded in .task, deliberately decoupled from liveContactmergedContact/applyOfflineState re-derive isFavorite from the seed on every ~1.5s path poll, so binding the star to displayedContact.isFavorite would make it visually snap back.
  • ContactsView — wires the node-details destination to ContactsViewModel.toggleFavorite(for:).
  • ContactsViewModel — a Contact-typed toggleFavorite overload that removes an already-saved contact or falls back to addToContacts(_:), keeping the star correct even when the peer isn't currently in the in-memory announce arrays.

Semantics

On iOS, favorite == contact membership in conversations.is_favorite, so one tap reproduces Android's add/remove behaviour via the existing persistence path — no new storage is introduced. Toggle is instant (no confirmation dialog) to match every other favorite star in the app. .primaryAction placement (not the iOS-only .topBarTrailing) keeps the macOS target compiling.

Decisions baked in

  • Instant toggle (iOS convention), no confirm dialog.
  • Star shown for all node types (Android parity), including NomadNet sites/relays.
  • Scoped to the Contacts surface; the Chats node-details destination leaves the callback nil so the star is hidden there.

Known cosmetic caveat

Favoriting a NomadNet site or relay surfaces it under My Contacts, which renders entries as chat peers (badgeType hardcoded .peer on reload). Matches the all-node-types choice; can be gated later if undesired.

Verification

Builds clean for both simulator and device (Columba-Swift, Debug-Swift) and installed/sanity-checked on a physical device.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a toolbar star button to NodeDetailsView so users can favorite/unfavorite a peer directly from the node details screen, closing a parity gap with Android Columba. A new Contact-typed async overload of toggleFavorite is introduced in ContactsViewModel to handle peers that may not be present in the in-memory announce arrays.

  • NodeDetailsView: adds onToggleFavorite callback, a separate isFavorite @State (deliberately decoupled from liveContact to prevent 1.5 s poll snap-back), isTogglingFavorite guard against rapid double-taps, and a .primaryAction toolbar Button that optimistically flips the star then reconciles to the authoritative persisted return value.
  • ContactsViewModel: new async -> Bool overload routes to the existing remove path for already-saved contacts and to await addToContacts for new ones, returning the actual in-memory membership state so callers can reconcile optimistic UI.
  • ContactsView: wires the onToggleFavorite callback to the ViewModel; the Chats destination leaves it nil so no star appears there.

Confidence Score: 5/5

Safe to merge; the toggle logic is correct and the previous concerns about missing rollback and rapid double-tap races have been addressed in this revision.

The add path is fully awaited and reconciles the star to the actual persisted outcome. The isTogglingFavorite guard disables the button while a toggle is in-flight, preventing concurrent taps. The remove path relies on the pre-existing fire-and-forget try? pattern — consistent with the rest of the file — and the return value accurately reflects in-memory state. The one open note is a docstring that slightly overstates the remove-path guarantee.

The docstring on the new toggleFavorite(for contact: Contact) overload in ContactsViewModel.swift warrants a read; it claims "after persistence completes" for both paths when only the add path is fully awaited.

Important Files Changed

Filename Overview
Sources/ColumbaApp/ViewModels/ContactsViewModel.swift New async toggleFavorite(for contact: Contact) overload; add path correctly awaits addToContacts, but remove path delegates to the fire-and-forget id-based overload, so the docstring's "after persistence completes" claim is only true for adds.
Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift Adds onToggleFavorite callback, isFavorite/isTogglingFavorite state, and .primaryAction toolbar star. Optimistic toggle with reconciliation and double-tap guard are correctly implemented.
Sources/ColumbaApp/Views/Contacts/ContactsView.swift Wires onToggleFavorite to vm.toggleFavorite(for:) in the Contacts destination; straightforward and correct.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant NodeDetailsView
    participant ContactsView
    participant ContactsViewModel
    participant DB as MessageRepository

    User->>NodeDetailsView: Tap star
    NodeDetailsView->>NodeDetailsView: isFavorite.toggle() (optimistic)
    NodeDetailsView->>NodeDetailsView: "isTogglingFavorite = true"
    NodeDetailsView->>ContactsView: onToggleFavorite(contact)
    ContactsView->>ContactsViewModel: await toggleFavorite(for: contact)

    alt contact in myContacts (remove path)
        ContactsViewModel->>ContactsViewModel: toggleFavorite(for: contact.id) [sync]
        ContactsViewModel-->>DB: "Task { try? setFavorite(false) } [fire-and-forget]"
        ContactsViewModel->>ContactsViewModel: myContacts.remove(at:) [sync]
        ContactsViewModel-->>ContactsView: return myContacts.contains(...) → false
    else contact NOT in myContacts (add path)
        ContactsViewModel->>DB: await ensureConversation(...)
        ContactsViewModel->>DB: await setFavorite(true)
        ContactsViewModel->>ContactsViewModel: myContacts.append(...)
        ContactsViewModel-->>ContactsView: return myContacts.contains(...) → true
    end

    ContactsView-->>NodeDetailsView: Bool (persisted state)
    NodeDetailsView->>NodeDetailsView: "isFavorite = persisted (reconcile)"
    NodeDetailsView->>NodeDetailsView: "isTogglingFavorite = false"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant NodeDetailsView
    participant ContactsView
    participant ContactsViewModel
    participant DB as MessageRepository

    User->>NodeDetailsView: Tap star
    NodeDetailsView->>NodeDetailsView: isFavorite.toggle() (optimistic)
    NodeDetailsView->>NodeDetailsView: "isTogglingFavorite = true"
    NodeDetailsView->>ContactsView: onToggleFavorite(contact)
    ContactsView->>ContactsViewModel: await toggleFavorite(for: contact)

    alt contact in myContacts (remove path)
        ContactsViewModel->>ContactsViewModel: toggleFavorite(for: contact.id) [sync]
        ContactsViewModel-->>DB: "Task { try? setFavorite(false) } [fire-and-forget]"
        ContactsViewModel->>ContactsViewModel: myContacts.remove(at:) [sync]
        ContactsViewModel-->>ContactsView: return myContacts.contains(...) → false
    else contact NOT in myContacts (add path)
        ContactsViewModel->>DB: await ensureConversation(...)
        ContactsViewModel->>DB: await setFavorite(true)
        ContactsViewModel->>ContactsViewModel: myContacts.append(...)
        ContactsViewModel-->>ContactsView: return myContacts.contains(...) → true
    end

    ContactsView-->>NodeDetailsView: Bool (persisted state)
    NodeDetailsView->>NodeDetailsView: "isFavorite = persisted (reconcile)"
    NodeDetailsView->>NodeDetailsView: "isTogglingFavorite = false"
Loading

Reviews (2): Last reviewed commit: "fix(contacts): reconcile node-details st..." | Re-trigger Greptile

Comment thread Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift Outdated
Comment thread Sources/ColumbaApp/ViewModels/ContactsViewModel.swift Outdated
…te (greptile #98 iter 1)

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 <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit bc23817 into main Jun 21, 2026
3 checks passed
@torlando-tech torlando-tech deleted the feat/ios-node-detail-favorite-star branch June 21, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant