commit 38df08bc2fdf90fff213fe75c182b629f6b73c14
parent 494e0019afbb926ff619c65c9de53edff030074d
Author: William Casarin <jb55@jb55.com>
Date: Mon, 5 Jan 2026 12:40:44 -0800
Merge contact list resubscribing by elsat #1226
William Casarin (3):
filter: fix small abstraction leak
nit: remove mut match
e (3):
filter: add contact_list_timestamp tracking to FilterStates
timeline: add reset methods for subscription and view cleanup
timeline: rebuild filter when contact list changes Closes #1225
Diffstat:
2 files changed, 138 insertions(+), 10 deletions(-)
diff --git a/crates/notedeck_columns/src/multi_subscriber.rs b/crates/notedeck_columns/src/multi_subscriber.rs
@@ -307,6 +307,45 @@ impl Default for TimelineSub {
}
impl TimelineSub {
+ /// Reset the subscription state, properly unsubscribing from ndb and
+ /// relay pool before clearing.
+ ///
+ /// Used when the contact list changes and we need to rebuild the
+ /// timeline with a new filter. Preserves the depender count so that
+ /// shared subscription reference counting remains correct.
+ pub fn reset(&mut self, ndb: &mut Ndb, pool: &mut RelayPool) {
+ let before = self.state.clone();
+
+ let dependers = match &self.state {
+ SubState::NoSub { dependers } => *dependers,
+
+ SubState::LocalOnly { local, dependers } => {
+ if let Err(e) = ndb.unsubscribe(*local) {
+ tracing::error!("TimelineSub::reset: failed to unsubscribe from ndb: {e}");
+ }
+ *dependers
+ }
+
+ SubState::RemoteOnly { remote, dependers } => {
+ pool.unsubscribe(remote.to_owned());
+ *dependers
+ }
+
+ SubState::Unified { unified, dependers } => {
+ pool.unsubscribe(unified.remote.to_owned());
+ if let Err(e) = ndb.unsubscribe(unified.local) {
+ tracing::error!("TimelineSub::reset: failed to unsubscribe from ndb: {e}");
+ }
+ *dependers
+ }
+ };
+
+ self.state = SubState::NoSub { dependers };
+ self.filter = None;
+
+ tracing::debug!("TimelineSub::reset: {:?} => {:?}", before, self.state);
+ }
+
pub fn try_add_local(&mut self, ndb: &Ndb, filter: &HybridFilter) {
let before = self.state.clone();
match &mut self.state {
diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs
@@ -150,6 +150,16 @@ impl TimelineTab {
}
}
+ /// Reset the tab to an empty state, clearing all cached notes.
+ ///
+ /// Used when the contact list changes and we need to rebuild
+ /// the timeline with a new filter.
+ pub fn reset(&mut self) {
+ self.units = TimelineUnits::with_capacity(1000);
+ self.selection = 0;
+ self.list.borrow_mut().reset();
+ }
+
fn insert<'a>(
&mut self,
payloads: Vec<&'a NotePayload>,
@@ -240,6 +250,11 @@ pub struct Timeline {
pub subscription: TimelineSub,
pub enable_front_insert: bool,
+
+ /// Timestamp (`created_at`) of the contact list note used to build
+ /// the current filter. Used to detect when the contact list has
+ /// changed (e.g., after follow/unfollow) so the filter can be rebuilt.
+ pub contact_list_timestamp: Option<u64>,
}
impl Timeline {
@@ -312,6 +327,7 @@ impl Timeline {
selected_view,
enable_front_insert,
seen_latest_notes: false,
+ contact_list_timestamp: None,
}
}
@@ -352,6 +368,16 @@ impl Timeline {
self.views.iter_mut().find(|tab| tab.filter == view)
}
+ /// Reset all views to an empty state, clearing all cached notes.
+ ///
+ /// Used when the contact list changes and we need to rebuild
+ /// the timeline with a new filter.
+ pub fn reset_views(&mut self) {
+ for view in &mut self.views {
+ view.reset();
+ }
+ }
+
/// Initial insert of notes into a timeline. Subsequent inserts should
/// just use the insert function
#[profiling::function]
@@ -500,6 +526,22 @@ impl Timeline {
self.insert(&new_note_ids, ndb, txn, unknown_ids, note_cache, reversed)
}
+
+ /// Invalidate the timeline, forcing a rebuild on the next check.
+ ///
+ /// This resets all relay states to [`FilterState::NeedsRemote`] and
+ /// clears the contact list timestamp, which will trigger the filter
+ /// rebuild flow when the timeline is next polled.
+ ///
+ /// Note: We reset states rather than clearing them so that
+ /// [`Self::set_all_states`] can update them during the rebuild.
+ pub fn invalidate(&mut self) {
+ self.filter.initial_state = FilterState::NeedsRemote;
+ for state in self.filter.states.values_mut() {
+ *state = FilterState::NeedsRemote;
+ }
+ self.contact_list_timestamp = None;
+ }
}
pub struct UnknownPksOwned {
@@ -563,7 +605,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,
@@ -792,22 +834,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.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.contact_list_timestamp, new_timestamp
+ );
+ timeline.invalidate();
+ timeline.reset_views();
+ timeline.subscription.reset(ndb, pool);
+ // Fall through to rebuild
}
let Some(res) = timeline.filter.get_any_gotremote() else {
@@ -847,12 +931,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
@@ -882,8 +970,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.contact_list_timestamp = Some(contact_timestamp);
+
timeline.subscription.try_add_remote(pool, &filter);
true
}