Fix missing profile names and pictures due to stream timing

When a view subscribes to profile updates via streamProfile() or
streamProfiles(), the stream now immediately yields any existing
profile data from NostrDB before waiting for network updates.

Previously, subscribers had to wait up to ~1 second for the
subscriptionSwitcherTask to restart the profile listener before
receiving any data. During this window, views would display
abbreviated pubkeys (e.g., "npub1abc...") or robohash placeholders
instead of the cached profile name and picture.

The fix adds a simple NDB lookup when creating the stream. This has
negligible performance impact since:
- It's a one-time operation per subscription (not per update)
- The same lookup was already happening in view bodies anyway
- NDB lookups are fast local queries

A new `yieldCached` parameter (default: true) allows callers to opt
out of the initial cached emission. NoteContentView uses this to
avoid redundant artifact re-renders — it only needs network updates
since its initial render already uses cached profile data.

Furthermore, when a profile has no metadata, the display name now shows
"npub1yrse...q9ye" instead of "1yrsedhw:8q0pq9ye" for a better UX.

Closes: https://github.com/damus-io/damus/issues/3454
Closes: https://github.com/damus-io/damus/issues/3455
Changelog-Changed: Changed abbreviated pubkey format to npub1...xyz for better readability
Changelog-Fixed: Fixed instances where a profile would not display profile name and picture for a few seconds
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
alltheseas
2025-12-19 19:21:49 -06:00
committed by GitHub
parent 5058fb33d7
commit a0cecdc8ad
5 changed files with 310 additions and 11 deletions

View File

@@ -0,0 +1,250 @@
//
// ProfilesManagerTests.swift
// damusTests
//
// Created by alltheseas on 2025-12-18.
//
import XCTest
@testable import damus
class ProfilesManagerTests: XCTestCase {
/// Tests that streamProfile with yieldCached: true (default) immediately emits a cached profile.
///
/// This verifies the fix for missing profile names/pictures: when a view subscribes to
/// profile updates, it should immediately receive any cached profile data from NostrDB
/// rather than waiting for the network subscription to restart (~1 second delay).
func testStreamProfileYieldsCachedProfileByDefault() async throws {
let ndb = Ndb.test
defer { ndb.close() }
// Seed a profile into NDB
let profilePubkey = test_keypair.pubkey
let profileJson = """
{"name":"testuser","display_name":"Test User","about":"A test profile","picture":"https://example.com/pic.jpg"}
"""
let metadataEvent = NostrEvent(
content: profileJson,
keypair: test_keypair,
kind: NostrKind.metadata.rawValue,
tags: [],
createdAt: UInt32(Date.now.timeIntervalSince1970)
)!
let eventJson = encode_json(metadataEvent)!
let relayMessage = "[\"EVENT\",\"subid\",\(eventJson)]"
let processed = ndb.processEvent(relayMessage)
XCTAssertTrue(processed, "Failed to process metadata event")
// Give NDB time to process
try await Task.sleep(for: .milliseconds(100))
// Verify profile is in NDB
let cachedProfile = ndb.lookup_profile_and_copy(profilePubkey)
XCTAssertNotNil(cachedProfile, "Profile should be cached in NDB")
XCTAssertEqual(cachedProfile?.name, "testuser")
// Create ProfilesManager and test streaming
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let subscriptionManager = NostrNetworkManager.SubscriptionManager(
pool: pool,
ndb: ndb,
experimentalLocalRelayModelSupport: false
)
let profilesManager = NostrNetworkManager.ProfilesManager(
subscriptionManager: subscriptionManager,
ndb: ndb
)
// Test: yieldCached: true (default) should emit immediately
let receivedProfile = XCTestExpectation(description: "Should receive cached profile immediately")
var emittedProfile: NdbProfile?
Task {
// Default yieldCached: true
for await profile in await profilesManager.streamProfile(pubkey: profilePubkey) {
emittedProfile = profile
receivedProfile.fulfill()
break // Only need the first emission
}
}
// Should receive the profile very quickly (not waiting for network)
await fulfillment(of: [receivedProfile], timeout: 0.5)
XCTAssertNotNil(emittedProfile, "Should have received a profile")
XCTAssertEqual(emittedProfile?.name, "testuser", "Should receive the cached profile data")
XCTAssertEqual(emittedProfile?.display_name, "Test User")
}
/// Tests that streamProfile with yieldCached: false does NOT immediately emit cached profiles.
///
/// This is used by callers like NoteContentView that only need network updates, not
/// initial cached state, to avoid redundant artifact re-renders.
func testStreamProfileOptOutSkipsCachedProfile() async throws {
let ndb = Ndb.test
defer { ndb.close() }
// Seed a profile into NDB
let profilePubkey = test_keypair.pubkey
let profileJson = """
{"name":"testuser","display_name":"Test User","about":"A test profile"}
"""
let metadataEvent = NostrEvent(
content: profileJson,
keypair: test_keypair,
kind: NostrKind.metadata.rawValue,
tags: [],
createdAt: UInt32(Date.now.timeIntervalSince1970)
)!
let eventJson = encode_json(metadataEvent)!
let relayMessage = "[\"EVENT\",\"subid\",\(eventJson)]"
let processed = ndb.processEvent(relayMessage)
XCTAssertTrue(processed, "Failed to process metadata event")
// Give NDB time to process
try await Task.sleep(for: .milliseconds(100))
// Verify profile is in NDB
let cachedProfile = ndb.lookup_profile_and_copy(profilePubkey)
XCTAssertNotNil(cachedProfile, "Profile should be cached in NDB")
// Create ProfilesManager
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let subscriptionManager = NostrNetworkManager.SubscriptionManager(
pool: pool,
ndb: ndb,
experimentalLocalRelayModelSupport: false
)
let profilesManager = NostrNetworkManager.ProfilesManager(
subscriptionManager: subscriptionManager,
ndb: ndb
)
// Test: yieldCached: false should NOT emit immediately
let shouldNotReceive = XCTestExpectation(description: "Should NOT receive cached profile")
shouldNotReceive.isInverted = true // We expect this to NOT be fulfilled
Task {
// Explicitly opt out of cached emission
for await _ in await profilesManager.streamProfile(pubkey: profilePubkey, yieldCached: false) {
shouldNotReceive.fulfill() // This should NOT happen
break
}
}
// Wait briefly - the stream should NOT emit anything
await fulfillment(of: [shouldNotReceive], timeout: 0.3)
// If we get here without the expectation being fulfilled, the test passes
}
/// Tests that streamProfiles with yieldCached: true emits all cached profiles.
func testStreamProfilesYieldsCachedProfilesByDefault() async throws {
let ndb = Ndb.test
defer { ndb.close() }
// Seed two profiles into NDB
let pubkey1 = test_keypair.pubkey
let pubkey2 = test_pubkey_2
let profile1Json = "{\"name\":\"user1\",\"display_name\":\"User One\"}"
let profile1Event = NostrEvent(
content: profile1Json,
keypair: test_keypair,
kind: NostrKind.metadata.rawValue,
tags: [],
createdAt: UInt32(Date.now.timeIntervalSince1970)
)!
// For pubkey2, we need to create an event that appears to come from that pubkey
// Since we can't sign for pubkey2, we'll just test with one profile
let eventJson = encode_json(profile1Event)!
let relayMessage = "[\"EVENT\",\"subid\",\(eventJson)]"
let processed = ndb.processEvent(relayMessage)
XCTAssertTrue(processed, "Failed to process metadata event")
try await Task.sleep(for: .milliseconds(100))
// Create ProfilesManager
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let subscriptionManager = NostrNetworkManager.SubscriptionManager(
pool: pool,
ndb: ndb,
experimentalLocalRelayModelSupport: false
)
let profilesManager = NostrNetworkManager.ProfilesManager(
subscriptionManager: subscriptionManager,
ndb: ndb
)
// Test: yieldCached: true (default) should emit cached profiles
let receivedProfile = XCTestExpectation(description: "Should receive cached profile")
var emittedProfiles: [NdbProfile] = []
Task {
// Request both pubkeys, but only pubkey1 has a profile in NDB
for await profile in await profilesManager.streamProfiles(pubkeys: Set([pubkey1, pubkey2])) {
emittedProfiles.append(profile)
receivedProfile.fulfill()
break
}
}
await fulfillment(of: [receivedProfile], timeout: 0.5)
XCTAssertEqual(emittedProfiles.count, 1, "Should receive one cached profile")
XCTAssertEqual(emittedProfiles.first?.name, "user1")
}
/// Tests that streamProfiles with yieldCached: false does NOT emit cached profiles.
func testStreamProfilesOptOutSkipsCachedProfiles() async throws {
let ndb = Ndb.test
defer { ndb.close() }
// Seed a profile into NDB
let profilePubkey = test_keypair.pubkey
let profileJson = "{\"name\":\"testuser\"}"
let metadataEvent = NostrEvent(
content: profileJson,
keypair: test_keypair,
kind: NostrKind.metadata.rawValue,
tags: [],
createdAt: UInt32(Date.now.timeIntervalSince1970)
)!
let eventJson = encode_json(metadataEvent)!
let relayMessage = "[\"EVENT\",\"subid\",\(eventJson)]"
ndb.processEvent(relayMessage)
try await Task.sleep(for: .milliseconds(100))
// Create ProfilesManager
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let subscriptionManager = NostrNetworkManager.SubscriptionManager(
pool: pool,
ndb: ndb,
experimentalLocalRelayModelSupport: false
)
let profilesManager = NostrNetworkManager.ProfilesManager(
subscriptionManager: subscriptionManager,
ndb: ndb
)
// Test: yieldCached: false should NOT emit
let shouldNotReceive = XCTestExpectation(description: "Should NOT receive cached profiles")
shouldNotReceive.isInverted = true
Task {
for await _ in await profilesManager.streamProfiles(pubkeys: Set([profilePubkey]), yieldCached: false) {
shouldNotReceive.fulfill()
break
}
}
await fulfillment(of: [shouldNotReceive], timeout: 0.3)
}
}