notedeck

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

commit dd985a1b948646e73a08b576c411bc5d4d01f45d
parent 26d2b8166e9f19c765ece97c5a3a560fa846f950
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 23 Feb 2026 11:15:30 -0800

nostrverse: add presence, structured locations, and AABB position resolver

- Add coarse presence via kind 10555 nostr events with NIP-40 expiration
  tags, change-based publishing (>0.5m threshold), and 60s keepalive
- Add structured Location enum to protoverse (top-of, near, floor, etc.)
  replacing the old Location(String) with proper relational positioning
- Replace hardcoded obj1/obj2 AABB stacking with general location resolver
  that computes positions from model bounds at load time
- Always emit position attribute in build_space (fixes round-trip data loss)

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

Diffstat:
Mcrates/notedeck_nostrverse/src/convert.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/notedeck_nostrverse/src/lib.rs | 167++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_nostrverse/src/nostr_events.rs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acrates/notedeck_nostrverse/src/presence.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_nostrverse/src/room_state.rs | 25+++++++++++++++++++++++++
Mcrates/notedeck_nostrverse/src/room_view.rs | 4++--
Mcrates/notedeck_nostrverse/src/subscriptions.rs | 23+++++++++++++++++++++++
Mcrates/protoverse/src/ast.rs | 39++++++++++++++++++++++++++++++++++++++-
Mcrates/protoverse/src/parser.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/protoverse/src/serializer.rs | 4++--
10 files changed, 756 insertions(+), 44 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs @@ -1,8 +1,8 @@ //! Convert protoverse Space AST to renderer room state. -use crate::room_state::{Room, RoomObject, RoomObjectType, RoomShape}; +use crate::room_state::{ObjectLocation, Room, RoomObject, RoomObjectType, RoomShape}; use glam::Vec3; -use protoverse::{Attribute, Cell, CellId, CellType, ObjectType, Shape, Space}; +use protoverse::{Attribute, Cell, CellId, CellType, Location, ObjectType, Shape, Space}; /// Convert a parsed protoverse Space into a Room and its objects. pub fn convert_space(space: &Space) -> (Room, Vec<RoomObject>) { @@ -34,6 +34,28 @@ fn extract_room(space: &Space, id: CellId) -> Room { } } +fn location_from_protoverse(loc: &Location) -> ObjectLocation { + match loc { + Location::Center => ObjectLocation::Center, + Location::Floor => ObjectLocation::Floor, + Location::Ceiling => ObjectLocation::Ceiling, + Location::TopOf(id) => ObjectLocation::TopOf(id.clone()), + Location::Near(id) => ObjectLocation::Near(id.clone()), + Location::Custom(s) => ObjectLocation::Custom(s.clone()), + } +} + +fn location_to_protoverse(loc: &ObjectLocation) -> Location { + match loc { + ObjectLocation::Center => Location::Center, + ObjectLocation::Floor => Location::Floor, + ObjectLocation::Ceiling => Location::Ceiling, + ObjectLocation::TopOf(id) => Location::TopOf(id.clone()), + ObjectLocation::Near(id) => Location::Near(id.clone()), + ObjectLocation::Custom(s) => Location::Custom(s.clone()), + } +} + fn object_type_from_cell(obj_type: &ObjectType) -> RoomObjectType { match obj_type { ObjectType::Table => RoomObjectType::Table, @@ -69,12 +91,16 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) { .unwrap_or(Vec3::ZERO); let model_url = space.model_url(id).map(|s| s.to_string()); + let location = space.location(id).map(location_from_protoverse); let mut obj = RoomObject::new(obj_id, name, position) .with_object_type(object_type_from_cell(obj_type)); if let Some(url) = model_url { obj = obj.with_model_url(url); } + if let Some(loc) = location { + obj = obj.with_location(loc); + } objects.push(obj); } @@ -140,14 +166,15 @@ pub fn build_space(room: &Room, objects: &[RoomObject]) -> Space { if let Some(url) = &obj.model_url { attributes.push(Attribute::ModelUrl(url.clone())); } - let pos = obj.position; - if pos != Vec3::ZERO { - attributes.push(Attribute::Position( - pos.x as f64, - pos.y as f64, - pos.z as f64, - )); + if let Some(loc) = &obj.location { + attributes.push(Attribute::Location(location_to_protoverse(loc))); } + let pos = obj.position; + attributes.push(Attribute::Position( + pos.x as f64, + pos.y as f64, + pos.z as f64, + )); let obj_attr_count = (attributes.len() as u32 - obj_attr_start) as u16; let obj_type = CellType::Object(match &obj.object_type { @@ -302,4 +329,76 @@ mod tests { assert_eq!(room.depth, 10.0); assert!(objects.is_empty()); } + + #[test] + fn test_convert_location_top_of() { + let space = parse( + r#"(room (group + (table (id obj1) (name "Table") (position 0 0 0)) + (prop (id obj2) (name "Bottle") (location top-of obj1))))"#, + ) + .unwrap(); + + let (_, objects) = convert_space(&space); + assert_eq!(objects.len(), 2); + assert_eq!(objects[0].location, None); + assert_eq!( + objects[1].location, + Some(ObjectLocation::TopOf("obj1".to_string())) + ); + } + + #[test] + fn test_build_space_always_emits_position() { + let room = Room { + name: "Test".to_string(), + shape: RoomShape::Rectangle, + width: 10.0, + height: 10.0, + depth: 10.0, + }; + let objects = vec![RoomObject::new( + "a".to_string(), + "Thing".to_string(), + Vec3::ZERO, + )]; + + let space = build_space(&room, &objects); + let serialized = protoverse::serialize(&space); + + // Position should appear even for Vec3::ZERO + assert!(serialized.contains("(position 0 0 0)")); + } + + #[test] + fn test_build_space_location_roundtrip() { + let room = Room { + name: "Test".to_string(), + shape: RoomShape::Rectangle, + width: 10.0, + height: 10.0, + depth: 10.0, + }; + let objects = vec![ + RoomObject::new("obj1".to_string(), "Table".to_string(), Vec3::ZERO) + .with_object_type(RoomObjectType::Table), + RoomObject::new( + "obj2".to_string(), + "Bottle".to_string(), + Vec3::new(0.0, 1.5, 0.0), + ) + .with_location(ObjectLocation::TopOf("obj1".to_string())), + ]; + + let space = build_space(&room, &objects); + let serialized = protoverse::serialize(&space); + let reparsed = parse(&serialized).unwrap(); + let (_, objects2) = convert_space(&reparsed); + + assert_eq!( + objects2[1].location, + Some(ObjectLocation::TopOf("obj1".to_string())) + ); + assert_eq!(objects2[1].position, Vec3::new(0.0, 1.5, 0.0)); + } } diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -8,6 +8,7 @@ mod convert; mod nostr_events; +mod presence; mod room_state; mod room_view; mod subscriptions; @@ -47,7 +48,8 @@ const DEMO_SPACE: &str = r#"(room (name "Demo Room") (shape rectangle) (width 20 (model-url "/home/jb55/var/models/ironwood/ironwood.glb") (position 0 0 0)) (prop (id obj2) (name "Water Bottle") - (model-url "/home/jb55/var/models/WaterBottle.glb"))))"#; + (model-url "/home/jb55/var/models/WaterBottle.glb") + (location top-of obj1))))"#; /// Event kinds for nostrverse pub mod kinds { @@ -75,6 +77,16 @@ pub struct NostrverseApp { avatar_bounds: Option<renderbud::Aabb>, /// Local nostrdb subscription for room events room_sub: Option<subscriptions::RoomSubscription>, + /// Presence publisher (throttled heartbeats) + presence_pub: presence::PresencePublisher, + /// Presence expiry (throttled stale-user cleanup) + presence_expiry: presence::PresenceExpiry, + /// Local nostrdb subscription for presence events + presence_sub: Option<subscriptions::PresenceSubscription>, + /// Cached room naddr string (avoids format! per frame) + room_naddr: String, + /// Monotonic time tracker (seconds since app start) + start_time: std::time::Instant, } impl NostrverseApp { @@ -85,6 +97,7 @@ impl NostrverseApp { let device = render_state.map(|rs| rs.device.clone()); let queue = render_state.map(|rs| rs.queue.clone()); + let room_naddr = room_ref.to_naddr(); Self { state: NostrverseState::new(room_ref), renderer, @@ -93,6 +106,11 @@ impl NostrverseApp { initialized: false, avatar_bounds: None, room_sub: None, + presence_pub: presence::PresencePublisher::new(), + presence_expiry: presence::PresenceExpiry::new(), + presence_sub: None, + room_naddr, + start_time: std::time::Instant::now(), } } @@ -135,11 +153,12 @@ impl NostrverseApp { // Ingest as a local-only room event if we have a keypair if let Some(kp) = ctx.accounts.selected_filled() { let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id); - nostr_events::ingest_room_event(builder, ctx.ndb, kp); + nostr_events::ingest_event(builder, ctx.ndb, kp); } - // Subscribe to room events in local nostrdb + // Subscribe to room and presence events in local nostrdb self.room_sub = Some(subscriptions::RoomSubscription::new(ctx.ndb)); + self.presence_sub = Some(subscriptions::PresenceSubscription::new(ctx.ndb)); // Query for any existing room events (including the one we just ingested) let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn"); @@ -232,11 +251,12 @@ impl NostrverseApp { let space = convert::build_space(room, &self.state.objects); let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id); - nostr_events::ingest_room_event(builder, ctx.ndb, kp); + nostr_events::ingest_event(builder, ctx.ndb, kp); tracing::info!("Saved room '{}'", self.state.room_ref.id); } - /// Load 3D models for objects and handle AABB-based placement. + /// Load 3D models for objects, then resolve any semantic locations + /// (e.g. "top-of obj1") to concrete positions using AABB bounds. fn load_object_models(&self, objects: &mut Vec<RoomObject>) { let renderer = self.renderer.as_ref(); let model_bounds_fn = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> { @@ -244,34 +264,77 @@ impl NostrverseApp { r.model_bounds(m?) }; - let mut table_top_y: f32 = 0.86; - let mut bottle_bounds = None; + // Phase 1: Load all models and cache their AABB bounds + let mut bounds_by_id: std::collections::HashMap<String, renderbud::Aabb> = + std::collections::HashMap::new(); for obj in objects.iter_mut() { if let Some(url) = &obj.model_url { let model = self.load_model(url); - let bounds = model_bounds_fn(model); + if let Some(bounds) = model_bounds_fn(model) { + bounds_by_id.insert(obj.id.clone(), bounds); + } + obj.model_handle = model; + } + } + + // Phase 2: Resolve semantic locations to positions + // Collect resolved positions first to avoid borrow issues + let mut resolved: Vec<(usize, Vec3)> = Vec::new(); - if obj.id == "obj1" { - if let Some(b) = bounds { - table_top_y = b.max.y; + for (i, obj) in objects.iter().enumerate() { + let Some(loc) = &obj.location else { + continue; + }; + + match loc { + room_state::ObjectLocation::TopOf(target_id) => { + // Find the target object's position and top-of-AABB + let target = objects.iter().find(|o| o.id == *target_id); + if let Some(target) = target { + let target_top = + bounds_by_id.get(target_id).map(|b| b.max.y).unwrap_or(0.0); + let self_half_h = bounds_by_id + .get(&obj.id) + .map(|b| (b.max.y - b.min.y) * 0.5) + .unwrap_or(0.0); + let pos = Vec3::new( + target.position.x, + target_top + self_half_h, + target.position.z, + ); + resolved.push((i, pos)); } } - - if obj.id == "obj2" { - bottle_bounds = bounds; + room_state::ObjectLocation::Near(target_id) => { + // Place nearby: offset by target's width + margin + let target = objects.iter().find(|o| o.id == *target_id); + if let Some(target) = target { + let offset = bounds_by_id + .get(target_id) + .map(|b| b.max.x - b.min.x) + .unwrap_or(1.0); + let pos = Vec3::new( + target.position.x + offset, + target.position.y, + target.position.z, + ); + resolved.push((i, pos)); + } } - - obj.model_handle = model; + room_state::ObjectLocation::Floor => { + let self_half_h = bounds_by_id + .get(&obj.id) + .map(|b| (b.max.y - b.min.y) * 0.5) + .unwrap_or(0.0); + resolved.push((i, Vec3::new(obj.position.x, self_half_h, obj.position.z))); + } + _ => {} } } - // Position the bottle on top of the table (runtime AABB placement) - if let Some(obj2) = objects.iter_mut().find(|o| o.id == "obj2") { - let bottle_half_h = bottle_bounds - .map(|b| (b.max.y - b.min.y) * 0.5) - .unwrap_or(0.0); - obj2.position = Vec3::new(0.0, table_top_y + bottle_half_h, 0.0); + for (i, pos) in resolved { + objects[i].position = pos; } } @@ -304,6 +367,63 @@ impl NostrverseApp { } } + /// Run one tick of presence: publish local position, poll remote, expire stale. + fn tick_presence(&mut self, ctx: &mut AppContext<'_>) { + let now = self.start_time.elapsed().as_secs_f64(); + + // Publish our position (throttled — only on change or keep-alive) + if let Some(kp) = ctx.accounts.selected_filled() { + let self_pos = self + .state + .users + .iter() + .find(|u| u.is_self) + .map(|u| u.position) + .unwrap_or(Vec3::ZERO); + + self.presence_pub + .maybe_publish(ctx.ndb, kp, &self.room_naddr, self_pos, now); + } + + // Poll for remote presence events + let self_pubkey = *ctx.accounts.selected_account_pubkey(); + if let Some(sub) = &self.presence_sub { + let changed = presence::poll_presence( + sub, + ctx.ndb, + &self.room_naddr, + &self_pubkey, + &mut self.state.users, + now, + ); + + // Assign avatar model to new users + if changed { + let avatar_model = self + .state + .users + .iter() + .find(|u| u.is_self) + .and_then(|u| u.model_handle); + if let Some(model) = avatar_model { + for user in &mut self.state.users { + if user.model_handle.is_none() { + user.model_handle = Some(model); + } + } + } + } + } + + // Expire stale remote users (throttled to every ~10s) + let removed = self + .presence_expiry + .maybe_expire(&mut self.state.users, now); + if removed > 0 { + tracing::info!("Expired {} stale users", removed); + } + } + /// Sync room objects and user avatars to the renderbud scene fn sync_scene(&mut self) { let Some(renderer) = &self.renderer else { @@ -397,6 +517,9 @@ impl notedeck::App for NostrverseApp { // Poll for room event updates self.poll_room_updates(ctx.ndb); + // Presence: publish, poll, expire + self.tick_presence(ctx); + // Sync state to 3D scene self.sync_scene(); diff --git a/crates/notedeck_nostrverse/src/nostr_events.rs b/crates/notedeck_nostrverse/src/nostr_events.rs @@ -63,25 +63,71 @@ fn get_tag_value<'a>(note: &'a Note<'a>, tag_name: &str) -> Option<&'a str> { None } -/// Sign and ingest a room event into the local nostrdb only (no relay publishing). -pub fn ingest_room_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) { +/// Build a coarse presence heartbeat event (kind 10555). +/// +/// Published on meaningful position change, plus periodic keep-alive. +/// Tags: ["a", room_naddr], ["position", "x y z"], ["expiration", unix_ts] +/// Content: empty +/// +/// The expiration tag (NIP-40) tells relays/nostrdb to discard the event +/// after 90 seconds, matching the client-side stale timeout. +pub fn build_presence_event<'a>(room_naddr: &str, position: glam::Vec3) -> NoteBuilder<'a> { + let pos_str = format!("{} {} {}", position.x, position.y, position.z); + + let expiration = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + + 90; + let exp_str = expiration.to_string(); + + NoteBuilder::new() + .kind(kinds::PRESENCE as u32) + .content("") + .start_tag() + .tag_str("a") + .tag_str(room_naddr) + .start_tag() + .tag_str("position") + .tag_str(&pos_str) + .start_tag() + .tag_str("expiration") + .tag_str(&exp_str) +} + +/// Parse a presence event's position tag into a Vec3. +pub fn parse_presence_position(note: &Note<'_>) -> Option<glam::Vec3> { + let pos_str = get_tag_value(note, "position")?; + let mut parts = pos_str.split_whitespace(); + let x: f32 = parts.next()?.parse().ok()?; + let y: f32 = parts.next()?.parse().ok()?; + let z: f32 = parts.next()?.parse().ok()?; + Some(glam::Vec3::new(x, y, z)) +} + +/// Extract the "a" tag (room naddr) from a presence note. +pub fn get_presence_room<'a>(note: &'a Note<'a>) -> Option<&'a str> { + get_tag_value(note, "a") +} + +/// Sign and ingest a nostr event into the local nostrdb only (no relay publishing). +pub fn ingest_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) { let note = builder .sign(&kp.secret_key.secret_bytes()) .build() .expect("build note"); let Ok(event) = &enostr::ClientMessage::event(&note) else { - tracing::error!("ingest_room_event: failed to build client message"); + tracing::error!("ingest_event: failed to build client message"); return; }; let Ok(json) = event.to_json() else { - tracing::error!("ingest_room_event: failed to serialize json"); + tracing::error!("ingest_event: failed to serialize json"); return; }; let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true)); - tracing::info!("ingested room event locally"); } #[cfg(test)] @@ -159,4 +205,29 @@ mod tests { assert_eq!(get_room_id(&note), Some("my-id")); } + + #[test] + fn test_build_presence_event() { + let pos = glam::Vec3::new(1.5, 0.0, -3.2); + let mut builder = build_presence_event("37555:abc123:my-room", pos); + let note = builder.build().expect("build note"); + + assert_eq!(note.content(), ""); + assert_eq!(get_presence_room(&note), Some("37555:abc123:my-room")); + + let parsed_pos = parse_presence_position(&note).expect("parse position"); + assert!((parsed_pos.x - 1.5).abs() < 0.01); + assert!((parsed_pos.y - 0.0).abs() < 0.01); + assert!((parsed_pos.z - (-3.2)).abs() < 0.01); + + // Should have an expiration tag (NIP-40) + let exp = get_tag_value(&note, "expiration").expect("missing expiration tag"); + let exp_ts: u64 = exp.parse().expect("expiration should be a number"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(exp_ts > now, "expiration should be in the future"); + assert!(exp_ts <= now + 91, "expiration should be ~90s from now"); + } } diff --git a/crates/notedeck_nostrverse/src/presence.rs b/crates/notedeck_nostrverse/src/presence.rs @@ -0,0 +1,262 @@ +//! Coarse presence via nostr events (kind 10555). +//! +//! Publishes only on meaningful position change (with 1s minimum gap), +//! plus a keep-alive heartbeat every 60s to maintain room presence. +//! Not intended for smooth real-time movement sync. + +use enostr::{FilledKeypair, Pubkey}; +use glam::Vec3; +use nostrdb::Ndb; + +use crate::{nostr_events, room_state::RoomUser, subscriptions::PresenceSubscription}; + +/// Minimum position change (distance) to trigger a publish. +const POSITION_THRESHOLD: f32 = 0.5; + +/// Minimum seconds between publishes even when moving. +const MIN_PUBLISH_GAP: f64 = 1.0; + +/// Keep-alive interval: publish even when idle to stay visible. +const KEEPALIVE_INTERVAL: f64 = 60.0; + +/// Seconds without a heartbeat before a remote user is considered gone. +const STALE_TIMEOUT: f64 = 90.0; + +/// How often to check for stale users (seconds). +const EXPIRY_CHECK_INTERVAL: f64 = 10.0; + +/// Publishes local user presence as kind 10555 events. +/// +/// Only publishes when position changes meaningfully, plus periodic +/// keep-alive to maintain room presence. Does not spam on idle. +pub struct PresencePublisher { + /// Last position we published + last_position: Vec3, + /// Monotonic time of last publish + last_publish_time: f64, + /// Whether we've published at least once + published_once: bool, +} + +impl PresencePublisher { + pub fn new() -> Self { + Self { + last_position: Vec3::ZERO, + last_publish_time: 0.0, + published_once: false, + } + } + + /// Check whether a publish should happen (without side effects). + /// Used for both the real publish path and tests. + fn should_publish(&self, position: Vec3, now: f64) -> bool { + // Always publish the first time + if !self.published_once { + return true; + } + + let elapsed = now - self.last_publish_time; + + // Rate limit: never more than once per second + if elapsed < MIN_PUBLISH_GAP { + return false; + } + + // Publish if position changed meaningfully + let moved = self.last_position.distance(position) > POSITION_THRESHOLD; + if moved { + return true; + } + + // Keep-alive: publish periodically even when idle + elapsed >= KEEPALIVE_INTERVAL + } + + /// Record that a publish happened (update internal state). + fn record_publish(&mut self, position: Vec3, now: f64) { + self.last_position = position; + self.last_publish_time = now; + self.published_once = true; + } + + /// Maybe publish a presence heartbeat. Returns true if published. + pub fn maybe_publish( + &mut self, + ndb: &Ndb, + kp: FilledKeypair, + room_naddr: &str, + position: Vec3, + now: f64, + ) -> bool { + if !self.should_publish(position, now) { + return false; + } + + let builder = nostr_events::build_presence_event(room_naddr, position); + nostr_events::ingest_event(builder, ndb, kp); + + self.record_publish(position, now); + true + } +} + +/// Poll for presence events and update the user list. +/// +/// Returns true if any users were added or updated. +pub fn poll_presence( + sub: &PresenceSubscription, + ndb: &Ndb, + room_naddr: &str, + self_pubkey: &Pubkey, + users: &mut Vec<RoomUser>, + now: f64, +) -> bool { + let txn = nostrdb::Transaction::new(ndb).expect("txn"); + let notes = sub.poll(ndb, &txn); + let mut changed = false; + + for note in &notes { + // Filter to our room + let Some(event_room) = nostr_events::get_presence_room(note) else { + continue; + }; + if event_room != room_naddr { + continue; + } + + let Some(position) = nostr_events::parse_presence_position(note) else { + continue; + }; + + let pubkey = Pubkey::new(*note.pubkey()); + + // Skip our own presence events + if &pubkey == self_pubkey { + continue; + } + + // Update or insert user + if let Some(user) = users.iter_mut().find(|u| u.pubkey == pubkey) { + user.position = position; + user.last_seen = now; + } else { + let mut user = RoomUser::new(pubkey, "anon".to_string(), position); + user.last_seen = now; + users.push(user); + } + changed = true; + } + + changed +} + +/// Remove users who haven't sent a heartbeat recently. +/// Throttled to only run every EXPIRY_CHECK_INTERVAL seconds. +pub struct PresenceExpiry { + last_check: f64, +} + +impl PresenceExpiry { + pub fn new() -> Self { + 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 { + if now - self.last_check < EXPIRY_CHECK_INTERVAL { + return 0; + } + self.last_check = now; + let before = users.len(); + users.retain(|u| u.is_self || (now - u.last_seen) < STALE_TIMEOUT); + before - users.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expiry_throttle_and_cleanup() { + let pk1 = Pubkey::new([1; 32]); + let pk2 = Pubkey::new([2; 32]); + let pk_self = Pubkey::new([3; 32]); + + let mut users = vec![ + { + let mut u = RoomUser::new(pk_self, "me".to_string(), Vec3::ZERO); + u.is_self = true; + u.last_seen = 0.0; // stale but self — should survive + u + }, + { + let mut u = RoomUser::new(pk1, "alice".to_string(), Vec3::ZERO); + u.last_seen = 80.0; // fresh (within 90s timeout) + u + }, + { + let mut u = RoomUser::new(pk2, "bob".to_string(), Vec3::ZERO); + u.last_seen = 1.0; // stale (>90s ago) + u + }, + ]; + + 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_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); + 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); + } + + #[test] + fn test_publisher_first_publish() { + let pub_ = PresencePublisher::new(); + // First publish should always happen + assert!(pub_.should_publish(Vec3::ZERO, 0.0)); + } + + #[test] + fn test_publisher_no_spam_when_idle() { + let mut pub_ = PresencePublisher::new(); + pub_.record_publish(Vec3::ZERO, 0.0); + + // Idle at same position — should NOT publish at 1s, 5s, 10s, 30s + assert!(!pub_.should_publish(Vec3::ZERO, 1.0)); + assert!(!pub_.should_publish(Vec3::ZERO, 5.0)); + assert!(!pub_.should_publish(Vec3::ZERO, 10.0)); + assert!(!pub_.should_publish(Vec3::ZERO, 30.0)); + + // Keep-alive triggers at 60s + assert!(pub_.should_publish(Vec3::ZERO, 60.1)); + } + + #[test] + fn test_publisher_on_movement() { + let mut pub_ = PresencePublisher::new(); + pub_.record_publish(Vec3::ZERO, 0.0); + + // Small movement below threshold — no publish + assert!(!pub_.should_publish(Vec3::new(0.1, 0.0, 0.0), 2.0)); + + // Significant movement — publish + assert!(pub_.should_publish(Vec3::new(5.0, 0.0, 0.0), 2.0)); + + // But rate limited: can't publish again within 1s + pub_.record_publish(Vec3::new(5.0, 0.0, 0.0), 2.0); + assert!(!pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), 2.5)); + + // After 1s gap, can publish again + assert!(pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), 3.1)); + } +} diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -70,6 +70,20 @@ pub enum RoomShape { Custom, } +/// Spatial location relative to the room or another object. +/// Mirrors protoverse::Location for decoupling. +#[derive(Clone, Debug, PartialEq)] +pub enum ObjectLocation { + Center, + Floor, + Ceiling, + /// On top of another object (by id) + TopOf(String), + /// Near another object (by id) + Near(String), + Custom(String), +} + /// Protoverse object type, preserved for round-trip serialization #[derive(Clone, Debug, Default)] pub enum RoomObjectType { @@ -91,6 +105,8 @@ pub struct RoomObject { pub object_type: RoomObjectType, /// URL to a glTF model (None = use placeholder geometry) pub model_url: Option<String>, + /// Semantic location (e.g. "top-of obj1"), resolved to position at load time + pub location: Option<ObjectLocation>, /// 3D position in world space pub position: Vec3, /// 3D rotation @@ -110,6 +126,7 @@ impl RoomObject { name, object_type: RoomObjectType::Prop, model_url: None, + location: None, position, rotation: Quat::IDENTITY, scale: Vec3::ONE, @@ -128,6 +145,11 @@ impl RoomObject { self } + pub fn with_location(mut self, loc: ObjectLocation) -> Self { + self.location = Some(loc); + self + } + pub fn with_scale(mut self, scale: Vec3) -> Self { self.scale = scale; self @@ -142,6 +164,8 @@ pub struct RoomUser { pub position: Vec3, /// Whether this is the current user pub is_self: bool, + /// Monotonic timestamp (seconds) of last presence update + pub last_seen: f64, /// Runtime: renderbud scene object handle for avatar pub scene_object_id: Option<ObjectId>, /// Runtime: loaded model handle for avatar @@ -155,6 +179,7 @@ impl RoomUser { display_name, position, is_self: false, + last_seen: 0.0, scene_object_id: None, model_handle: None, } diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -47,10 +47,10 @@ pub fn show_room_view( ui.input(|i| { if i.key_down(egui::Key::W) { - forward += 1.0; + forward -= 1.0; } if i.key_down(egui::Key::S) { - forward -= 1.0; + forward += 1.0; } if i.key_down(egui::Key::D) { right += 1.0; diff --git a/crates/notedeck_nostrverse/src/subscriptions.rs b/crates/notedeck_nostrverse/src/subscriptions.rs @@ -51,3 +51,26 @@ impl RoomSubscription { .collect() } } + +/// Manages a local nostrdb subscription for presence events (kind 10555). +pub struct PresenceSubscription { + sub: Subscription, +} + +impl PresenceSubscription { + /// Subscribe to presence events in the local nostrdb. + pub fn new(ndb: &Ndb) -> Self { + let filter = Filter::new().kinds([kinds::PRESENCE as u64]).build(); + let sub = ndb.subscribe(&[filter]).expect("presence subscription"); + Self { sub } + } + + /// Poll for new presence events. + pub fn poll<'a>(&self, ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> { + let note_keys = ndb.poll_for_notes(self.sub, 50); + note_keys + .into_iter() + .filter_map(|nk| ndb.get_note_by_key(txn, nk).ok()) + .collect() + } +} diff --git a/crates/protoverse/src/ast.rs b/crates/protoverse/src/ast.rs @@ -62,12 +62,29 @@ pub enum Attribute { Width(f64), Depth(f64), Height(f64), - Location(String), + Location(Location), State(CellState), Position(f64, f64, f64), ModelUrl(String), } +/// Spatial location relative to the room or another object. +#[derive(Clone, Debug, PartialEq)] +pub enum Location { + /// Center of parent container + Center, + /// On the floor + Floor, + /// On the ceiling + Ceiling, + /// On top of another object (by id) + TopOf(String), + /// Near another object (by id) + Near(String), + /// Freeform / unrecognized location value + Custom(String), +} + #[derive(Clone, Copy, Debug, PartialEq)] pub enum Shape { Rectangle, @@ -127,6 +144,19 @@ impl fmt::Display for CellState { } } +impl fmt::Display for Location { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Location::Center => write!(f, "center"), + Location::Floor => write!(f, "floor"), + Location::Ceiling => write!(f, "ceiling"), + Location::TopOf(id) => write!(f, "top-of {}", id), + Location::Near(id) => write!(f, "near {}", id), + Location::Custom(s) => write!(f, "{}", s), + } + } +} + // --- Space accessor methods --- impl Space { @@ -183,6 +213,13 @@ impl Space { }) } + pub fn location(&self, id: CellId) -> Option<&Location> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Location(loc) => Some(loc), + _ => None, + }) + } + pub fn width(&self, id: CellId) -> Option<f64> { self.attrs(id).iter().find_map(|a| match a { Attribute::Width(n) => Some(*n), diff --git a/crates/protoverse/src/parser.rs b/crates/protoverse/src/parser.rs @@ -177,9 +177,23 @@ impl<'a> Parser<'a> { "condition" => self .eat_string() .map(|s| Attribute::Condition(s.to_string())), - "location" => self - .eat_symbol() - .map(|s| Attribute::Location(s.to_string())), + "location" => self.eat_symbol().and_then(|s| { + let loc = match s { + "center" => Location::Center, + "floor" => Location::Floor, + "ceiling" => Location::Ceiling, + "top-of" => { + let id = self.eat_symbol()?; + Location::TopOf(id.to_string()) + } + "near" => { + let id = self.eat_symbol()?; + Location::Near(id.to_string()) + } + other => Location::Custom(other.to_string()), + }; + Some(Attribute::Location(loc)) + }), "state" => self.eat_symbol().and_then(|s| { let state = match s { "on" => CellState::On, @@ -451,4 +465,62 @@ mod tests { CellType::Object(ObjectType::Chair) ); } + + #[test] + fn test_parse_location_variants() { + // Simple locations + let space = parse("(table (location center))").unwrap(); + assert_eq!(space.location(space.root), Some(&Location::Center)); + + let space = parse("(table (location floor))").unwrap(); + assert_eq!(space.location(space.root), Some(&Location::Floor)); + + let space = parse("(table (location ceiling))").unwrap(); + assert_eq!(space.location(space.root), Some(&Location::Ceiling)); + + // Relational locations + let space = parse("(prop (location top-of obj1))").unwrap(); + assert_eq!( + space.location(space.root), + Some(&Location::TopOf("obj1".to_string())) + ); + + let space = parse("(chair (location near desk))").unwrap(); + assert_eq!( + space.location(space.root), + Some(&Location::Near("desk".to_string())) + ); + + // Custom/unknown location + let space = parse("(light (location somewhere))").unwrap(); + assert_eq!( + space.location(space.root), + Some(&Location::Custom("somewhere".to_string())) + ); + } + + #[test] + fn test_location_roundtrip() { + use crate::serializer::serialize; + + let input = r#"(room (group (table (id obj1) (position 0 0 0)) (prop (id obj2) (location top-of obj1))))"#; + let space1 = parse(input).unwrap(); + let serialized = serialize(&space1); + let space2 = parse(&serialized).unwrap(); + + // Find obj2 in both + let group1 = space1.children(space1.root)[0]; + let obj2_1 = space1.children(group1)[1]; + assert_eq!( + space1.location(obj2_1), + Some(&Location::TopOf("obj1".to_string())) + ); + + let group2 = space2.children(space2.root)[0]; + let obj2_2 = space2.children(group2)[1]; + assert_eq!( + space2.location(obj2_2), + Some(&Location::TopOf("obj1".to_string())) + ); + } } diff --git a/crates/protoverse/src/serializer.rs b/crates/protoverse/src/serializer.rs @@ -71,8 +71,8 @@ fn write_attr(attr: &Attribute, out: &mut String) { Attribute::Condition(s) => { let _ = write!(out, "(condition \"{}\")", s); } - Attribute::Location(s) => { - let _ = write!(out, "(location {})", s); + Attribute::Location(loc) => { + let _ = write!(out, "(location {})", loc); } Attribute::State(s) => { let _ = write!(out, "(state {})", s);