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:
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(¬e) 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(¬e), 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(¬e), Some("37555:abc123:my-room"));
+
+ let parsed_pos = parse_presence_position(¬e).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(¬e, "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 ¬es {
+ // 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);