commit 4b9e83efaebf2ee9c18defdd923136eb8d51ed88
parent 22483f23fe277577b089399b1fcac17c3ed3b0d3
Author: e <e@*.lan>
Date: Sun, 28 Dec 2025 09:37:18 -0600
timeline: rebuild filter when contact list changes
Closes #1225
Fixes #1225 - unfollowed accounts still showing up in feed.
The root cause was that timeline filters were built once from the
contact list and never updated when users followed/unfollowed accounts.
Changes:
- Add `contact_list_needs_rebuild()` helper to detect stale filters
- Modify `is_timeline_ready()` to check for contact list changes and
trigger filter rebuild when detected
- Store contact list timestamp when filter is built
- Add `#[profiling::function]` attribute for performance monitoring
The rebuild flow:
1. Detect timestamp mismatch between filter and current contact list
2. Invalidate filter states (reset to NeedsRemote)
3. Clear cached notes from views
4. Reset subscription (properly unsubscribing first)
5. Fall through to normal filter build path
Code follows nevernesting pattern with early returns and extracted
helper function for readability.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: e <e@*.lan>
Signed-off-by: alltheseas
Diffstat:
1 file changed, 57 insertions(+), 10 deletions(-)
diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs
@@ -582,7 +582,7 @@ pub fn merge_sorted_vecs<T: Ord + Copy>(vec1: &[T], vec2: &[T]) -> (Vec<T>, Merg
#[allow(clippy::too_many_arguments)]
pub fn setup_new_timeline(
timeline: &mut Timeline,
- ndb: &Ndb,
+ ndb: &mut Ndb,
txn: &Transaction,
subs: &mut Subscriptions,
pool: &mut RelayPool,
@@ -808,22 +808,64 @@ fn setup_timeline_nostrdb_sub(
Ok(())
}
+/// Check if the contact list has changed since the filter was built.
+///
+/// Returns `Some(timestamp)` if the contact list has a newer timestamp
+/// than when the filter was built, indicating the filter needs rebuilding.
+/// Returns `None` if the filter is up-to-date or this isn't a contact timeline.
+fn contact_list_needs_rebuild(timeline: &Timeline, accounts: &Accounts) -> Option<u64> {
+ if !timeline.kind.is_contacts() {
+ return None;
+ }
+
+ let ContactState::Received {
+ contacts: _,
+ note_key: _,
+ timestamp,
+ } = accounts.get_selected_account().data.contacts.get_state()
+ else {
+ return None;
+ };
+
+ if timeline.filter.contact_list_timestamp == Some(*timestamp) {
+ return None;
+ }
+
+ Some(*timestamp)
+}
+
/// Check our timeline filter and see if we have any filter data ready.
+///
/// Our timelines may require additional data before it is functional. For
/// example, when we have to fetch a contact list before we do the actual
/// following list query.
+///
+/// For contact list timelines, this also detects when the contact list has
+/// changed (e.g., after follow/unfollow) and triggers a filter rebuild.
+#[profiling::function]
pub fn is_timeline_ready(
- ndb: &Ndb,
+ ndb: &mut Ndb,
pool: &mut RelayPool,
note_cache: &mut NoteCache,
timeline: &mut Timeline,
accounts: &Accounts,
unknown_ids: &mut UnknownIds,
) -> bool {
- // TODO: we should debounce the filter states a bit to make sure we have
- // seen all of the different contact lists from each relay
- if let Some(_f) = timeline.filter.get_any_ready() {
- return true;
+ // Check if filter is ready and contact list hasn't changed
+ if timeline.filter.get_any_ready().is_some() {
+ let Some(new_timestamp) = contact_list_needs_rebuild(timeline, accounts) else {
+ return true;
+ };
+
+ // Contact list changed - invalidate and rebuild
+ info!(
+ "Contact list changed (old: {:?}, new: {}), rebuilding timeline filter",
+ timeline.filter.contact_list_timestamp, new_timestamp
+ );
+ timeline.filter.invalidate();
+ timeline.reset_views();
+ timeline.subscription.reset(ndb, pool);
+ // Fall through to rebuild
}
let Some(res) = timeline.filter.get_any_gotremote() else {
@@ -863,12 +905,16 @@ pub fn is_timeline_ready(
let with_hashtags = false;
- let filter = {
+ let (filter, contact_timestamp) = {
let txn = Transaction::new(ndb).expect("txn");
let note = ndb.get_note_by_key(&txn, note_key).expect("note");
let add_pk = timeline.kind.pubkey().map(|pk| pk.bytes());
+ let timestamp = note.created_at();
- hybrid_contacts_filter(¬e, add_pk, with_hashtags)
+ (
+ hybrid_contacts_filter(¬e, add_pk, with_hashtags),
+ timestamp,
+ )
};
// TODO: into_follow_filter is hardcoded to contact lists, let's generalize
@@ -898,8 +944,9 @@ pub fn is_timeline_ready(
.filter
.set_relay_state(relay_id, FilterState::ready_hybrid(filter.clone()));
- //let ck = &timeline.kind;
- //let subid = damus.gen_subid(&SubKind::Column(ck.clone()));
+ // Store timestamp so we can detect when contact list changes
+ timeline.filter.contact_list_timestamp = Some(contact_timestamp);
+
timeline.subscription.try_add_remote(pool, &filter);
true
}