Improve Follow pack timeline loading logic in the Universe view

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-09-15 16:12:13 -07:00
parent 2185984ed7
commit 0582892cae
8 changed files with 130 additions and 40 deletions

View File

@@ -1161,6 +1161,9 @@
D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */; }; D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */; };
D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */; }; D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */; };
D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */; }; D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */; };
D72C01312E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */; };
D72C01322E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */; };
D72C01332E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */; };
D72E12782BEED22500F4F781 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; }; D72E12782BEED22500F4F781 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; };
D72E127A2BEEEED000F4F781 /* NostrFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */; }; D72E127A2BEEEED000F4F781 /* NostrFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */; };
D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */; }; D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */; };
@@ -2607,6 +2610,7 @@
D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventViewTests.swift; sourceTree = "<group>"; }; D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventViewTests.swift; sourceTree = "<group>"; };
D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDamusState.swift; sourceTree = "<group>"; }; D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDamusState.swift; sourceTree = "<group>"; };
D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProfiles.swift; sourceTree = "<group>"; }; D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProfiles.swift; sourceTree = "<group>"; };
D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CondensedProfilePicturesViewModel.swift; sourceTree = "<group>"; };
D72E12772BEED22400F4F781 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; }; D72E12772BEED22400F4F781 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrFilterTests.swift; sourceTree = "<group>"; }; D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrFilterTests.swift; sourceTree = "<group>"; };
D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = "<group>"; }; D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = "<group>"; };
@@ -4301,6 +4305,7 @@
5C78A7922E3036F800CF177D /* Models */ = { 5C78A7922E3036F800CF177D /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */,
4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */, 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */,
4C363A912825FCF2006E126D /* ProfileUpdate.swift */, 4C363A912825FCF2006E126D /* ProfileUpdate.swift */,
); );
@@ -5783,6 +5788,7 @@
D706C5AF2D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */, D706C5AF2D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */,
6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */, 6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */,
4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */, 4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */,
D72C01312E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */,
4C1253682A76D2470004F4B8 /* MuteNotify.swift in Sources */, 4C1253682A76D2470004F4B8 /* MuteNotify.swift in Sources */,
4CDA128C29EB19C40006FA5A /* LocalNotification.swift in Sources */, 4CDA128C29EB19C40006FA5A /* LocalNotification.swift in Sources */,
4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */, 4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */,
@@ -6385,6 +6391,7 @@
82D6FC082CD99F7900C925F4 /* ProfileZapLinkView.swift in Sources */, 82D6FC082CD99F7900C925F4 /* ProfileZapLinkView.swift in Sources */,
D71AD8FE2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */, D71AD8FE2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */,
82D6FC092CD99F7900C925F4 /* AboutView.swift in Sources */, 82D6FC092CD99F7900C925F4 /* AboutView.swift in Sources */,
D72C01332E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */,
82D6FC0A2CD99F7900C925F4 /* ProfileName.swift in Sources */, 82D6FC0A2CD99F7900C925F4 /* ProfileName.swift in Sources */,
82D6FC0B2CD99F7900C925F4 /* ProfilePictureSelector.swift in Sources */, 82D6FC0B2CD99F7900C925F4 /* ProfilePictureSelector.swift in Sources */,
82D6FC0C2CD99F7900C925F4 /* EditMetadataView.swift in Sources */, 82D6FC0C2CD99F7900C925F4 /* EditMetadataView.swift in Sources */,
@@ -6940,6 +6947,7 @@
D703D7502C6709F500A400EA /* NdbTxn.swift in Sources */, D703D7502C6709F500A400EA /* NdbTxn.swift in Sources */,
D703D77E2C670C1100A400EA /* NostrKind.swift in Sources */, D703D77E2C670C1100A400EA /* NostrKind.swift in Sources */,
D73E5F972C6AA7B7007EB227 /* SuggestedHashtagsView.swift in Sources */, D73E5F972C6AA7B7007EB227 /* SuggestedHashtagsView.swift in Sources */,
D72C01322E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */,
D703D7B22C6710AF00A400EA /* ContentParsing.swift in Sources */, D703D7B22C6710AF00A400EA /* ContentParsing.swift in Sources */,
D703D7522C670A1400A400EA /* Log.swift in Sources */, D703D7522C670A1400A400EA /* Log.swift in Sources */,
D73E5E1B2C6A9672007EB227 /* LikeCounter.swift in Sources */, D73E5E1B2C6A9672007EB227 /* LikeCounter.swift in Sources */,

View File

@@ -44,6 +44,10 @@ class RelayPool {
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor") private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
private var last_network_status: NWPath.Status = .unsatisfied private var last_network_status: NWPath.Status = .unsatisfied
/// The limit of maximum concurrent subscriptions. Any subscriptions beyond this limit will be paused until subscriptions clear
/// This is to avoid error states and undefined behaviour related to hitting subscription limits on the relays, by letting those wait instead with the principle that slower is better than broken.
static let MAX_CONCURRENT_SUBSCRIPTION_LIMIT = 10 // This number is only an educated guess at this point.
func close() { func close() {
disconnect() disconnect()
relays = [] relays = []
@@ -102,10 +106,17 @@ class RelayPool {
} }
@MainActor @MainActor
func register_handler(sub_id: String, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) { func register_handler(sub_id: String, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) async {
while handlers.count > Self.MAX_CONCURRENT_SUBSCRIPTION_LIMIT {
Log.debug("%s: Too many subscriptions, waiting for subscription pool to clear", for: .networking, sub_id)
try? await Task.sleep(for: .seconds(1))
}
Log.debug("%s: Subscription pool cleared", for: .networking, sub_id)
for handler in handlers { for handler in handlers {
// don't add duplicate handlers // don't add duplicate handlers
if handler.sub_id == sub_id { if handler.sub_id == sub_id {
assertionFailure("Duplicate handlers are not allowed. Proper error handling for this has not been built yet.")
Log.error("Duplicate handlers are not allowed. Error handling for this has not been built yet.", for: .networking)
return return
} }
} }

View File

@@ -142,7 +142,7 @@ struct SaveKeysView: View {
add_rw_relay(self.pool, relay) add_rw_relay(self.pool, relay)
} }
self.pool.register_handler(sub_id: "signup", handler: handle_event) Task { await self.pool.register_handler(sub_id: "signup", handler: handle_event) }
self.loading = true self.loading = true

View File

@@ -0,0 +1,42 @@
//
// CondensedProfilePicturesViewModel.swift
// damus
//
// Created by Daniel DAquino on 2025-09-15.
//
import Combine
import Foundation
class CondensedProfilePicturesViewModel: ObservableObject {
let state: DamusState
let pubkeys: [Pubkey]
let maxPictures: Int
var shownPubkeys: [Pubkey] {
return Array(pubkeys.prefix(maxPictures))
}
var loadingTask: Task<Void, Never>? = nil
init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int) {
self.state = state
self.pubkeys = pubkeys
self.maxPictures = min(maxPictures, pubkeys.count)
}
func load() {
loadingTask?.cancel()
loadingTask = Task { try? await loadingTask() }
}
func loadingTask() async throws {
let filter = NostrFilter(kinds: [.metadata], authors: shownPubkeys)
let _ = await state.nostrNetwork.reader.query(filters: [filter])
for await _ in state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [filter]) {
// NO-OP, we just need it to be loaded into NostrDB.
try Task.checkCancellation()
}
DispatchQueue.main.async {
// Cause the view to re-render with the newly loaded profiles
self.objectWillChange.send()
}
}
}

View File

@@ -8,26 +8,26 @@
import SwiftUI import SwiftUI
struct CondensedProfilePicturesView: View { struct CondensedProfilePicturesView: View {
let state: DamusState let model: CondensedProfilePicturesViewModel
let pubkeys: [Pubkey]
let maxPictures: Int
init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int) { init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int) {
self.state = state self.model = CondensedProfilePicturesViewModel(state: state, pubkeys: pubkeys, maxPictures: maxPictures)
self.pubkeys = pubkeys
self.maxPictures = min(maxPictures, pubkeys.count)
} }
var body: some View { var body: some View {
// Using ZStack to make profile pictures floating and stacked on top of each other. // Using ZStack to make profile pictures floating and stacked on top of each other.
ZStack { ZStack {
ForEach((0..<maxPictures).reversed(), id: \.self) { index in ForEach((0..<model.maxPictures).reversed(), id: \.self) { index in
ProfilePicView(pubkey: pubkeys[index], size: 32.0, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation) ProfilePicView(pubkey: model.pubkeys[index], size: 32.0, highlight: .none, profiles: model.state.profiles, disable_animation: model.state.settings.disable_animation)
.offset(x: CGFloat(index) * 20) .offset(x: CGFloat(index) * 20)
} }
} }
// Padding is needed so that other components drawn adjacent to this view don't get drawn on top. // Padding is needed so that other components drawn adjacent to this view don't get drawn on top.
.padding(.trailing, CGFloat((maxPictures - 1) * 20)) .padding(.trailing, CGFloat((model.maxPictures - 1) * 20))
.onAppear {
self.model.load()
}
} }
} }

View File

@@ -10,9 +10,11 @@ import Foundation
/// The data model for the SearchHome view, typically something global-like /// The data model for the SearchHome view, typically something global-like
class SearchHomeModel: ObservableObject { class SearchHomeModel: ObservableObject {
var events: EventHolder var events: EventHolder
var followPackEvents: EventHolder
@Published var loading: Bool = false @Published var loading: Bool = false
var seen_pubkey: Set<Pubkey> = Set() var seen_pubkey: Set<Pubkey> = Set()
var follow_pack_seen_pubkey: Set<Pubkey> = Set()
let damus_state: DamusState let damus_state: DamusState
let base_subid = UUID().description let base_subid = UUID().description
let follow_pack_subid = UUID().description let follow_pack_subid = UUID().description
@@ -25,6 +27,9 @@ class SearchHomeModel: ObservableObject {
self.events = EventHolder(on_queue: { ev in self.events = EventHolder(on_queue: { ev in
preload_events(state: damus_state, events: [ev]) preload_events(state: damus_state, events: [ev])
}) })
self.followPackEvents = EventHolder(on_queue: { ev in
preload_events(state: damus_state, events: [ev])
})
} }
func get_base_filter() -> NostrFilter { func get_base_filter() -> NostrFilter {
@@ -40,6 +45,12 @@ class SearchHomeModel: ObservableObject {
self.objectWillChange.send() self.objectWillChange.send()
} }
@MainActor
func reload() async {
self.events.reset()
await self.load()
}
func load() async { func load() async {
DispatchQueue.main.async { DispatchQueue.main.async {
self.loading = true self.loading = true
@@ -51,16 +62,23 @@ class SearchHomeModel: ObservableObject {
var follow_list_filter = NostrFilter(kinds: [.follow_list]) var follow_list_filter = NostrFilter(kinds: [.follow_list])
follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970) follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970)
for await noteLender in damus_state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [get_base_filter(), follow_list_filter], to: to_relays) { for await noteLender in damus_state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [follow_list_filter], to: to_relays) {
await noteLender.justUseACopy({ await self.handleFollowPackEvent($0) })
}
for await noteLender in damus_state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [get_base_filter()], to: to_relays) {
await noteLender.justUseACopy({ await self.handleEvent($0) }) await noteLender.justUseACopy({ await self.handleEvent($0) })
} }
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
let allEvents = events.all_events + followPackEvents.all_events
let task = load_profiles(context: "universe", load: .from_events(allEvents), damus_state: damus_state, txn: txn)
try? await task?.value
DispatchQueue.main.async { DispatchQueue.main.async {
self.loading = false self.loading = false
} }
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
load_profiles(context: "universe", load: .from_events(events.all_events), damus_state: damus_state, txn: txn)
} }
@MainActor @MainActor
@@ -76,6 +94,20 @@ class SearchHomeModel: ObservableObject {
} }
} }
} }
@MainActor
func handleFollowPackEvent(_ ev: NostrEvent) {
if ev.known_kind == .follow_list && should_show_event(state: damus_state, ev: ev) && !ev.is_reply() {
if !damus_state.settings.multiple_events_per_pubkey && follow_pack_seen_pubkey.contains(ev.pubkey) {
return
}
follow_pack_seen_pubkey.insert(ev.pubkey)
if self.followPackEvents.insert(ev) {
self.objectWillChange.send()
}
}
}
} }
func find_profiles_to_fetch<Y>(profiles: Profiles, load: PubkeysToLoad, cache: EventCache, txn: NdbTxn<Y>) -> [Pubkey] { func find_profiles_to_fetch<Y>(profiles: Profiles, load: PubkeysToLoad, cache: EventCache, txn: NdbTxn<Y>) -> [Pubkey] {
@@ -113,28 +145,23 @@ enum PubkeysToLoad {
case from_keys([Pubkey]) case from_keys([Pubkey])
} }
func load_profiles<Y>(context: String, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn<Y>) { func load_profiles<Y>(context: String, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn<Y>) -> Task<Void, any Error>? {
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events, txn: txn) let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events, txn: txn)
guard !authors.isEmpty else { guard !authors.isEmpty else {
return return nil
} }
Task { return Task {
print("load_profiles[\(context)]: requesting \(authors.count) profiles from relay pool") print("load_profiles[\(context)]: requesting \(authors.count) profiles from relay pool")
let filter = NostrFilter(kinds: [.metadata], authors: authors) let filter = NostrFilter(kinds: [.metadata], authors: authors)
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter]) { for await noteLender in damus_state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [filter]) {
let now = UInt64(Date.now.timeIntervalSince1970) let now = UInt64(Date.now.timeIntervalSince1970)
switch item { try noteLender.borrow { event in
case .event(let lender):
lender.justUseACopy({ event in
if event.known_kind == .metadata { if event.known_kind == .metadata {
damus_state.ndb.write_profile_last_fetched(pubkey: event.pubkey, fetched_at: now) damus_state.ndb.write_profile_last_fetched(pubkey: event.pubkey, fetched_at: now)
} }
})
case .eose:
break
} }
} }

View File

@@ -54,7 +54,7 @@ struct SearchHomeView: View {
loading: $model.loading, loading: $model.loading,
damus: damus_state, damus: damus_state,
show_friend_icon: true, show_friend_icon: true,
filter:content_filter(FilterState.posts), filter: content_filter(FilterState.posts),
content: { content: {
AnyView(VStack(alignment: .leading) { AnyView(VStack(alignment: .leading) {
HStack { HStack {
@@ -66,7 +66,7 @@ struct SearchHomeView: View {
.padding(.top) .padding(.top)
.padding(.horizontal) .padding(.horizontal)
FollowPackTimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true,filter:content_filter(FilterState.follow_list) FollowPackTimelineView<AnyView>(events: model.followPackEvents, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: content_filter(FilterState.follow_list)
).padding(.bottom) ).padding(.bottom)
Divider() Divider()
@@ -83,20 +83,10 @@ struct SearchHomeView: View {
}.padding(.bottom, 50)) }.padding(.bottom, 50))
} }
) )
.refreshable {
// Fetch new information by unsubscribing and resubscribing to the relay
loadingTask?.cancel()
loadingTask = Task { await model.load() }
}
} }
var SearchContent: some View { var SearchContent: some View {
SearchResultsView(damus_state: damus_state, search: $search) SearchResultsView(damus_state: damus_state, search: $search)
.refreshable {
// Fetch new information by unsubscribing and resubscribing to the relay
loadingTask?.cancel()
loadingTask = Task { await model.load() }
}
} }
var MainContent: some View { var MainContent: some View {
@@ -136,6 +126,12 @@ struct SearchHomeView: View {
.onDisappear { .onDisappear {
loadingTask?.cancel() loadingTask?.cancel()
} }
.refreshable {
// Fetch new information by unsubscribing and resubscribing to the relay
loadingTask?.cancel()
loadingTask = Task { await model.reload() }
try? await loadingTask?.value
}
} }
} }

View File

@@ -95,4 +95,10 @@ class EventHolder: ObservableObject, ScrollQueue {
self.incoming = [] self.incoming = []
} }
@MainActor
func reset() {
self.incoming = []
self.events = []
}
} }