Move profile update handling from notes to the background

During profiling, I found that some large hangs were being caused by a
large number of `notify` calls (and their handling functions) keeping
the main thread overly busy.

We cannot move the `notify` mechanism to a background thread (It has to
be done on the main actor or else runtime warnings/errors appear), so
instead this commit removes a very large source of notify calls/handling around
NoteContentView, and replaces it with a background task that streams
for profile updates and only updates its view when a relevant profile is
updated.

Changelog-Changed: Improved performance around note content views to prevent hangs
Closes: https://github.com/damus-io/damus/issues/3439
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-12-10 19:20:05 -08:00
parent d3a54458f5
commit 48143f859a
9 changed files with 90 additions and 140 deletions

View File

@@ -101,10 +101,17 @@ extension NostrNetworkManager {
relevantStream.continuation.yield(profile) relevantStream.continuation.yield(profile)
} }
} }
}
// Notify the rest of the app so views that rely on rendered text (like mention strings) /// Manually trigger profile updates for a given pubkey
// can reload and pick up the freshly fetched profile metadata. /// This is useful for local profile changes (e.g., nip05 validation, donation percentage updates)
notify(.profile_updated(.remote(pubkey: metadataEvent.pubkey))) func notifyProfileUpdate(pubkey: Pubkey) {
if let relevantStreams = streams[pubkey] {
guard let profile = ndb.lookup_profile_and_copy(pubkey) else { return }
for relevantStream in relevantStreams.values {
relevantStream.continuation.yield(profile)
}
}
} }
@@ -121,6 +128,29 @@ extension NostrNetworkManager {
} }
} }
func streamProfiles(pubkeys: Set<Pubkey>) -> AsyncStream<ProfileStreamItem> {
guard !pubkeys.isEmpty else {
return AsyncStream<ProfileStreamItem> { continuation in
continuation.finish()
}
}
return AsyncStream<ProfileStreamItem> { continuation in
let stream = ProfileStreamInfo(continuation: continuation)
for pubkey in pubkeys {
self.add(pubkey: pubkey, stream: stream)
}
continuation.onTermination = { @Sendable _ in
Task {
for pubkey in pubkeys {
await self.removeStream(pubkey: pubkey, id: stream.id)
}
}
}
}
}
// MARK: - Stream management // MARK: - Stream management

View File

@@ -46,7 +46,6 @@ struct NoteContentView: View {
let event: NostrEvent let event: NostrEvent
@State var blur_images: Bool @State var blur_images: Bool
@State var load_media: Bool = false @State var load_media: Bool = false
@State private var requestedMentionProfiles: Set<Pubkey> = []
let size: EventViewKind let size: EventViewKind
let preview_height: CGFloat? let preview_height: CGFloat?
let options: EventViewOptions let options: EventViewOptions
@@ -279,10 +278,12 @@ struct NoteContentView: View {
.padding(.horizontal) .padding(.horizontal)
} }
func ensureMentionProfilesAreFetchingIfNeeded() { @concurrent
func streamProfiles() async throws {
var mentionPubkeys: Set<Pubkey> = [] var mentionPubkeys: Set<Pubkey> = []
try? NdbBlockGroup.borrowBlockGroup(event: event, using: damus_state.ndb, and: damus_state.keypair, borrow: { blockGroup in let event = await self.event.clone()
let _: ()? = try? blockGroup.forEachBlock({ _, block in try await NdbBlockGroup.borrowBlockGroup(event: event, using: damus_state.ndb, and: damus_state.keypair, borrow: { blockGroup in
blockGroup.forEachBlock({ _, block in
guard let pubkey = block.mentionPubkey(tags: event.tags) else { guard let pubkey = block.mentionPubkey(tags: event.tags) else {
return .loopContinue return .loopContinue
} }
@@ -291,32 +292,12 @@ struct NoteContentView: View {
}) })
}) })
guard !mentionPubkeys.isEmpty else { return } if mentionPubkeys.isEmpty {
return
var toFetch: [Pubkey] = []
for pubkey in mentionPubkeys {
if requestedMentionProfiles.contains(pubkey) {
continue
}
requestedMentionProfiles.insert(pubkey)
if damus_state.profiles.has_fresh_profile(id: pubkey) {
continue
} }
toFetch.append(pubkey) for await profile in await damus_state.nostrNetwork.profilesManager.streamProfiles(pubkeys: mentionPubkeys) {
} await load(force_artifacts: true)
guard !toFetch.isEmpty else { return }
// Kick off metadata fetches for any missing mention profiles so their names can render once loaded.
for pubkey in toFetch {
Task {
for await _ in await damus_state.nostrNetwork.profilesManager.streamProfile(pubkey: pubkey) {
// NO-OP, we will receive the update via `notify`
break
}
}
} }
} }
@@ -325,8 +306,6 @@ struct NoteContentView: View {
return return
} }
ensureMentionProfilesAreFetchingIfNeeded()
// always reload artifacts on load // always reload artifacts on load
let plan = get_preload_plan(evcache: damus_state.events, ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings) let plan = get_preload_plan(evcache: damus_state.events, ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings)
@@ -442,44 +421,13 @@ struct NoteContentView: View {
var body: some View { var body: some View {
ArtifactContent ArtifactContent
.onReceive(handle_notify(.profile_updated)) { profile in .task {
try? NdbBlockGroup.borrowBlockGroup(event: event, using: damus_state.ndb, and: damus_state.keypair, borrow: { blockGroup in try? await streamProfiles()
let _: Int? = blockGroup.forEachBlock { index, block in
switch block {
case .mention(let m):
guard let typ = m.bech32_type else {
return .loopContinue
}
switch typ {
case .nprofile:
if m.bech32.nprofile.matches_pubkey(pk: profile.pubkey) {
load(force_artifacts: true)
}
case .npub:
if m.bech32.npub.matches_pubkey(pk: profile.pubkey) {
load(force_artifacts: true)
}
case .nevent: return .loopContinue
case .nrelay: return .loopContinue
case .nsec: return .loopContinue
case .note: return .loopContinue
case .naddr: return .loopContinue
}
case .text: return .loopContinue
case .hashtag: return .loopContinue
case .url: return .loopContinue
case .invoice: return .loopContinue
case .mention_index(_): return .loopContinue
}
return .loopContinue
}
})
} }
.onAppear { .onAppear {
load() load()
} }
} }
} }
class NoteArtifactsParts { class NoteArtifactsParts {

View File

@@ -46,7 +46,7 @@ struct NIP05DomainTimelineView: View {
if let pubkeys = model.filter.authors { if let pubkeys = model.filter.authors {
for pubkey in pubkeys { for pubkey in pubkeys {
check_nip05_validity(pubkey: pubkey, profiles: damus_state.profiles) check_nip05_validity(pubkey: pubkey, damus_state: damus_state)
} }
} }
} }

View File

@@ -17,7 +17,6 @@ struct EventProfileName: View {
@State var nip05: NIP05? @State var nip05: NIP05?
@State var donation: Int? @State var donation: Int?
@State var purple_account: DamusPurple.Account? @State var purple_account: DamusPurple.Account?
@StateObject private var profileObserver: ProfileObserver
let size: EventViewKind let size: EventViewKind
@@ -28,7 +27,6 @@ struct EventProfileName: View {
let donation = damus.profiles.lookup(id: pubkey)?.damus_donation let donation = damus.profiles.lookup(id: pubkey)?.damus_donation
self._donation = State(wrappedValue: donation) self._donation = State(wrappedValue: donation)
self.purple_account = nil self.purple_account = nil
self._profileObserver = StateObject.init(wrappedValue: ProfileObserver(pubkey: pubkey, damusState: damus))
} }
var friend_type: FriendType? { var friend_type: FriendType? {
@@ -102,13 +100,8 @@ struct EventProfileName: View {
SupporterBadge(percent: self.supporter_percentage(), purple_account: self.purple_account, style: .compact) SupporterBadge(percent: self.supporter_percentage(), purple_account: self.purple_account, style: .compact)
} }
} }
.onReceive(handle_notify(.profile_updated)) { update in .task {
if update.pubkey != pubkey { for await profile in await damus_state.nostrNetwork.profilesManager.streamProfile(pubkey: pubkey) {
return
}
guard let profile = damus_state.profiles.lookup(id: update.pubkey) else { return }
let display_name = Profile.displayName(profile: profile, pubkey: pubkey) let display_name = Profile.displayName(profile: profile, pubkey: pubkey)
if display_name != self.display_name { if display_name != self.display_name {
self.display_name = display_name self.display_name = display_name
@@ -124,6 +117,7 @@ struct EventProfileName: View {
donation = profile.damus_donation donation = profile.damus_donation
} }
} }
}
.task { .task {
if damus_state.purple.enable_purple { if damus_state.purple.enable_purple {
self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: pubkey) self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: pubkey)

View File

@@ -45,7 +45,6 @@ struct ProfileName: View {
@State var donation: Int? @State var donation: Int?
@State var purple_account: DamusPurple.Account? @State var purple_account: DamusPurple.Account?
@State var nip05_domain_favicon: FaviconURL? @State var nip05_domain_favicon: FaviconURL?
@StateObject var profileObserver: ProfileObserver
init(pubkey: Pubkey, prefix: String = "", damus: DamusState, show_nip5_domain: Bool = true, supporterBadgeStyle: SupporterBadge.Style = .compact) { init(pubkey: Pubkey, prefix: String = "", damus: DamusState, show_nip5_domain: Bool = true, supporterBadgeStyle: SupporterBadge.Style = .compact) {
self.pubkey = pubkey self.pubkey = pubkey
@@ -54,7 +53,6 @@ struct ProfileName: View {
self.show_nip5_domain = show_nip5_domain self.show_nip5_domain = show_nip5_domain
self.supporterBadgeStyle = supporterBadgeStyle self.supporterBadgeStyle = supporterBadgeStyle
self.purple_account = nil self.purple_account = nil
self._profileObserver = StateObject.init(wrappedValue: ProfileObserver(pubkey: pubkey, damusState: damus))
} }
var friend_type: FriendType? { var friend_type: FriendType? {
@@ -131,21 +129,10 @@ struct ProfileName: View {
.largest() .largest()
} }
} }
.onReceive(handle_notify(.profile_updated)) { update in .task {
if update.pubkey != pubkey { for await profile in await damus_state.nostrNetwork.profilesManager.streamProfile(pubkey: pubkey) {
return handle_profile_update(profile: profile)
} }
switch update {
case .remote(let pubkey):
guard let prof = damus_state.profiles.lookup(id: pubkey) else {
return
}
handle_profile_update(profile: prof)
case .manual(_, let prof):
handle_profile_update(profile: prof)
}
} }
} }

View File

@@ -75,8 +75,7 @@ struct ProfilePicView: View {
let privacy_sensitive: Bool let privacy_sensitive: Bool
@State var picture: String? @State var picture: String?
@StateObject private var profileObserver: ProfileObserver let damusState: DamusState
@EnvironmentObject var damusState: DamusState
init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil, privacy_sensitive: Bool = false, damusState: DamusState) { init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil, privacy_sensitive: Bool = false, damusState: DamusState) {
self.pubkey = pubkey self.pubkey = pubkey
@@ -87,7 +86,7 @@ struct ProfilePicView: View {
self.disable_animation = disable_animation self.disable_animation = disable_animation
self.zappability_indicator = show_zappability ?? false self.zappability_indicator = show_zappability ?? false
self.privacy_sensitive = privacy_sensitive self.privacy_sensitive = privacy_sensitive
self._profileObserver = StateObject.init(wrappedValue: ProfileObserver(pubkey: pubkey, damusState: damusState)) self.damusState = damusState
} }
var privacy_sensitive_pubkey: Pubkey { var privacy_sensitive_pubkey: Pubkey {
@@ -110,23 +109,6 @@ struct ProfilePicView: View {
var body: some View { var body: some View {
ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) {
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: privacy_sensitive_pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(privacy_sensitive_pubkey)), size: size, highlight: highlight, disable_animation: disable_animation) InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: privacy_sensitive_pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(privacy_sensitive_pubkey)), size: size, highlight: highlight, disable_animation: disable_animation)
.onReceive(handle_notify(.profile_updated)) { updated in
guard updated.pubkey == self.pubkey else {
return
}
switch updated {
case .manual(_, let profile):
if let pic = profile.picture {
self.picture = pic
}
case .remote(pubkey: let pk):
let profile = profiles.lookup(id: pk)
if let pic = profile?.picture {
self.picture = pic
}
}
}
if self.zappability_indicator, let lnurl = self.get_lnurl(), lnurl != "" { if self.zappability_indicator, let lnurl = self.get_lnurl(), lnurl != "" {
Image("zap.fill") Image("zap.fill")
@@ -141,6 +123,13 @@ struct ProfilePicView: View {
.clipShape(Circle()) .clipShape(Circle())
} }
} }
.task {
for await profile in await damusState.nostrNetwork.profilesManager.streamProfile(pubkey: pubkey) {
if let pic = profile.picture {
self.picture = pic
}
}
}
} }
} }

View File

@@ -144,7 +144,7 @@ struct ProfileView: View {
return AnyView( return AnyView(
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack { ZStack {
BannerImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) BannerImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state)
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight) .frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight)
.clipped() .clipped()
@@ -525,7 +525,7 @@ struct ProfileView: View {
dismiss() dismiss()
} }
.onAppear() { .onAppear() {
check_nip05_validity(pubkey: self.profile.pubkey, profiles: self.damus_state.profiles) check_nip05_validity(pubkey: self.profile.pubkey, damus_state: self.damus_state)
profile.subscribe() profile.subscribe()
//followers.subscribe() //followers.subscribe()
} }
@@ -568,7 +568,8 @@ extension View {
} }
@MainActor @MainActor
func check_nip05_validity(pubkey: Pubkey, profiles: Profiles) { func check_nip05_validity(pubkey: Pubkey, damus_state: DamusState) {
let profiles = damus_state.profiles
let profile = profiles.lookup(id: pubkey) let profile = profiles.lookup(id: pubkey)
guard let nip05 = profile?.nip05, guard let nip05 = profile?.nip05,
@@ -586,7 +587,7 @@ func check_nip05_validity(pubkey: Pubkey, profiles: Profiles) {
Task { @MainActor in Task { @MainActor in
profiles.set_validated(pubkey, nip05: validated) profiles.set_validated(pubkey, nip05: validated)
profiles.nip05_pubkey[nip05] = pubkey profiles.nip05_pubkey[nip05] = pubkey
notify(.profile_updated(.remote(pubkey: pubkey))) await damus_state.nostrNetwork.profilesManager.notifyProfileUpdate(pubkey: pubkey)
} }
} }
} }

View File

@@ -250,7 +250,9 @@ struct NWCSettings: View {
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: p, reactions: profile.reactions) let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: p, reactions: profile.reactions)
notify(.profile_updated(.manual(pubkey: self.damus_state.pubkey, profile: prof))) Task {
await damus_state.nostrNetwork.profilesManager.notifyProfileUpdate(pubkey: self.damus_state.pubkey)
}
} }
.onDisappear { .onDisappear {

View File

@@ -90,30 +90,28 @@ struct BannerImageView: View {
let disable_animation: Bool let disable_animation: Bool
let pubkey: Pubkey let pubkey: Pubkey
let profiles: Profiles let profiles: Profiles
let damusState: DamusState
@State var banner: String? @State var banner: String?
init(pubkey: Pubkey, profiles: Profiles, disable_animation: Bool, banner: String? = nil) { init(pubkey: Pubkey, profiles: Profiles, disable_animation: Bool, banner: String? = nil, damusState: DamusState) {
self.pubkey = pubkey self.pubkey = pubkey
self.profiles = profiles self.profiles = profiles
self._banner = State(initialValue: banner) self._banner = State(initialValue: banner)
self.disable_animation = disable_animation self.disable_animation = disable_animation
self.damusState = damusState
} }
var body: some View { var body: some View {
InnerBannerImageView(disable_animation: disable_animation, url: get_banner_url(banner: banner, pubkey: pubkey, profiles: profiles)) InnerBannerImageView(disable_animation: disable_animation, url: get_banner_url(banner: banner, pubkey: pubkey, profiles: profiles))
.onReceive(handle_notify(.profile_updated)) { updated in .task {
guard updated.pubkey == self.pubkey, for await profile in await damusState.nostrNetwork.profilesManager.streamProfile(pubkey: pubkey) {
let profile = profiles.lookup(id: updated.pubkey)
else {
return
}
if let bannerImage = profile.banner, bannerImage != self.banner { if let bannerImage = profile.banner, bannerImage != self.banner {
self.banner = bannerImage self.banner = bannerImage
} }
} }
} }
}
} }
func get_banner_url(banner: String?, pubkey: Pubkey, profiles: Profiles) -> URL? { func get_banner_url(banner: String?, pubkey: Pubkey, profiles: Profiles) -> URL? {
@@ -129,7 +127,8 @@ struct BannerImageView_Previews: PreviewProvider {
BannerImageView( BannerImageView(
pubkey: test_pubkey, pubkey: test_pubkey,
profiles: make_preview_profiles(test_pubkey), profiles: make_preview_profiles(test_pubkey),
disable_animation: false disable_animation: false,
damusState: test_damus_state
) )
} }
} }