Fix favorites timeline: filter in-place, manage queuing, persist tab selection and sync has_event.

Changelog-None:
Signed-off-by: Askeew <askeew@hotmail.com>
This commit is contained in:
Askeew
2026-03-27 09:10:36 +01:00
parent 69ba4b2c38
commit 75bbae86b3
5 changed files with 78 additions and 10 deletions
+2 -1
View File
@@ -131,6 +131,7 @@ struct ContentView: View {
@State var confirm_overwrite_mutelist: Bool = false
@State private var isSideBarOpened = false
@State var headerOffset: CGFloat = 0.0
@State private var postingTimelineSource: TimelineSource = .follows
var home: HomeModel = HomeModel()
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
@@ -186,7 +187,7 @@ struct ContentView: View {
}
case .home:
PostingTimelineView(damus_state: damus_state!, home: home, homeEvents: home.events, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
PostingTimelineView(damus_state: damus_state!, home: home, homeEvents: home.events, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset, timeline_source: $postingTimelineSource)
case .notifications:
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
@@ -15,18 +15,23 @@ struct FavoriteButtonView: View {
var body: some View {
Button(
action: {
guard let keypair = damus_state.keypair.to_full() else { return }
damus_state.contactCards.toggleFavorite(
pubkey,
postbox: damus_state.nostrNetwork.postbox,
keyPair: damus_state.keypair.to_full()
keyPair: keypair
)
favorite.toggle()
favorite = damus_state.contactCards.isFavorite(pubkey)
}) {
Image(favorite ? "heart.fill" : "heart")
.foregroundColor(favorite ? DamusColors.purple : .primary)
.font(.system(size: 16, weight: .medium))
}
.buttonStyle(PlainButtonStyle())
.onReceive(handle_notify(.favoriteUpdated)) { _ in
favorite = damus_state.contactCards.isFavorite(pubkey)
}
}
}
+61 -4
View File
@@ -83,6 +83,10 @@ class HomeModel: ContactsDelegate, ObservableObject {
var notification_status = NotificationStatusModel()
var events: EventHolder = EventHolder()
var favoriteEvents: EventHolder = EventHolder()
/// Tracks the set of favorited pubkeys from the last `subscribe_to_favorites()` call.
/// Used to detect when new authors are added so we can skip `sinceOptimization`
/// (ndb may lack their history). Cleared when favorites become empty.
private var previousFavorites: Set<Pubkey> = []
var already_reposted: Set<NoteId> = Set()
var zap_button: ZapButtonModel = ZapButtonModel()
@@ -738,7 +742,17 @@ class HomeModel: ContactsDelegate, ObservableObject {
guard damus_state.settings.enable_favourites_feature else { return }
let all_favorites = Array(damus_state.contactCards.favorites)
guard !all_favorites.isEmpty else { return }
// Filter out events from users who are no longer favorites,
// keeping existing events for users who are still favorites.
self.favoriteEvents.filter { all_favorites.contains($0.pubkey) }
refreshFavoriteFilteredHolders()
guard !all_favorites.isEmpty else {
self.favoritesHandlerTask?.cancel()
previousFavorites = []
return
}
var home_filter_kinds: [NostrKind] = [.text, .longform, .boost, .highlight]
if !damus_state.settings.onlyzaps_mode {
@@ -749,24 +763,67 @@ class HomeModel: ContactsDelegate, ObservableObject {
favorites_filter.authors = all_favorites
favorites_filter.limit = 500
// Skip sinceOptimization when new authors were added, since ndb may not have
// their history and the `since` timestamp would be based on other authors' events.
let currentFavorites = Set(all_favorites)
let hasNewAuthors = !currentFavorites.isSubset(of: previousFavorites)
let streamMode: NostrNetworkManager.StreamMode =
hasNewAuthors
? .ndbAndNetworkParallel(networkOptimization: nil)
: .ndbAndNetworkParallel(networkOptimization: .sinceOptimization)
previousFavorites = currentFavorites
// Temporarily disable queuing so the initial batch of events from the
// new subscription goes directly into `events` instead of `incoming`.
// Once the initial load completes (eose), re-enable queuing.
// Capture the original state before any prior call may have disabled it,
// so rapid resubscriptions don't permanently leave queuing off.
let shouldRestoreQueuing = self.favoriteEvents.should_queue
self.favoriteEvents.set_should_queue(false)
self.favoritesHandlerTask?.cancel()
self.favoritesHandlerTask = Task {
for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [favorites_filter], streamMode: .ndbAndNetworkParallel(networkOptimization: .sinceOptimization)) {
for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [favorites_filter], streamMode: streamMode) {
switch item {
case .event(let lender):
await lender.justUseACopy({ await self.insert_favorite_event($0) })
case .eose, .ndbEose, .networkEose:
case .ndbEose:
self.refreshFavoriteFilteredHolders()
case .eose:
self.refreshFavoriteFilteredHolders()
// Only re-enable queuing after network EOSE so the full
// initial load (including network-only events) is visible.
if shouldRestoreQueuing {
self.favoriteEvents.set_should_queue(true)
}
case .networkEose:
break
}
}
}
}
/// Updates all filtered holders with the current favorite events.
@MainActor
private func refreshFavoriteFilteredHolders() {
for (_, filteredHolder) in favoriteEvents.filteredHolders {
filteredHolder.update(events: favoriteEvents.events)
}
}
/// Inserts a favorite event into the holder and mirrors it to the global cache.
/// Rejects events from authors no longer in the favorites list (e.g. in-flight
/// events arriving after a favorite was removed and the stream cancelled).
@MainActor
func insert_favorite_event(_ ev: NostrEvent) {
guard should_show_event(state: damus_state, ev: ev) else { return }
guard damus_state.contactCards.favorites.contains(ev.pubkey) else { return }
// Only add to the global event cache if the event is new to the holder
let inserted = favoriteEvents.insert(ev)
if inserted {
damus_state.events.insert(ev)
favoriteEvents.insert(ev)
}
}
/// Adapter pattern to make migration easier
@@ -28,7 +28,7 @@ struct PostingTimelineView: View {
@State var headerHeight: CGFloat = 0
@Binding var headerOffset: CGFloat
@SceneStorage("PostingTimelineView.filter_state") var filter_state : FilterState = .posts_and_replies
@State var timeline_source: TimelineSource = .follows
@Binding var timeline_source: TimelineSource
@State private var damusTips: Any? = {
if #available(iOS 18.0, *) {
@@ -187,7 +187,8 @@ struct PostingTimelineView_Previews: PreviewProvider {
homeEvents: .init(),
isSideBarOpened: .constant(false),
active_sheet: .constant(nil),
headerOffset: .constant(0)
headerOffset: .constant(0),
timeline_source: .constant(.follows)
)
}
}
+4
View File
@@ -34,9 +34,13 @@ class EventHolder: ObservableObject, ScrollQueue {
self.on_queue = on_queue
}
/// Filters stored events in place and rebuilds dedup state.
/// Note: callers are responsible for refreshing `filteredHolders` afterwards.
func filter(_ isIncluded: (NostrEvent) -> Bool) {
self.events = self.events.filter(isIncluded)
self.incoming = self.incoming.filter(isIncluded)
// Rebuild has_event so removed events can be re-inserted later
self.has_event = Set(self.events.map(\.id) + self.incoming.map(\.id))
}
@MainActor