notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 364b84de718e4fe30c33276a9afb09cec7d2c0ec
parent f76da4773e072bb7e14cb6f6a077512352d7753c
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 27 Feb 2026 13:56:28 -0800

nostrverse: clean up scene objects when expiring stale users

maybe_expire now returns the removed RoomUsers so callers can
remove their avatar scene objects, preventing lingering 3D models.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_nostrverse/src/lib.rs | 15++++++++++++---
Mcrates/notedeck_nostrverse/src/presence.rs | 29+++++++++++++++++++----------
2 files changed, 31 insertions(+), 13 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -563,11 +563,20 @@ impl NostrverseApp { } // Expire stale remote users (throttled to every ~10s) - let removed = self + let expired = self .presence_expiry .maybe_expire(&mut self.state.users, now); - if removed > 0 { - tracing::info!("Expired {} stale users", removed); + if !expired.is_empty() { + tracing::info!("Expired {} stale users", expired.len()); + // Clean up scene objects so avatars don't linger in the 3D scene + if let Some(renderer) = &self.renderer { + let mut r = renderer.renderer.lock().unwrap(); + for user in &expired { + if let Some(scene_id) = user.scene_object_id { + r.remove_object(scene_id); + } + } + } } } diff --git a/crates/notedeck_nostrverse/src/presence.rs b/crates/notedeck_nostrverse/src/presence.rs @@ -222,15 +222,23 @@ impl PresenceExpiry { Self { last_check: 0.0 } } - /// Maybe expire stale users. Returns the number removed (0 if check was skipped). - pub fn maybe_expire(&mut self, users: &mut Vec<RoomUser>, now: f64) -> usize { + /// Maybe expire stale users. Returns removed users so callers can clean up + /// their scene objects. Empty if the check was throttled. + pub fn maybe_expire(&mut self, users: &mut Vec<RoomUser>, now: f64) -> Vec<RoomUser> { if now - self.last_check < EXPIRY_CHECK_INTERVAL { - return 0; + return Vec::new(); } self.last_check = now; - let before = users.len(); - users.retain(|u| u.is_self || (now - u.last_seen) < STALE_TIMEOUT); - before - users.len() + let mut expired = Vec::new(); + let mut i = 0; + while i < users.len() { + if !users[i].is_self && (now - users[i].last_seen) >= STALE_TIMEOUT { + expired.push(users.swap_remove(i)); + } else { + i += 1; + } + } + expired } } @@ -266,18 +274,19 @@ mod tests { let mut expiry = PresenceExpiry::new(); // First call at t=5 — too soon (< 10s from init at 0.0), skipped - assert_eq!(expiry.maybe_expire(&mut users, 5.0), 0); + assert!(expiry.maybe_expire(&mut users, 5.0).is_empty()); assert_eq!(users.len(), 3); // no one removed // At t=100 — enough time, bob is stale - let removed = expiry.maybe_expire(&mut users, 100.0); - assert_eq!(removed, 1); + let expired = expiry.maybe_expire(&mut users, 100.0); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].display_name, "bob"); assert_eq!(users.len(), 2); assert!(users.iter().any(|u| u.is_self)); assert!(users.iter().any(|u| u.display_name == "alice")); // Immediately again at t=101 — throttled, skipped - assert_eq!(expiry.maybe_expire(&mut users, 101.0), 0); + assert!(expiry.maybe_expire(&mut users, 101.0).is_empty()); } #[test]