diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 6bb9c181..b82a7582 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -1669,6 +1669,7 @@ D74EC8522E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */; }; D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; }; D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; }; + D751FA992EF62C8100E10F1B /* ProfilesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */; }; D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; }; D755B28D2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; }; D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; }; @@ -2768,6 +2769,7 @@ D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonCopyableLinkedList.swift; sourceTree = ""; }; D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = ""; }; D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = ""; }; + D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesManagerTests.swift; sourceTree = ""; }; D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = ""; }; D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP37Draft.swift; sourceTree = ""; }; D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = ""; }; @@ -5278,6 +5280,7 @@ D7EBF8BC2E5946F9004EAE29 /* NostrNetworkManagerTests */ = { isa = PBXGroup; children = ( + D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */, D7EBF8BD2E594708004EAE29 /* test_notes.jsonl */, D7EBF8BA2E5901F7004EAE29 /* NostrNetworkManagerTests.swift */, D7EBF8BF2E5D39D1004EAE29 /* ThreadModelTests.swift */, @@ -6252,6 +6255,7 @@ 4C0ED07F2D7A1E260020D8A2 /* Benchmarking.swift in Sources */, 3A3040ED29A5CB86008A0F29 /* ReplyDescriptionTests.swift in Sources */, D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */, + D751FA992EF62C8100E10F1B /* ProfilesManagerTests.swift in Sources */, 3AAC7A022A60FE72002B50DF /* LocalizationUtilTests.swift in Sources */, D7CBD1D62B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift in Sources */, D7EBF8BB2E59022A004EAE29 /* NostrNetworkManagerTests.swift in Sources */, diff --git a/damus/Core/Networking/NostrNetworkManager/ProfilesManager.swift b/damus/Core/Networking/NostrNetworkManager/ProfilesManager.swift index 71e9166a..86527914 100644 --- a/damus/Core/Networking/NostrNetworkManager/ProfilesManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/ProfilesManager.swift @@ -116,31 +116,72 @@ extension NostrNetworkManager { // MARK: - Streaming interface - - func streamProfile(pubkey: Pubkey) -> AsyncStream { + + /// Streams profile updates for a single pubkey. + /// + /// By default, the stream immediately yields the existing profile from NostrDB + /// (if available), then continues yielding updates as they arrive from the network. + /// + /// This immediate yield is essential for views that display profile data (names, + /// pictures) because the subscription restart has a ~1 second delay. Without it, + /// views would flash abbreviated pubkeys or robohash placeholders. + /// + /// Set `yieldCached: false` for subscribers that only need network updates (e.g., + /// re-rendering content when profiles change) and already handle initial state + /// through other means. + /// + /// - Parameters: + /// - pubkey: The pubkey to stream profile updates for + /// - yieldCached: Whether to immediately yield the cached profile. Defaults to `true`. + /// - Returns: An AsyncStream that yields Profile objects + func streamProfile(pubkey: Pubkey, yieldCached: Bool = true) -> AsyncStream { return AsyncStream { continuation in let stream = ProfileStreamInfo(continuation: continuation) self.add(pubkey: pubkey, stream: stream) - + + // Yield cached profile immediately so views don't flash placeholder content. + // Callers that only need updates (not initial state) can opt out via yieldCached: false. + if yieldCached, let existingProfile = ndb.lookup_profile_and_copy(pubkey) { + continuation.yield(existingProfile) + } + continuation.onTermination = { @Sendable _ in Task { await self.removeStream(pubkey: pubkey, id: stream.id) } } } } - - func streamProfiles(pubkeys: Set) -> AsyncStream { + + /// Streams profile updates for multiple pubkeys. + /// + /// Same behavior as `streamProfile(_:yieldCached:)` but for a set of pubkeys. + /// + /// - Parameters: + /// - pubkeys: The set of pubkeys to stream profile updates for + /// - yieldCached: Whether to immediately yield cached profiles. Defaults to `true`. + /// - Returns: An AsyncStream that yields Profile objects + func streamProfiles(pubkeys: Set, yieldCached: Bool = true) -> AsyncStream { guard !pubkeys.isEmpty else { return AsyncStream { continuation in continuation.finish() } } - + return AsyncStream { continuation in let stream = ProfileStreamInfo(continuation: continuation) for pubkey in pubkeys { self.add(pubkey: pubkey, stream: stream) } - + + // Yield cached profiles immediately so views render correctly from the start. + // Callers that only need updates (not initial state) can opt out via yieldCached: false. + if yieldCached { + for pubkey in pubkeys { + if let existingProfile = ndb.lookup_profile_and_copy(pubkey) { + continuation.yield(existingProfile) + } + } + } + continuation.onTermination = { @Sendable _ in Task { for pubkey in pubkeys { diff --git a/damus/Features/Events/NoteContentView.swift b/damus/Features/Events/NoteContentView.swift index f236c492..3ee4c932 100644 --- a/damus/Features/Events/NoteContentView.swift +++ b/damus/Features/Events/NoteContentView.swift @@ -296,7 +296,9 @@ struct NoteContentView: View { return } - for await profile in await damus_state.nostrNetwork.profilesManager.streamProfiles(pubkeys: mentionPubkeys) { + // Only re-render on network updates, not cached profiles. + // Initial render already uses cached profile data via the view hierarchy. + for await profile in await damus_state.nostrNetwork.profilesManager.streamProfiles(pubkeys: mentionPubkeys, yieldCached: false) { await load(force_artifacts: true) } } diff --git a/damus/Shared/Utilities/DisplayName.swift b/damus/Shared/Utilities/DisplayName.swift index 6ae5074e..a928772b 100644 --- a/damus/Shared/Utilities/DisplayName.swift +++ b/damus/Shared/Utilities/DisplayName.swift @@ -80,9 +80,11 @@ func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) -> } func abbrev_bech32_pubkey(pubkey: Pubkey) -> String { - return abbrev_identifier(String(pubkey.npub.dropFirst(4))) + let npub = pubkey.npub + // Show "npub1abc...xyz" format for better readability + return String(npub.prefix(9)) + "..." + String(npub.suffix(4)) } -func abbrev_identifier(_ pubkey: String, amount: Int = 8) -> String { - return pubkey.prefix(amount) + ":" + pubkey.suffix(amount) +func abbrev_identifier(_ identifier: String, amount: Int = 8) -> String { + return identifier.prefix(amount) + ":" + identifier.suffix(amount) } diff --git a/damusTests/NostrNetworkManagerTests/ProfilesManagerTests.swift b/damusTests/NostrNetworkManagerTests/ProfilesManagerTests.swift new file mode 100644 index 00000000..394ccf0d --- /dev/null +++ b/damusTests/NostrNetworkManagerTests/ProfilesManagerTests.swift @@ -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) + } +}