From 33fe0980110151623b557867745a035344f9fbd8 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Tue, 16 Jun 2026 17:08:54 -0400 Subject: [PATCH] fix(follows): reconcile local follow cache with newest relay kind-3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-edit source of truth (FollowsCache) was only ever written at onboarding and on in-app follow/unfollow, and never reconciled against a newer kind-3 arriving from relays. A follow list edited in another client (e.g. trimmed via an external tool) was therefore never reflected locally, and the next in-app follow/unfollow rebuilt off the stale set and republished it, clobbering the out-of-band change. Track the created_at of the kind-3 that produced the stored set and add FollowsCache.reconcile, which adopts a relay copy only when it is strictly newer than what is held locally — replacing a frozen snapshot while refusing a stale copy that would undo a fresher local edit. Reconcile on launch (FeedViewModel.loadUserProfile, which already fetches the user's own kind-0) and when viewing one's own profile (ProfileViewModel.loadContacts). Follow/unfollow and onboarding now stamp the cache with the event timestamp. --- FeedViewModel.swift | 22 +++++++++++++++++ FollowSender.swift | 6 +++-- FollowsCache.swift | 51 +++++++++++++++++++++++++++++++++++++-- NostrKey.swift | 1 + OnboardingViewModel.swift | 4 ++- ProfileViewModel.swift | 9 +++++++ SignUpViewModel.swift | 6 +++-- 7 files changed, 92 insertions(+), 7 deletions(-) diff --git a/FeedViewModel.swift b/FeedViewModel.swift index c29f18a..729ff8b 100644 --- a/FeedViewModel.swift +++ b/FeedViewModel.swift @@ -1064,6 +1064,28 @@ final class FeedViewModel { userProfile = updated profiles[pubkey] = updated } + + // Reconcile the local follow set with the freshest kind-3 on relays. + // `FollowsCache` is otherwise only written at onboarding and on in-app + // follow/unfollow, so a follow list changed in another client (e.g. + // trimmed via an external tool) never lands locally — and the next + // in-app edit republishes the stale set, undoing the change. + // `reconcile` adopts the relay copy only when its `created_at` is + // newer than the set we already hold, so it can't clobber a fresher + // local edit. + let contactResults = await RelayPool.query( + relays: Self.indexerRelays, + filter: NostrFilter(kinds: [3], authors: [pubkey], limit: 1), + waitForAllRelays: true + ) + if let bestContacts = contactResults.filter({ $0.kind == 3 }).max(by: { $0.createdAt < $1.createdAt }) { + let followPubkeys = bestContacts.tags.compactMap { tag -> String? in + tag.count >= 2 && tag[0] == "p" ? tag[1] : nil + } + if FollowsCache.shared.reconcile(pubkey: pubkey, follows: followPubkeys, createdAt: bestContacts.createdAt) { + reloadFollowsCache() + } + } } /// Number of (score-sorted) relays the live follows feed connects to. With diff --git a/FollowSender.swift b/FollowSender.swift index df40395..822276e 100644 --- a/FollowSender.swift +++ b/FollowSender.swift @@ -60,8 +60,10 @@ final class FollowSender { guard !relays.isEmpty else { throw SendError.noRelays } // Save the new set locally up front so the UI reflects the change - // immediately even if the relay round-trip takes a moment. - FollowsCache.shared.update(pubkey: keypair.pubkey, follows: Array(follows)) + // immediately even if the relay round-trip takes a moment. Stamp it + // with the published event's `created_at` so a later relay reconcile + // recognizes this edit as the freshest and doesn't undo it. + FollowsCache.shared.update(pubkey: keypair.pubkey, follows: Array(follows), createdAt: event.createdAt) await EventStore.shared.persist([event]) let succeeded = await RelayPool.publish(event: event, to: relays, timeout: 8) diff --git a/FollowsCache.swift b/FollowsCache.swift index 87ffeb1..15daeb5 100644 --- a/FollowsCache.swift +++ b/FollowsCache.swift @@ -19,6 +19,11 @@ nonisolated final class FollowsCache: @unchecked Sendable { private let lock = NSLock() private var byPubkey: [String: [String]] = [:] private var setByPubkey: [String: Set] = [:] + /// `created_at` of the kind-3 that produced the stored follow set, per + /// pubkey. Used by `reconcile` to refuse a stale relay copy. 0 means + /// "unknown" — e.g. a set persisted before this field existed; in that + /// case any relay copy is allowed to replace it. + private var createdAtByPubkey: [String: Int] = [:] private init() {} @@ -26,6 +31,10 @@ nonisolated final class FollowsCache: @unchecked Sendable { "follow_pubkeys_\(pubkey)" } + nonisolated private static func createdAtKey(for pubkey: String) -> String { + "follow_pubkeys_ts_\(pubkey)" + } + /// Returns the follow array for `pubkey`, loading from UserDefaults on /// first access. The returned array preserves insertion order. nonisolated func follows(for pubkey: String) -> [String] { @@ -59,16 +68,36 @@ nonisolated final class FollowsCache: @unchecked Sendable { return s } + /// `created_at` of the kind-3 that produced the stored follow set, or 0 if + /// unknown. Loads from UserDefaults on first access (mirrors `follows`). + nonisolated func storedCreatedAt(for pubkey: String) -> Int { + lock.lock() + if let cached = createdAtByPubkey[pubkey] { + lock.unlock() + return cached + } + lock.unlock() + let ts = UserDefaults.standard.integer(forKey: Self.createdAtKey(for: pubkey)) + lock.lock() + createdAtByPubkey[pubkey] = ts + lock.unlock() + return ts + } + /// Persist a new follow list. Updates the in-memory cache *and* /// UserDefaults so callers reading via the legacy key still see the - /// fresh value. - nonisolated func update(pubkey: String, follows: [String]) { + /// fresh value. `createdAt` is the `created_at` of the kind-3 this set + /// came from (the just-published event for local edits) — stored so + /// `reconcile` can tell a fresher relay copy from a stale one. + nonisolated func update(pubkey: String, follows: [String], createdAt: Int) { let set = Set(follows) lock.lock() byPubkey[pubkey] = follows setByPubkey[pubkey] = set + createdAtByPubkey[pubkey] = createdAt lock.unlock() UserDefaults.standard.set(follows, forKey: Self.key(for: pubkey)) + UserDefaults.standard.set(createdAt, forKey: Self.createdAtKey(for: pubkey)) // Notify avatar follow-status badges (and anything else tracking the // follow set) to re-query. `update` is `nonisolated` and may run off // the main thread; post on the main actor so observers (which mutate @@ -79,11 +108,29 @@ nonisolated final class FollowsCache: @unchecked Sendable { } } + /// Adopt a follow set fetched from relays ONLY if its kind-3 is strictly + /// newer than the one that produced the currently-stored set. This is the + /// reconciliation the local cache previously lacked: the cache was only + /// ever written at onboarding and on in-app follow/unfollow, so a follow + /// list edited in another client (e.g. trimmed via an external tool) was + /// never reflected locally — and the next in-app edit republished the + /// stale set, clobbering the change. Gating on `created_at` lets an + /// out-of-band change (which carries a newer timestamp) replace the frozen + /// snapshot while refusing a stale relay copy that would undo a fresher + /// local edit. Returns true if it replaced the stored set. + @discardableResult + nonisolated func reconcile(pubkey: String, follows: [String], createdAt: Int) -> Bool { + guard createdAt > storedCreatedAt(for: pubkey) else { return false } + update(pubkey: pubkey, follows: follows, createdAt: createdAt) + return true + } + /// Drop the cache entry for `pubkey`. Called from `NostrKey.deleteAccount`. nonisolated func invalidate(pubkey: String) { lock.lock() byPubkey.removeValue(forKey: pubkey) setByPubkey.removeValue(forKey: pubkey) + createdAtByPubkey.removeValue(forKey: pubkey) lock.unlock() } } diff --git a/NostrKey.swift b/NostrKey.swift index 7ae92b9..292a462 100644 --- a/NostrKey.swift +++ b/NostrKey.swift @@ -109,6 +109,7 @@ enum NostrKey { "onboarding_done_\(pubkey)", "watch_only_\(pubkey)", "follow_pubkeys_\(pubkey)", + "follow_pubkeys_ts_\(pubkey)", "relay_scoreboard_v1_\(pubkey)", "latest_feed_ts_\(pubkey)", // Safety: mute lists, blocked users, muted threads, mute event timestamp diff --git a/OnboardingViewModel.swift b/OnboardingViewModel.swift index b382a47..2ab7cd3 100644 --- a/OnboardingViewModel.swift +++ b/OnboardingViewModel.swift @@ -57,16 +57,18 @@ final class OnboardingViewModel { phase = .fetchingFollows var followPubkeys: [String] = [] + var followCreatedAt = 0 if let followEvent = profileAndFollows .filter({ $0.kind == 3 }) .max(by: { $0.createdAt < $1.createdAt }) { followPubkeys = followEvent.tags.compactMap { tag in tag.count >= 2 && tag[0] == "p" ? tag[1] : nil } + followCreatedAt = followEvent.createdAt } followCount = followPubkeys.count - FollowsCache.shared.update(pubkey: keypair.pubkey, follows: followPubkeys) + FollowsCache.shared.update(pubkey: keypair.pubkey, follows: followPubkeys, createdAt: followCreatedAt) guard !followPubkeys.isEmpty else { phase = .done diff --git a/ProfileViewModel.swift b/ProfileViewModel.swift index ea85cab..ed63142 100644 --- a/ProfileViewModel.swift +++ b/ProfileViewModel.swift @@ -244,6 +244,15 @@ final class ProfileViewModel { followingCount = pubkeys.count // Their contact list p-tags the active user → they follow us. followsYou = pubkeys.contains(activeUserPubkey) + + // When this IS our own profile, push the freshest relay copy into the + // local follow cache so a list edited in another client replaces our + // frozen snapshot before the next in-app follow/unfollow rebuilds off + // it. Gated on `created_at`, so a stale relay copy can't undo a newer + // local edit. + if pubkey == activeUserPubkey { + FollowsCache.shared.reconcile(pubkey: pubkey, follows: pubkeys, createdAt: best.createdAt) + } } private func loadTargetWriteRelays() async { diff --git a/SignUpViewModel.swift b/SignUpViewModel.swift index 2c79acf..b3839cd 100644 --- a/SignUpViewModel.swift +++ b/SignUpViewModel.swift @@ -558,7 +558,10 @@ final class SignUpViewModel { var follows = selectedFollows follows.insert(keypair.pubkey) - FollowsCache.shared.update(pubkey: keypair.pubkey, follows: Array(follows)) + // Stamp the cache with the same `created_at` the kind-3 below is signed + // with, so a later relay reconcile treats this as the freshest set. + let now = NostrClock.now() + FollowsCache.shared.update(pubkey: keypair.pubkey, follows: Array(follows), createdAt: now) let writeRelays = discoveredRelays.filter(\.write).map(\.url) @@ -571,7 +574,6 @@ final class SignUpViewModel { startOutboxBuilder(follows: Array(follows), ownWriteRelays: writeRelays) let targets = (writeRelays + Self.indexerRelays).uniquedPreservingOrder() - let now = NostrClock.now() let tags: [[String]] = follows.map { ["p", $0] } guard let event = try? NostrEvent.sign( privkey32: privkey,