From 434c54f98ee49921c04fb44a49808a91e1e01a94 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 6 Feb 2026 16:05:29 -0800 Subject: [PATCH] Fix favorites timeline not showing events when switching The favorites timeline was empty because: 1. The @StateObject filter in InnerTimelineView was captured once at init 2. Favorite events were mixed with follows events and got drowned out Fixed by: - Adding viewId parameter to TimelineView to force view recreation on switch - Creating separate favoriteEvents EventHolder for favorites - Adding dedicated subscribe_to_favorites() subscription that inserts directly into favoriteEvents when contact cards are loaded --- damus/ContentView.swift | 2 +- .../Features/Timeline/Models/HomeModel.swift | 52 ++++++++++++++----- .../Timeline/Views/PostingTimelineView.swift | 14 ++--- .../Timeline/Views/TimelineView.swift | 8 ++- 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index d2abcb73..5cac8ad6 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -461,7 +461,7 @@ struct ContentView: View { self.active_full_screen_item = item } .onReceive(handle_notify(.favoriteUpdated)) { _ in - home.resubscribe(.following) + home.subscribe_to_favorites() } .onReceive(handle_notify(.zapping)) { zap_ev in guard !zap_ev.is_custom else { diff --git a/damus/Features/Timeline/Models/HomeModel.swift b/damus/Features/Timeline/Models/HomeModel.swift index 84c6ed27..86f25fa7 100644 --- a/damus/Features/Timeline/Models/HomeModel.swift +++ b/damus/Features/Timeline/Models/HomeModel.swift @@ -68,6 +68,7 @@ class HomeModel: ContactsDelegate, ObservableObject { var should_debounce_dms = true var homeHandlerTask: Task? + var favoritesHandlerTask: Task? var notificationsHandlerTask: Task? var generalHandlerTask: Task? var dmsHandlerTask: Task? @@ -81,6 +82,7 @@ class HomeModel: ContactsDelegate, ObservableObject { var notifications = NotificationsModel() var notification_status = NotificationStatusModel() var events: EventHolder = EventHolder() + var favoriteEvents: EventHolder = EventHolder() var already_reposted: Set = Set() var zap_button: ZapButtonModel = ZapButtonModel() @@ -692,18 +694,6 @@ class HomeModel: ContactsDelegate, ObservableObject { home_filters.append(hashtag_filter) } - // Add filter for favorited users who we dont follow - if damus_state.settings.enable_favourites_feature { - let all_favorites = damus_state.contactCards.favorites - let favorited_not_followed = Array(all_favorites.subtracting(Set(friends))) - if !favorited_not_followed.isEmpty { - var favorites_filter = NostrFilter(kinds: home_filter_kinds) - favorites_filter.authors = favorited_not_followed - favorites_filter.limit = 500 - home_filters.append(favorites_filter) - } - } - self.homeHandlerTask?.cancel() self.homeHandlerTask = Task { let startTime = CFAbsoluteTimeGetCurrent() @@ -742,7 +732,43 @@ class HomeModel: ContactsDelegate, ObservableObject { } } } - + + /// Subscribe to favorites - called when contact cards are loaded or favorites change + func subscribe_to_favorites() { + guard damus_state.settings.enable_favourites_feature else { return } + + let all_favorites = Array(damus_state.contactCards.favorites) + guard !all_favorites.isEmpty else { return } + + var home_filter_kinds: [NostrKind] = [.text, .longform, .boost, .highlight] + if !damus_state.settings.onlyzaps_mode { + home_filter_kinds.append(.like) + } + + var favorites_filter = NostrFilter(kinds: home_filter_kinds) + favorites_filter.authors = all_favorites + favorites_filter.limit = 500 + + self.favoritesHandlerTask?.cancel() + self.favoritesHandlerTask = Task { + for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [favorites_filter], streamMode: .ndbAndNetworkParallel(networkOptimization: .sinceOptimization)) { + switch item { + case .event(let lender): + await lender.justUseACopy({ await self.insert_favorite_event($0) }) + case .eose, .ndbEose, .networkEose: + break + } + } + } + } + + @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) + } + /// Adapter pattern to make migration easier enum SubscriptionContext { case home diff --git a/damus/Features/Timeline/Views/PostingTimelineView.swift b/damus/Features/Timeline/Views/PostingTimelineView.swift index 37a8cc67..f4fbb411 100644 --- a/damus/Features/Timeline/Views/PostingTimelineView.swift +++ b/damus/Features/Timeline/Views/PostingTimelineView.swift @@ -52,21 +52,21 @@ struct PostingTimelineView: View { func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) { var filters = ContentFilters.defaults(damus_state: damus_state) filters.append(fstate.filter) - + // If favourites feature is disabled, always use follows let sourceToUse = damus_state.settings.enable_favourites_feature ? timeline_source : .follows - - switch sourceToUse { - case .follows: + + // Only apply friend_filter for follows timeline + // Favorites timeline uses a dedicated EventHolder (favoriteEvents) that already contains only favorited users' events + if sourceToUse == .follows { filters.append(damus_state.contacts.friend_filter) - case .favorites: - filters.append(damus_state.contactCards.filter) } return ContentFilters(filters: filters).filter } func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View { - TimelineView(events: home.events, loading: self.loading, headerHeight: $headerHeight, headerOffset: $headerOffset, damus: damus_state, show_friend_icon: false, filter: filter) + let eventsSource = timeline_source == .favorites ? home.favoriteEvents : home.events + return TimelineView(events: eventsSource, loading: self.loading, headerHeight: $headerHeight, headerOffset: $headerOffset, damus: damus_state, show_friend_icon: false, filter: filter, viewId: timeline_source) } func HeaderView() -> some View { diff --git a/damus/Features/Timeline/Views/TimelineView.swift b/damus/Features/Timeline/Views/TimelineView.swift index 93559025..921de667 100644 --- a/damus/Features/Timeline/Views/TimelineView.swift +++ b/damus/Features/Timeline/Views/TimelineView.swift @@ -21,8 +21,9 @@ struct TimelineView: View { let filter: (NostrEvent) -> Bool let content: Content? let apply_mute_rules: Bool + let viewId: AnyHashable? - init(events: EventHolder, loading: Binding, headerHeight: Binding, headerOffset: Binding, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) { + init(events: EventHolder, loading: Binding, headerHeight: Binding, headerOffset: Binding, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, viewId: AnyHashable? = nil, content: (() -> Content)? = nil) { self.events = events self._loading = loading self._headerHeight = headerHeight @@ -31,10 +32,11 @@ struct TimelineView: View { self.show_friend_icon = show_friend_icon self.filter = filter self.apply_mute_rules = apply_mute_rules + self.viewId = viewId self.content = content?() } - init(events: EventHolder, loading: Binding, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) { + init(events: EventHolder, loading: Binding, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, viewId: AnyHashable? = nil, content: (() -> Content)? = nil) { self.events = events self._loading = loading self._headerHeight = .constant(0.0) @@ -43,6 +45,7 @@ struct TimelineView: View { self.show_friend_icon = show_friend_icon self.filter = filter self.apply_mute_rules = apply_mute_rules + self.viewId = viewId self.content = content?() } @@ -71,6 +74,7 @@ struct TimelineView: View { .frame(height: 0) InnerTimelineView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules) + .id(viewId) .redacted(reason: loading ? .placeholder : []) .shimmer(loading) .disabled(loading)