Skip to content
Open
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
22 changes: 22 additions & 0 deletions FeedViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions FollowSender.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 49 additions & 2 deletions FollowsCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,22 @@ nonisolated final class FollowsCache: @unchecked Sendable {
private let lock = NSLock()
private var byPubkey: [String: [String]] = [:]
private var setByPubkey: [String: Set<String>] = [:]
/// `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() {}

nonisolated private static func key(for pubkey: String) -> String {
"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] {
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
}
1 change: 1 addition & 0 deletions NostrKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion OnboardingViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions ProfileViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions SignUpViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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,
Expand Down