notedeck

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

presence.rs (13042B)


      1 //! Coarse presence via nostr events (kind 10555).
      2 //!
      3 //! Publishes only on meaningful position change (with 1s minimum gap),
      4 //! plus a keep-alive heartbeat every 60s to maintain room presence.
      5 //! Not intended for smooth real-time movement sync.
      6 
      7 use enostr::{FilledKeypair, Pubkey};
      8 use glam::Vec3;
      9 use nostrdb::Ndb;
     10 
     11 use crate::{nostr_events, room_state::RoomUser, subscriptions::PresenceSubscription};
     12 
     13 /// Minimum position change (distance) to trigger a publish.
     14 const POSITION_THRESHOLD: f32 = 0.5;
     15 
     16 /// Minimum seconds between publishes even when moving.
     17 const MIN_PUBLISH_GAP: f64 = 1.0;
     18 
     19 /// Keep-alive interval: publish even when idle to stay visible.
     20 const KEEPALIVE_INTERVAL: f64 = 60.0;
     21 
     22 /// Seconds without a heartbeat before a remote user is considered gone.
     23 const STALE_TIMEOUT: f64 = 90.0;
     24 
     25 /// How often to check for stale users (seconds).
     26 const EXPIRY_CHECK_INTERVAL: f64 = 10.0;
     27 
     28 /// Minimum speed to consider "moving" (units/s). Below this, velocity is zeroed.
     29 const MIN_SPEED: f32 = 0.1;
     30 
     31 /// Direction change threshold (dot product). cos(30°) ≈ 0.866.
     32 /// If the normalized velocity direction changes by more than ~30°, publish.
     33 const DIRECTION_CHANGE_THRESHOLD: f32 = 0.866;
     34 
     35 /// Publishes local user presence as kind 10555 events.
     36 ///
     37 /// Only publishes when position or velocity changes meaningfully, plus periodic
     38 /// keep-alive to maintain room presence. Includes velocity for dead reckoning.
     39 pub struct PresencePublisher {
     40     /// Last position we published
     41     last_position: Vec3,
     42     /// Last velocity we published
     43     last_velocity: Vec3,
     44     /// Monotonic time of last publish
     45     last_publish_time: f64,
     46     /// Previous position sample (for computing velocity)
     47     prev_position: Vec3,
     48     /// Time of previous position sample
     49     prev_position_time: f64,
     50     /// Whether we've published at least once
     51     published_once: bool,
     52 }
     53 
     54 impl PresencePublisher {
     55     pub fn new() -> Self {
     56         Self {
     57             last_position: Vec3::ZERO,
     58             last_velocity: Vec3::ZERO,
     59             last_publish_time: 0.0,
     60             prev_position: Vec3::ZERO,
     61             prev_position_time: 0.0,
     62             published_once: false,
     63         }
     64     }
     65 
     66     /// Compute instantaneous velocity from position samples.
     67     fn compute_velocity(&self, position: Vec3, now: f64) -> Vec3 {
     68         let dt = now - self.prev_position_time;
     69         if dt < 0.01 {
     70             return self.last_velocity;
     71         }
     72         let vel = (position - self.prev_position) / dt as f32;
     73         if vel.length() < MIN_SPEED {
     74             Vec3::ZERO
     75         } else {
     76             vel
     77         }
     78     }
     79 
     80     /// Check whether a publish should happen (without side effects).
     81     fn should_publish(&self, position: Vec3, velocity: Vec3, now: f64) -> bool {
     82         // Always publish the first time
     83         if !self.published_once {
     84             return true;
     85         }
     86 
     87         let elapsed = now - self.last_publish_time;
     88 
     89         // Rate limit: never more than once per second
     90         if elapsed < MIN_PUBLISH_GAP {
     91             return false;
     92         }
     93 
     94         // Publish if position changed meaningfully
     95         if self.last_position.distance(position) > POSITION_THRESHOLD {
     96             return true;
     97         }
     98 
     99         // Publish on start/stop transitions
    100         let was_moving = self.last_velocity.length() > MIN_SPEED;
    101         let is_moving = velocity.length() > MIN_SPEED;
    102         if was_moving != is_moving {
    103             return true;
    104         }
    105 
    106         // Publish on significant direction change while moving
    107         if was_moving && is_moving {
    108             let old_dir = self.last_velocity.normalize();
    109             let new_dir = velocity.normalize();
    110             if old_dir.dot(new_dir) < DIRECTION_CHANGE_THRESHOLD {
    111                 return true;
    112             }
    113         }
    114 
    115         // Keep-alive: publish periodically even when idle
    116         elapsed >= KEEPALIVE_INTERVAL
    117     }
    118 
    119     /// Record that a publish happened (update internal state).
    120     fn record_publish(&mut self, position: Vec3, velocity: Vec3, now: f64) {
    121         self.last_position = position;
    122         self.last_velocity = velocity;
    123         self.last_publish_time = now;
    124         self.published_once = true;
    125     }
    126 
    127     /// Maybe publish a presence heartbeat.
    128     ///
    129     /// Returns the ingested note if published so the caller can forward it.
    130     pub fn maybe_publish(
    131         &mut self,
    132         ndb: &Ndb,
    133         kp: FilledKeypair,
    134         room_naddr: &str,
    135         position: Vec3,
    136         now: f64,
    137     ) -> Option<nostrdb::Note<'static>> {
    138         let velocity = self.compute_velocity(position, now);
    139 
    140         // Always update position sample for velocity computation
    141         self.prev_position = position;
    142         self.prev_position_time = now;
    143 
    144         if !self.should_publish(position, velocity, now) {
    145             return None;
    146         }
    147 
    148         let builder = nostr_events::build_presence_event(room_naddr, position, velocity);
    149         let result = nostr_events::ingest_event(builder, ndb, kp);
    150 
    151         self.record_publish(position, velocity, now);
    152         result
    153     }
    154 }
    155 
    156 /// Poll for presence events and update the user list.
    157 ///
    158 /// Returns true if any users were added or updated.
    159 pub fn poll_presence(
    160     sub: &PresenceSubscription,
    161     ndb: &Ndb,
    162     room_naddr: &str,
    163     self_pubkey: &Pubkey,
    164     users: &mut Vec<RoomUser>,
    165     now: f64,
    166 ) -> bool {
    167     let txn = nostrdb::Transaction::new(ndb).expect("txn");
    168     let notes = sub.poll(ndb, &txn);
    169     let mut changed = false;
    170 
    171     for note in &notes {
    172         // Filter to our space
    173         let Some(event_space) = nostr_events::get_presence_space(note) else {
    174             continue;
    175         };
    176         if event_space != room_naddr {
    177             continue;
    178         }
    179 
    180         let Some(position) = nostr_events::parse_presence_position(note) else {
    181             continue;
    182         };
    183 
    184         let pubkey = Pubkey::new(*note.pubkey());
    185 
    186         // Skip our own presence events
    187         if &pubkey == self_pubkey {
    188             continue;
    189         }
    190 
    191         let velocity = nostr_events::parse_presence_velocity(note);
    192         let created_at = note.created_at();
    193 
    194         // Update or insert user
    195         if let Some(user) = users.iter_mut().find(|u| u.pubkey == pubkey) {
    196             // Skip stale events — replaceable events can arrive out of order
    197             if created_at <= user.event_created_at {
    198                 continue;
    199             }
    200             // Update authoritative state; preserve display_position for smooth lerp
    201             user.position = position;
    202             user.velocity = velocity;
    203             user.update_time = now;
    204             user.last_seen = now;
    205             user.event_created_at = created_at;
    206         } else {
    207             let mut user = RoomUser::new(pubkey, "anon".to_string(), position);
    208             user.velocity = velocity;
    209             user.display_position = position; // snap on first appearance
    210             user.update_time = now;
    211             user.last_seen = now;
    212             user.event_created_at = created_at;
    213             users.push(user);
    214         }
    215         changed = true;
    216     }
    217 
    218     changed
    219 }
    220 
    221 /// Remove users who haven't sent a heartbeat recently.
    222 /// Throttled to only run every EXPIRY_CHECK_INTERVAL seconds.
    223 pub struct PresenceExpiry {
    224     last_check: f64,
    225 }
    226 
    227 impl PresenceExpiry {
    228     pub fn new() -> Self {
    229         Self { last_check: 0.0 }
    230     }
    231 
    232     /// Maybe expire stale users. Returns removed users so callers can clean up
    233     /// their scene objects. Empty if the check was throttled.
    234     pub fn maybe_expire(&mut self, users: &mut Vec<RoomUser>, now: f64) -> Vec<RoomUser> {
    235         if now - self.last_check < EXPIRY_CHECK_INTERVAL {
    236             return Vec::new();
    237         }
    238         self.last_check = now;
    239         let mut expired = Vec::new();
    240         let mut i = 0;
    241         while i < users.len() {
    242             if !users[i].is_self && (now - users[i].last_seen) >= STALE_TIMEOUT {
    243                 expired.push(users.swap_remove(i));
    244             } else {
    245                 i += 1;
    246             }
    247         }
    248         expired
    249     }
    250 }
    251 
    252 #[cfg(test)]
    253 mod tests {
    254     use super::*;
    255 
    256     #[test]
    257     fn test_expiry_throttle_and_cleanup() {
    258         let pk1 = Pubkey::new([1; 32]);
    259         let pk2 = Pubkey::new([2; 32]);
    260         let pk_self = Pubkey::new([3; 32]);
    261 
    262         let mut users = vec![
    263             {
    264                 let mut u = RoomUser::new(pk_self, "me".to_string(), Vec3::ZERO);
    265                 u.is_self = true;
    266                 u.last_seen = 0.0; // stale but self — should survive
    267                 u
    268             },
    269             {
    270                 let mut u = RoomUser::new(pk1, "alice".to_string(), Vec3::ZERO);
    271                 u.last_seen = 80.0; // fresh (within 90s timeout)
    272                 u
    273             },
    274             {
    275                 let mut u = RoomUser::new(pk2, "bob".to_string(), Vec3::ZERO);
    276                 u.last_seen = 1.0; // stale (>90s ago)
    277                 u
    278             },
    279         ];
    280 
    281         let mut expiry = PresenceExpiry::new();
    282 
    283         // First call at t=5 — too soon (< 10s from init at 0.0), skipped
    284         assert!(expiry.maybe_expire(&mut users, 5.0).is_empty());
    285         assert_eq!(users.len(), 3); // no one removed
    286 
    287         // At t=100 — enough time, bob is stale
    288         let expired = expiry.maybe_expire(&mut users, 100.0);
    289         assert_eq!(expired.len(), 1);
    290         assert_eq!(expired[0].display_name, "bob");
    291         assert_eq!(users.len(), 2);
    292         assert!(users.iter().any(|u| u.is_self));
    293         assert!(users.iter().any(|u| u.display_name == "alice"));
    294 
    295         // Immediately again at t=101 — throttled, skipped
    296         assert!(expiry.maybe_expire(&mut users, 101.0).is_empty());
    297     }
    298 
    299     #[test]
    300     fn test_publisher_first_publish() {
    301         let pub_ = PresencePublisher::new();
    302         // First publish should always happen
    303         assert!(pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 0.0));
    304     }
    305 
    306     #[test]
    307     fn test_publisher_no_spam_when_idle() {
    308         let mut pub_ = PresencePublisher::new();
    309         pub_.record_publish(Vec3::ZERO, Vec3::ZERO, 0.0);
    310 
    311         // Idle at same position — should NOT publish at 1s, 5s, 10s, 30s
    312         assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 1.0));
    313         assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 5.0));
    314         assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 10.0));
    315         assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 30.0));
    316 
    317         // Keep-alive triggers at 60s
    318         assert!(pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 60.1));
    319     }
    320 
    321     #[test]
    322     fn test_publisher_on_movement() {
    323         let mut pub_ = PresencePublisher::new();
    324         pub_.record_publish(Vec3::ZERO, Vec3::ZERO, 0.0);
    325 
    326         // Small movement below threshold — no publish
    327         assert!(!pub_.should_publish(Vec3::new(0.1, 0.0, 0.0), Vec3::ZERO, 2.0));
    328 
    329         // Significant movement — publish
    330         assert!(pub_.should_publish(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, 2.0));
    331 
    332         // But rate limited: can't publish again within 1s
    333         pub_.record_publish(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, 2.0);
    334         assert!(!pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, 2.5));
    335 
    336         // After 1s gap, can publish again
    337         assert!(pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, 3.1));
    338     }
    339 
    340     #[test]
    341     fn test_publisher_velocity_start_stop() {
    342         let mut pub_ = PresencePublisher::new();
    343         let pos = Vec3::new(1.0, 0.0, 0.0);
    344         pub_.record_publish(pos, Vec3::ZERO, 0.0);
    345 
    346         // Start moving — should trigger (velocity went from zero to non-zero)
    347         let vel = Vec3::new(3.0, 0.0, 0.0);
    348         assert!(pub_.should_publish(pos, vel, 2.0));
    349         pub_.record_publish(pos, vel, 2.0);
    350 
    351         // Stop moving — should trigger (velocity went from non-zero to zero)
    352         assert!(pub_.should_publish(pos, Vec3::ZERO, 3.5));
    353     }
    354 
    355     #[test]
    356     fn test_publisher_velocity_direction_change() {
    357         let mut pub_ = PresencePublisher::new();
    358         let pos = Vec3::new(1.0, 0.0, 0.0);
    359         let vel_east = Vec3::new(3.0, 0.0, 0.0);
    360         pub_.record_publish(pos, vel_east, 0.0);
    361 
    362         // Small direction change (still mostly east) — no publish
    363         let vel_slight = Vec3::new(3.0, 0.0, 0.5);
    364         assert!(!pub_.should_publish(pos, vel_slight, 2.0));
    365 
    366         // Large direction change (east → north, 90 degrees) — should publish
    367         let vel_north = Vec3::new(0.0, 0.0, 3.0);
    368         assert!(pub_.should_publish(pos, vel_north, 2.0));
    369     }
    370 
    371     #[test]
    372     fn test_compute_velocity() {
    373         let mut pub_ = PresencePublisher::new();
    374         pub_.prev_position = Vec3::ZERO;
    375         pub_.prev_position_time = 0.0;
    376 
    377         // 5 units in 1 second = 5 units/s
    378         let vel = pub_.compute_velocity(Vec3::new(5.0, 0.0, 0.0), 1.0);
    379         assert!((vel.x - 5.0).abs() < 0.01);
    380 
    381         // Very small movement → zeroed (below MIN_SPEED)
    382         pub_.prev_position = Vec3::ZERO;
    383         pub_.prev_position_time = 0.0;
    384         let vel = pub_.compute_velocity(Vec3::new(0.01, 0.0, 0.0), 1.0);
    385         assert_eq!(vel, Vec3::ZERO);
    386     }
    387 }