From 75bbae86b372f5538e208cff651c71b9b1ad973d Mon Sep 17 00:00:00 2001 From: Askeew Date: Thu, 19 Mar 2026 16:36:31 +0100 Subject: [PATCH] Fix favorites timeline: filter in-place, manage queuing, persist tab selection and sync has_event. Changelog-None: Signed-off-by: Askeew --- damus/ContentView.swift | 3 +- .../Views/FavoriteButtonView.swift | 9 ++- .../Features/Timeline/Models/HomeModel.swift | 67 +++++++++++++++++-- .../Timeline/Views/PostingTimelineView.swift | 5 +- damus/Shared/Utilities/EventHolder.swift | 4 ++ 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index af9d2677..d1df5be4 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -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) diff --git a/damus/Features/ContactCard/Views/FavoriteButtonView.swift b/damus/Features/ContactCard/Views/FavoriteButtonView.swift index 32f52a5f..873dac55 100644 --- a/damus/Features/ContactCard/Views/FavoriteButtonView.swift +++ b/damus/Features/ContactCard/Views/FavoriteButtonView.swift @@ -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) + } } } diff --git a/damus/Features/Timeline/Models/HomeModel.swift b/damus/Features/Timeline/Models/HomeModel.swift index 86f25fa7..fade572b 100644 --- a/damus/Features/Timeline/Models/HomeModel.swift +++ b/damus/Features/Timeline/Models/HomeModel.swift @@ -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 = [] var already_reposted: Set = 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 } - damus_state.events.insert(ev) - favoriteEvents.insert(ev) + 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) + } } /// Adapter pattern to make migration easier diff --git a/damus/Features/Timeline/Views/PostingTimelineView.swift b/damus/Features/Timeline/Views/PostingTimelineView.swift index c42acfa9..dc0741e9 100644 --- a/damus/Features/Timeline/Views/PostingTimelineView.swift +++ b/damus/Features/Timeline/Views/PostingTimelineView.swift @@ -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) ) } } diff --git a/damus/Shared/Utilities/EventHolder.swift b/damus/Shared/Utilities/EventHolder.swift index b6fd34f6..a8f2f701 100644 --- a/damus/Shared/Utilities/EventHolder.swift +++ b/damus/Shared/Utilities/EventHolder.swift @@ -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