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)
}
}
}
// Notify the rest of the app so views that rely on rendered text (like mention strings)
// can reload and pick up the freshly fetched profile metadata.
notify(.profile_updated(.remote(pubkey: metadataEvent.pubkey)))
/// Manually trigger profile updates for a given pubkey
/// This is useful for local profile changes (e.g., nip05 validation, donation percentage updates)
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

View File

@@ -46,7 +46,6 @@ struct NoteContentView: View {
let event: NostrEvent
@State var blur_images: Bool
@State var load_media: Bool = false
@State private var requestedMentionProfiles: Set<Pubkey> = []
let size: EventViewKind
let preview_height: CGFloat?
let options: EventViewOptions
@@ -279,10 +278,12 @@ struct NoteContentView: View {
.padding(.horizontal)
}
func ensureMentionProfilesAreFetchingIfNeeded() {
@concurrent
func streamProfiles() async throws {
var mentionPubkeys: Set<Pubkey> = []
try? NdbBlockGroup.borrowBlockGroup(event: event, using: damus_state.ndb, and: damus_state.keypair, borrow: { blockGroup in
let _: ()? = try? blockGroup.forEachBlock({ _, block in
let event = await self.event.clone()
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 {
return .loopContinue
}
@@ -291,32 +292,12 @@ struct NoteContentView: View {
})
})
guard !mentionPubkeys.isEmpty else { 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)
if mentionPubkeys.isEmpty {
return
}
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
}
}
for await profile in await damus_state.nostrNetwork.profilesManager.streamProfiles(pubkeys: mentionPubkeys) {
await load(force_artifacts: true)
}
}
@@ -325,8 +306,6 @@ struct NoteContentView: View {
return
}
ensureMentionProfilesAreFetchingIfNeeded()
// 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)
@@ -442,44 +421,13 @@ struct NoteContentView: View {
var body: some View {
ArtifactContent
.onReceive(handle_notify(.profile_updated)) { profile in
try? NdbBlockGroup.borrowBlockGroup(event: event, using: damus_state.ndb, and: damus_state.keypair, borrow: { blockGroup in
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
}
})
.task {
try? await streamProfiles()
}
.onAppear {
load()
}
}
}
class NoteArtifactsParts {

View File

@@ -46,7 +46,7 @@ struct NIP05DomainTimelineView: View {
if let pubkeys = model.filter.authors {
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 donation: Int?
@State var purple_account: DamusPurple.Account?
@StateObject private var profileObserver: ProfileObserver
let size: EventViewKind
@@ -28,7 +27,6 @@ struct EventProfileName: View {
let donation = damus.profiles.lookup(id: pubkey)?.damus_donation
self._donation = State(wrappedValue: donation)
self.purple_account = nil
self._profileObserver = StateObject.init(wrappedValue: ProfileObserver(pubkey: pubkey, damusState: damus))
}
var friend_type: FriendType? {
@@ -102,26 +100,22 @@ struct EventProfileName: View {
SupporterBadge(percent: self.supporter_percentage(), purple_account: self.purple_account, style: .compact)
}
}
.onReceive(handle_notify(.profile_updated)) { update in
if update.pubkey != pubkey {
return
}
.task {
for await profile in await damus_state.nostrNetwork.profilesManager.streamProfile(pubkey: pubkey) {
let display_name = Profile.displayName(profile: profile, pubkey: pubkey)
if display_name != self.display_name {
self.display_name = display_name
}
guard let profile = damus_state.profiles.lookup(id: update.pubkey) else { return }
let nip05 = damus_state.profiles.is_validated(pubkey)
let display_name = Profile.displayName(profile: profile, pubkey: pubkey)
if display_name != self.display_name {
self.display_name = display_name
}
if self.nip05 != nip05 {
self.nip05 = nip05
}
let nip05 = damus_state.profiles.is_validated(pubkey)
if self.nip05 != nip05 {
self.nip05 = nip05
}
if self.donation != profile.damus_donation {
donation = profile.damus_donation
if self.donation != profile.damus_donation {
donation = profile.damus_donation
}
}
}
.task {

View File

@@ -45,7 +45,6 @@ struct ProfileName: View {
@State var donation: Int?
@State var purple_account: DamusPurple.Account?
@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) {
self.pubkey = pubkey
@@ -54,7 +53,6 @@ struct ProfileName: View {
self.show_nip5_domain = show_nip5_domain
self.supporterBadgeStyle = supporterBadgeStyle
self.purple_account = nil
self._profileObserver = StateObject.init(wrappedValue: ProfileObserver(pubkey: pubkey, damusState: damus))
}
var friend_type: FriendType? {
@@ -131,21 +129,10 @@ struct ProfileName: View {
.largest()
}
}
.onReceive(handle_notify(.profile_updated)) { update in
if update.pubkey != pubkey {
return
.task {
for await profile in await damus_state.nostrNetwork.profilesManager.streamProfile(pubkey: pubkey) {
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
@State var picture: String?
@StateObject private var profileObserver: ProfileObserver
@EnvironmentObject var damusState: DamusState
let 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
@@ -87,7 +86,7 @@ struct ProfilePicView: View {
self.disable_animation = disable_animation
self.zappability_indicator = show_zappability ?? false
self.privacy_sensitive = privacy_sensitive
self._profileObserver = StateObject.init(wrappedValue: ProfileObserver(pubkey: pubkey, damusState: damusState))
self.damusState = damusState
}
var privacy_sensitive_pubkey: Pubkey {
@@ -110,23 +109,6 @@ struct ProfilePicView: View {
var body: some View {
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)
.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 != "" {
Image("zap.fill")
@@ -141,6 +123,13 @@ struct ProfilePicView: View {
.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(
VStack(spacing: 0) {
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)
.frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight)
.clipped()
@@ -525,7 +525,7 @@ struct ProfileView: View {
dismiss()
}
.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()
//followers.subscribe()
}
@@ -568,7 +568,8 @@ extension View {
}
@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)
guard let nip05 = profile?.nip05,
@@ -586,7 +587,7 @@ func check_nip05_validity(pubkey: Pubkey, profiles: Profiles) {
Task { @MainActor in
profiles.set_validated(pubkey, nip05: validated)
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)
notify(.profile_updated(.manual(pubkey: self.damus_state.pubkey, profile: prof)))
Task {
await damus_state.nostrNetwork.profilesManager.notifyProfileUpdate(pubkey: self.damus_state.pubkey)
}
}
.onDisappear {

View File

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