notedeck

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

commit ed31aa61534a66de02d6bb4c06b6fa4edcdb5af1
parent 2531541424c7535792699db72882a8fab72d4332
Author: William Casarin <jb55@jb55.com>
Date:   Sat, 21 Feb 2026 19:54:17 -0800

Merge branch 'nostrverse', remote-tracking branch 'github/pr/1300'

Diffstat:
A.beads/issues.jsonl | 24++++++++++++++++++++++++
MCargo.lock | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 4++++
Mcrates/notedeck_chrome/Cargo.toml | 2++
Mcrates/notedeck_chrome/src/app.rs | 8++++++++
Mcrates/notedeck_chrome/src/chrome.rs | 16++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 29+++++++++++++++++++++--------
Acrates/notedeck_nostrverse/Cargo.toml | 14++++++++++++++
Acrates/notedeck_nostrverse/src/lib.rs | 349+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_nostrverse/src/room_state.rs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_nostrverse/src/room_view.rs | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 943 insertions(+), 10 deletions(-)

diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl @@ -0,0 +1,24 @@ +{"id":"notedeck-0mh","title":"Remote NIP-50 search","description":"GitHub #1110: Implement remote search using NIP-50 protocol. See https://github.com/damus-io/notedeck/issues/1110","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:41.013086749-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:41.013086749-08:00","labels":["columns"]} +{"id":"notedeck-27x","title":"Auto-steal focus should return to original session","description":"In crates/notedeck_dave, when an agent steals focus from another agent to ask a question, I want it to focus back to where it was after the interaction completes.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:38:44.526107651-08:00","created_by":"William Casarin","updated_at":"2026-01-30T22:35:00.825936422-08:00","closed_at":"2026-01-30T22:35:00.825936422-08:00","close_reason":"Auto-steal focus now returns to original session after interaction completes","labels":["dave"]} +{"id":"notedeck-2yj","title":"Profile search","description":"GitHub #1111: Extend search functionality to include profile lookups. See https://github.com/damus-io/notedeck/issues/1111","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:36.780164431-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:36.780164431-08:00","labels":["columns"]} +{"id":"notedeck-3ns","title":"Approve/deny view text not wrapping - goes off screen","description":"The approve/deny view for tool calls doesn't wrap text properly. Long descriptions go all the way off the screen, making them unreadable.","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:52:24.878602874-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:52:24.878602874-08:00","labels":["dave"]} +{"id":"notedeck-4no","title":"Follow packs show blank profiles","description":"GitHub #1107: Some profiles in follow packs display blank. See https://github.com/damus-io/notedeck/issues/1107","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:46:44.374295002-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:46:44.374295002-08:00","labels":["columns"]} +{"id":"notedeck-84e","title":"Link previews (OpenGraph)","description":"GitHub #992: Display link previews using OpenGraph metadata. See https://github.com/damus-io/notedeck/issues/992","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:46.117752018-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:51:46.117752018-08:00","labels":["columns"]} +{"id":"notedeck-ajn","title":"Contact lists aren't updated periodically","description":"GitHub #575: Contact lists don't refresh periodically as they should. See https://github.com/damus-io/notedeck/issues/575","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:46.741346469-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:49:46.741346469-08:00","labels":["columns"]} +{"id":"notedeck-azp","title":"Column requires opening app twice to show new notes","description":"GitHub #780: Columns require opening notedeck twice to display new notes on initial launch. See https://github.com/damus-io/notedeck/issues/780","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:39.093911034-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:49:39.093911034-08:00","labels":["columns"]} +{"id":"notedeck-bce","title":"Handle ExitPlanMode tool call","description":"Handle ExitPlanMode which simply exits plan mode. Claude-code sends this when it's done its planning phase.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:17.311242243-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:38:19.839039601-08:00","closed_at":"2026-01-30T12:38:19.839039601-08:00","close_reason":"Implemented ExitPlanMode UI with Approve/Reject buttons. When approved, exits plan mode and allows the tool call. UI shows PLAN badge with 'Plan ready for approval' message.","labels":["dave"]} +{"id":"notedeck-c3p","title":"Implement multiline message composer (Signal-style)","description":"Replaced singleline TextEdit with multiline, using Signal-style keybindings: Enter to send, Shift+Enter for newline. Based on existing dave.rs implementation.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T12:32:45.563930191-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:33:07.061369966-08:00","closed_at":"2026-01-30T12:33:07.061369966-08:00","close_reason":"Implemented: Changed TextEdit from singleline to multiline with Signal-style keybindings (Enter=send, Shift+Enter=newline) in convo.rs"} +{"id":"notedeck-cf0","title":"Preserve edit view after approval/denial","description":"When approving or denying an edit, keep the diff visible instead of making it disappear. Allows reviewing what was changed even after responding.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:41:04.789975491-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:41:04.789975491-08:00","labels":["dave"]} +{"id":"notedeck-dx2","title":"Multi column image reply bug","description":"GitHub #1104: Images in multi-column replies appear in wrong column. See https://github.com/damus-io/notedeck/issues/1104","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:15.806023697-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:15.806023697-08:00","labels":["columns"]} +{"id":"notedeck-fs8","title":"Ctrl+P not dropping from NeedsInput to Done","description":"Commit c6a96d8dbfef is supposed to enable dropping from NeedsInput to Done with Ctrl+P, but it's not working. The commit message says Ctrl+P should navigate backward within a priority group and drop to the next lower priority level when at the first item (NeedsInput → Error → Done).","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:42:27.63658035-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:57:51.25992885-08:00","closed_at":"2026-01-30T11:57:51.25992885-08:00","close_reason":"Fixed swapped keybindings in keybindings.rs:82-90 - Ctrl+N now returns FocusQueueNext (higher priority) and Ctrl+P returns FocusQueuePrev (lower priority)","labels":["dave"]} +{"id":"notedeck-gj0","title":"Timeline carousel does not work","description":"GitHub #1006: Image carousel swiping functionality needs implementation or repair for navigating images. See https://github.com/damus-io/notedeck/issues/1006","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:49.42410683-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:49.42410683-08:00","labels":["columns"]} +{"id":"notedeck-h9s","title":"Note not ingesting when sending locally (offline)","description":"GitHub #1050: Offline notes fail to sync when device reconnects; messages aren't appearing even after network restored. See https://github.com/damus-io/notedeck/issues/1050","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:34.188084601-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:34.188084601-08:00","labels":["columns"]} +{"id":"notedeck-hav","title":"Add auto-accept mode for agent tool calls","description":"Add a toggle that automatically approves agent tool calls without requiring manual confirmation. Useful for trusted tasks or batch mode. Could be global or per-agent.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:00.952701242-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:40:00.952701242-08:00","labels":["dave"]} +{"id":"notedeck-i40","title":"Quoted note target changes depending on wide or selected mode","description":"GitHub #1117: Quoted note references inconsistent based on view mode. See https://github.com/damus-io/notedeck/issues/1117","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:45:42.264025111-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:45:42.264025111-08:00","labels":["columns"]} +{"id":"notedeck-j8c","title":"Chat sidebar should show last message from user or AI","description":"Chat sidebar text should show the user's or AI's last message, not our last message.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:39:11.482262998-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:39:11.482262998-08:00","labels":["dave"]} +{"id":"notedeck-kpa","title":"Zap notification","description":"GitHub #1037: Notify users when zapped, showing zap amount, sender, and zapped content details. See https://github.com/damus-io/notedeck/issues/1037","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:29.181749931-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:51:29.181749931-08:00","labels":["columns"]} +{"id":"notedeck-p1n","title":"NIP-05 validation not working as intended","description":"GitHub #1274: NIP-05 validation feature is malfunctioning. See https://github.com/damus-io/notedeck/issues/1274","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:38:30.276030933-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:38:30.276030933-08:00","labels":["columns"]} +{"id":"notedeck-pj7","title":"Like button not visible in light theme","description":"GitHub #1246: Like button rendering visibility issue when switching to light theme mode. See https://github.com/damus-io/notedeck/issues/1246","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:16.601075159-08:00","created_by":"William Casarin","updated_at":"2026-01-30T13:38:50.529278612-08:00","closed_at":"2026-01-30T13:38:50.529278612-08:00","close_reason":"Fixed by applying text color tint unconditionally in like_button()","labels":["columns"]} +{"id":"notedeck-war","title":"MacOS crash on PFP → Side menu Accounts","description":"GitHub #1270: Application crashes when navigating to Accounts via profile picture menu due to 'layer_id change panic'. See https://github.com/damus-io/notedeck/issues/1270","status":"open","priority":1,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:09.082890703-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:41:09.082890703-08:00","labels":["columns"]} +{"id":"notedeck-xer","title":"Persist conversation across app restarts","description":"Save and restore conversation state so it survives app restarts.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:11.196068397-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:40:11.196068397-08:00","labels":["dave"]} +{"id":"notedeck-zg7","title":"Quote notification","description":"GitHub #1041: Implement notifications when users' posts are quoted by others. See https://github.com/damus-io/notedeck/issues/1041","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:58.370192754-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:58.370192754-08:00","labels":["columns"]} diff --git a/Cargo.lock b/Cargo.lock @@ -633,6 +633,12 @@ dependencies = [ [[package]] name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" @@ -2481,6 +2487,15 @@ dependencies = [ ] [[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" +dependencies = [ + "bytemuck", +] + +[[package]] name = "glob" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2511,6 +2526,45 @@ dependencies = [ ] [[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "base64 0.13.1", + "byteorder", + "gltf-json", + "image", + "lazy_static", + "serde_json", + "urlencoding", +] + +[[package]] +name = "gltf-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" +dependencies = [ + "inflections", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "gltf-json" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" +dependencies = [ + "gltf-derive", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] name = "glutin" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2596,6 +2650,18 @@ dependencies = [ ] [[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] name = "gpu-descriptor" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2636,12 +2702,13 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", ] [[package]] @@ -3103,6 +3170,12 @@ dependencies = [ ] [[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4048,6 +4121,7 @@ dependencies = [ "notedeck_dashboard", "notedeck_dave", "notedeck_messages", + "notedeck_nostrverse", "notedeck_notebook", "notedeck_ui", "objc2 0.5.2", @@ -4216,6 +4290,20 @@ dependencies = [ ] [[package]] +name = "notedeck_nostrverse" +version = "0.7.1" +dependencies = [ + "egui", + "egui-wgpu", + "enostr", + "glam", + "nostrdb", + "notedeck", + "renderbud", + "tracing", +] + +[[package]] name = "notedeck_notebook" version = "0.7.1" dependencies = [ @@ -5092,6 +5180,12 @@ dependencies = [ ] [[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] name = "pretty_assertions" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5343,6 +5437,12 @@ dependencies = [ ] [[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] name = "rav1e" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5572,6 +5672,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] +name = "renderbud" +version = "0.1.0" +dependencies = [ + "bytemuck", + "egui", + "egui-wgpu", + "glam", + "gltf", + "half", + "image", + "rayon", + "wgpu", + "winit 0.30.11", +] + +[[package]] name = "renderdoc-sys" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -7652,6 +7768,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", + "bit-set", "bitflags 2.9.1", "block", "bytemuck", @@ -7660,6 +7777,7 @@ dependencies = [ "glow", "glutin_wgl_sys", "gpu-alloc", + "gpu-allocator", "gpu-descriptor", "js-sys", "khronos-egl", @@ -7674,6 +7792,7 @@ dependencies = [ "ordered-float", "parking_lot", "profiling", + "range-alloc", "raw-window-handle", "renderdoc-sys", "rustc-hash 1.1.0", @@ -7683,6 +7802,7 @@ dependencies = [ "web-sys", "wgpu-types", "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] @@ -8349,6 +8469,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit", "smol_str", "tracing", diff --git a/Cargo.toml b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/notedeck_dave", "crates/notedeck_messages", "crates/notedeck_notebook", + "crates/notedeck_nostrverse", "crates/notedeck_ui", "crates/notedeck_clndash", "crates/notedeck_dashboard", @@ -55,6 +56,7 @@ ewebsock = { version = "0.2.0", features = ["tls"] } fluent = "0.17.0" fluent-resmgr = "0.0.8" fluent-langneg = "0.13" +glam = { version = "0.30", features = ["bytemuck"] } hex = { version = "0.4.3", features = ["serde"] } image = { version = "0.25", features = ["jpeg", "png", "webp"] } indexmap = "2.6.0" @@ -73,6 +75,8 @@ notedeck_columns = { path = "crates/notedeck_columns" } notedeck_dave = { path = "crates/notedeck_dave" } notedeck_messages = { path = "crates/notedeck_messages" } notedeck_notebook = { path = "crates/notedeck_notebook" } +notedeck_nostrverse = { path = "crates/notedeck_nostrverse" } +renderbud = { path = "../github/jb55/renderbud", features = ["egui"] } notedeck_ui = { path = "crates/notedeck_ui" } tokenator = { path = "crates/tokenator" } md-stream = { path = "crates/md-stream" } diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml @@ -20,6 +20,7 @@ notedeck_ui = { workspace = true } notedeck_dave = { workspace = true } notedeck_messages = { workspace = true, optional = true } notedeck_notebook = { workspace = true, optional = true } +notedeck_nostrverse = { workspace = true, optional = true } notedeck_clndash = { workspace = true, optional = true } notedeck_dashboard = { workspace = true, optional = true } notedeck = { workspace = true } @@ -57,6 +58,7 @@ puffin = ["profiling/profile-with-puffin", "dep:puffin"] tracy = ["profiling/profile-with-tracy"] messages = ["notedeck_messages"] notebook = ["notedeck_notebook"] +nostrverse = ["notedeck_nostrverse"] clndash = ["notedeck_clndash"] dashboard = ["notedeck_dashboard"] diff --git a/crates/notedeck_chrome/src/app.rs b/crates/notedeck_chrome/src/app.rs @@ -15,6 +15,9 @@ use notedeck_dashboard::Dashboard; #[cfg(feature = "notebook")] use notedeck_notebook::Notebook; +#[cfg(feature = "nostrverse")] +use notedeck_nostrverse::NostrverseApp; + #[allow(clippy::large_enum_variant)] pub enum NotedeckApp { Dave(Box<Dave>), @@ -32,6 +35,8 @@ pub enum NotedeckApp { #[cfg(feature = "dashboard")] Dashboard(Box<Dashboard>), + #[cfg(feature = "nostrverse")] + Nostrverse(Box<NostrverseApp>), Other(Box<dyn notedeck::App>), } @@ -54,6 +59,9 @@ impl notedeck::App for NotedeckApp { #[cfg(feature = "dashboard")] NotedeckApp::Dashboard(db) => db.update(ctx, ui), + #[cfg(feature = "nostrverse")] + NotedeckApp::Nostrverse(nostrverse) => nostrverse.update(ctx, ui), + NotedeckApp::Other(other) => other.update(ctx, ui), } } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -183,6 +183,11 @@ impl Chrome { #[cfg(feature = "clndash")] chrome.add_app(NotedeckApp::ClnDash(Box::default())); + #[cfg(feature = "nostrverse")] + chrome.add_app(NotedeckApp::Nostrverse(Box::new( + notedeck_nostrverse::NostrverseApp::demo(cc.wgpu_render_state.as_ref()), + ))); + chrome.set_active(0); Ok(chrome) @@ -840,6 +845,12 @@ fn topdown_sidebar( #[cfg(feature = "clndash")] NotedeckApp::ClnDash(_) => tr!(loc, "ClnDash", "Button to go to the ClnDash app"), + + #[cfg(feature = "nostrverse")] + NotedeckApp::Nostrverse(_) => { + tr!(loc, "Nostrverse", "Button to go to the Nostrverse app") + } + NotedeckApp::Other(_) => tr!(loc, "Other", "Button to go to the Other app"), }; @@ -887,6 +898,11 @@ fn topdown_sidebar( notebook_button(ui); } + #[cfg(feature = "nostrverse")] + NotedeckApp::Nostrverse(_nostrverse) => { + ui.add(app_images::universe_image()); + } + NotedeckApp::Other(_other) => { // app provides its own button rendering ui? panic!("TODO: implement other apps") diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -66,6 +66,15 @@ pub use vec3::Vec3; /// Default relay URL used for PNS event publishing and subscription. const DEFAULT_PNS_RELAY: &str = "ws://relay.jb55.com/"; +/// Normalize a relay URL to always have a trailing slash. +fn normalize_relay_url(url: String) -> String { + if url.ends_with('/') { + url + } else { + url + "/" + } +} + /// Extract a 32-byte secret key from a keypair. fn secret_key_bytes(keypair: KeypairUnowned<'_>) -> Option<[u8; 32]> { keypair.secret_key.map(|sk| { @@ -350,10 +359,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr tools.insert(tool.name().to_string(), tool); } - let pns_relay_url = model_config - .pns_relay - .clone() - .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string()); + let pns_relay_url = normalize_relay_url( + model_config + .pns_relay + .clone() + .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string()), + ); let directory_picker = DirectoryPicker::new(); @@ -422,10 +433,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Note: Provider changes require app restart to take effect. pub fn apply_settings(&mut self, settings: DaveSettings) { self.model_config = ModelConfig::from_settings(&settings); - self.pns_relay_url = settings - .pns_relay - .clone() - .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string()); + self.pns_relay_url = normalize_relay_url( + settings + .pns_relay + .clone() + .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string()), + ); self.settings_serializer.try_save(settings.clone()); self.settings = settings; } diff --git a/crates/notedeck_nostrverse/Cargo.toml b/crates/notedeck_nostrverse/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "notedeck_nostrverse" +edition = "2024" +version.workspace = true + +[dependencies] +notedeck = { workspace = true } +egui = { workspace = true } +egui-wgpu = { workspace = true } +enostr = { workspace = true } +glam = { workspace = true } +nostrdb = { workspace = true } +renderbud = { workspace = true } +tracing = { workspace = true } diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -0,0 +1,349 @@ +//! Nostrverse: Virtual rooms as Nostr events +//! +//! This app implements spatial views for nostrverse - a protocol where +//! rooms and objects are Nostr events (kinds 37555, 37556, 10555). +//! +//! Rooms are rendered as 3D scenes using renderbud's PBR pipeline, +//! embedded in egui via wgpu paint callbacks. + +mod room_state; +mod room_view; + +pub use room_state::{ + NostrverseAction, NostrverseState, Presence, Room, RoomObject, RoomRef, RoomShape, RoomUser, +}; +pub use room_view::{NostrverseResponse, render_inspection_panel, show_room_view}; + +use enostr::Pubkey; +use glam::Vec3; +use notedeck::{AppContext, AppResponse}; +use renderbud::Transform; + +use egui_wgpu::wgpu; + +/// Event kinds for nostrverse +pub mod kinds { + /// Room event kind (addressable) + pub const ROOM: u16 = 37555; + /// Object event kind (addressable) + pub const OBJECT: u16 = 37556; + /// Presence event kind (user-replaceable) + pub const PRESENCE: u16 = 10555; +} + +/// Nostrverse app - a 3D spatial canvas for virtual rooms +pub struct NostrverseApp { + /// Current room state + state: NostrverseState, + /// 3D renderer (None if wgpu unavailable) + renderer: Option<renderbud::egui::EguiRenderer>, + /// GPU device for model loading (Arc-wrapped internally by wgpu) + device: Option<wgpu::Device>, + /// GPU queue for model loading (Arc-wrapped internally by wgpu) + queue: Option<wgpu::Queue>, + /// Whether the app has been initialized with demo data + initialized: bool, +} + +impl NostrverseApp { + /// Create a new nostrverse app with a room reference + pub fn new(room_ref: RoomRef, render_state: Option<&egui_wgpu::RenderState>) -> Self { + let renderer = render_state.map(|rs| renderbud::egui::EguiRenderer::new(rs, (800, 600))); + + let device = render_state.map(|rs| rs.device.clone()); + let queue = render_state.map(|rs| rs.queue.clone()); + + Self { + state: NostrverseState::new(room_ref), + renderer, + device, + queue, + initialized: false, + } + } + + /// Create with a demo room + pub fn demo(render_state: Option<&egui_wgpu::RenderState>) -> Self { + let demo_pubkey = + Pubkey::from_hex("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245") + .unwrap_or_else(|_| { + Pubkey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .unwrap() + }); + + let room_ref = RoomRef::new("demo-room".to_string(), demo_pubkey); + Self::new(room_ref, render_state) + } + + /// Load a glTF model and return its handle + fn load_model(&self, path: &str) -> Option<renderbud::Model> { + let renderer = self.renderer.as_ref()?; + let device = self.device.as_ref()?; + let queue = self.queue.as_ref()?; + let mut r = renderer.renderer.lock().unwrap(); + match r.load_gltf_model(device, queue, path) { + Ok(model) => Some(model), + Err(e) => { + tracing::warn!("Failed to load model {}: {}", path, e); + None + } + } + } + + /// Initialize with demo data (for testing) + fn init_demo_data(&mut self) { + if self.initialized { + return; + } + + // Set up demo room + self.state.room = Some(Room { + name: "Demo Room".to_string(), + shape: RoomShape::Rectangle, + width: 20.0, + height: 15.0, + depth: 10.0, + }); + + // Load test models from disk + let bottle = self.load_model("/home/jb55/var/models/WaterBottle.glb"); + let ironwood = self.load_model("/home/jb55/var/models/ironwood/ironwood.glb"); + + // Query AABBs for placement + let renderer = self.renderer.as_ref(); + let model_bounds = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> { + let r = renderer?.renderer.lock().unwrap(); + r.model_bounds(m?) + }; + + let table_bounds = model_bounds(ironwood); + let bottle_bounds = model_bounds(bottle); + + // Table top Y (in model space, 1 unit = 1 meter) + let table_top_y = table_bounds.map(|b| b.max.y).unwrap_or(0.86); + // Bottle half-height (real-world scale, ~0.26m tall) + let bottle_half_h = bottle_bounds + .map(|b| (b.max.y - b.min.y) * 0.5) + .unwrap_or(0.0); + + // Ironwood (table) at origin + let mut obj1 = RoomObject::new( + "obj1".to_string(), + "Ironwood Table".to_string(), + Vec3::new(0.0, 0.0, 0.0), + ) + .with_scale(Vec3::splat(1.0)); + obj1.model_handle = ironwood; + + // Water bottle on top of the table: table_top + half bottle height + let mut obj2 = RoomObject::new( + "obj2".to_string(), + "Water Bottle".to_string(), + Vec3::new(0.0, table_top_y + bottle_half_h, 0.0), + ) + .with_scale(Vec3::splat(1.0)); + obj2.model_handle = bottle; + + self.state.objects = vec![obj1, obj2]; + + // Add demo users + let user1_pubkey = + Pubkey::from_hex("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245") + .unwrap_or_else(|_| { + Pubkey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .unwrap() + }); + + let user2_pubkey = + Pubkey::from_hex("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52") + .unwrap_or_else(|_| { + Pubkey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000002", + ) + .unwrap() + }); + + let agent_pubkey = + Pubkey::from_hex("ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49") + .unwrap_or_else(|_| { + Pubkey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000003", + ) + .unwrap() + }); + + self.state.users = vec![ + RoomUser::new(user1_pubkey, "jb55".to_string(), Vec3::new(-2.0, 0.0, -2.0)) + .with_self(true), + RoomUser::new( + user2_pubkey, + "fiatjaf".to_string(), + Vec3::new(3.0, 0.0, 1.0), + ), + RoomUser::new( + agent_pubkey, + "Claude".to_string(), + Vec3::new(-5.0, 0.0, 4.0), + ) + .with_agent(true), + ]; + + // Assign the bottle model as avatar placeholder for all users + if let Some(model) = bottle { + for user in &mut self.state.users { + user.model_handle = Some(model); + } + } + + // Switch to third-person camera mode centered on the self-user + if let Some(renderer) = &self.renderer { + let self_pos = self + .state + .users + .iter() + .find(|u| u.is_self) + .map(|u| u.position) + .unwrap_or(Vec3::ZERO); + let mut r = renderer.renderer.lock().unwrap(); + r.set_third_person_mode(self_pos); + } + + self.initialized = true; + } + + /// Sync room objects and user avatars to the renderbud scene + fn sync_scene(&mut self) { + let Some(renderer) = &self.renderer else { + return; + }; + let mut r = renderer.renderer.lock().unwrap(); + + // Sync room objects + for obj in &mut self.state.objects { + let transform = Transform { + translation: obj.position, + rotation: obj.rotation, + scale: obj.scale, + }; + + if let Some(scene_id) = obj.scene_object_id { + r.update_object_transform(scene_id, transform); + } else if let Some(model) = obj.model_handle { + let scene_id = r.place_object(model, transform); + obj.scene_object_id = Some(scene_id); + } + } + + // Read avatar position/yaw from the third-person controller + let avatar_pos = r.avatar_position(); + let avatar_yaw = r.avatar_yaw(); + + // Update self-user's position from the controller + if let Some(pos) = avatar_pos { + if let Some(self_user) = self.state.users.iter_mut().find(|u| u.is_self) { + self_user.position = pos; + } + } + + // Sync all user avatars to the scene + // Water bottle is ~0.26m; scale to human height (~1.8m) + let avatar_scale = 7.0_f32; + for user in &mut self.state.users { + let yaw = if user.is_self { + avatar_yaw.unwrap_or(0.0) + } else { + 0.0 + }; + + let transform = Transform { + translation: user.position, + rotation: glam::Quat::from_rotation_y(yaw), + scale: Vec3::splat(avatar_scale), + }; + + if let Some(scene_id) = user.scene_object_id { + r.update_object_transform(scene_id, transform); + } else if let Some(model) = user.model_handle { + let scene_id = r.place_object(model, transform); + user.scene_object_id = Some(scene_id); + } + } + } + + /// Get the current state + pub fn state(&self) -> &NostrverseState { + &self.state + } + + /// Get mutable state + pub fn state_mut(&mut self) -> &mut NostrverseState { + &mut self.state + } +} + +impl notedeck::App for NostrverseApp { + fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { + // Initialize demo data on first frame + self.init_demo_data(); + + // Sync state to 3D scene + self.sync_scene(); + + // Get available size before layout + let available = ui.available_size(); + + // Main layout with room view and optional inspection panel + ui.allocate_ui(available, |ui| { + ui.horizontal(|ui| { + // Reserve space for panel if needed + let room_width = if self.state.selected_object.is_some() { + available.x - 200.0 + } else { + available.x + }; + + ui.allocate_ui(egui::vec2(room_width, available.y), |ui| { + if let Some(renderer) = &self.renderer { + let response = show_room_view(ui, &mut self.state, renderer); + + // Handle actions from room view + if let Some(action) = response.action { + match action { + NostrverseAction::MoveObject { id, position } => { + tracing::info!("Object {} moved to {:?}", id, position); + } + NostrverseAction::SelectObject(selected) => { + self.state.selected_object = selected; + } + NostrverseAction::OpenAddObject => { + // TODO: Open add object dialog + } + } + } + } else { + ui.centered_and_justified(|ui| { + ui.label("3D rendering unavailable (no wgpu)"); + }); + } + }); + + // Inspection panel when object selected + if self.state.selected_object.is_some() { + ui.allocate_ui(egui::vec2(200.0, available.y), |ui| { + if let Some(action) = render_inspection_panel(ui, &mut self.state) + && let NostrverseAction::SelectObject(None) = action + { + self.state.selected_object = None; + } + }); + } + }); + }); + + AppResponse::none() + } +} diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -0,0 +1,207 @@ +//! Room state management for nostrverse views + +use enostr::Pubkey; +use glam::{Quat, Vec3}; +use renderbud::{Model, ObjectId}; + +/// Actions that can be triggered from the nostrverse view +#[derive(Clone, Debug)] +pub enum NostrverseAction { + /// Object was moved to a new position (id, new_pos) + MoveObject { id: String, position: Vec3 }, + /// Object was selected + SelectObject(Option<String>), + /// Request to open add object UI + OpenAddObject, +} + +/// Reference to a nostrverse room +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RoomRef { + /// Room identifier (d-tag) + pub id: String, + /// Room owner pubkey + pub pubkey: Pubkey, +} + +impl RoomRef { + pub fn new(id: String, pubkey: Pubkey) -> Self { + Self { id, pubkey } + } + + /// Get the NIP-33 "a" tag format + pub fn to_naddr(&self) -> String { + format!("{}:{}:{}", super::kinds::ROOM, self.pubkey.hex(), self.id) + } +} + +/// Parsed room data from event +#[derive(Clone, Debug)] +pub struct Room { + pub name: String, + pub shape: RoomShape, + pub width: f32, + pub height: f32, + pub depth: f32, +} + +impl Default for Room { + fn default() -> Self { + Self { + name: "Untitled Room".to_string(), + shape: RoomShape::Rectangle, + width: 20.0, + height: 15.0, + depth: 10.0, + } + } +} + +/// Room shape types +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum RoomShape { + #[default] + Rectangle, + Circle, + Custom, +} + +/// Object in a room - references a 3D model +#[derive(Clone, Debug)] +pub struct RoomObject { + pub id: String, + pub name: String, + /// URL to a glTF model (None = use placeholder geometry) + pub model_url: Option<String>, + /// 3D position in world space + pub position: Vec3, + /// 3D rotation + pub rotation: Quat, + /// 3D scale + pub scale: Vec3, + /// Runtime: renderbud scene object handle + pub scene_object_id: Option<ObjectId>, + /// Runtime: loaded model handle + pub model_handle: Option<Model>, +} + +impl RoomObject { + pub fn new(id: String, name: String, position: Vec3) -> Self { + Self { + id, + name, + model_url: None, + position, + rotation: Quat::IDENTITY, + scale: Vec3::ONE, + scene_object_id: None, + model_handle: None, + } + } + + pub fn with_model_url(mut self, url: String) -> Self { + self.model_url = Some(url); + self + } + + pub fn with_scale(mut self, scale: Vec3) -> Self { + self.scale = scale; + self + } +} + +/// User presence in a room (legacy, use RoomUser for rendering) +#[derive(Clone, Debug)] +pub struct Presence { + pub pubkey: Pubkey, + pub position: Vec3, + pub status: Option<String>, +} + +/// A user present in a room (for rendering) +#[derive(Clone, Debug)] +pub struct RoomUser { + pub pubkey: Pubkey, + pub display_name: String, + pub position: Vec3, + /// Whether this is the current user + pub is_self: bool, + /// Whether this user is an AI agent + pub is_agent: bool, + /// Runtime: renderbud scene object handle for avatar + pub scene_object_id: Option<ObjectId>, + /// Runtime: loaded model handle for avatar + pub model_handle: Option<Model>, +} + +impl RoomUser { + pub fn new(pubkey: Pubkey, display_name: String, position: Vec3) -> Self { + Self { + pubkey, + display_name, + position, + is_self: false, + is_agent: false, + scene_object_id: None, + model_handle: None, + } + } + + pub fn with_self(mut self, is_self: bool) -> Self { + self.is_self = is_self; + self + } + + pub fn with_agent(mut self, is_agent: bool) -> Self { + self.is_agent = is_agent; + self + } +} + +/// State for a nostrverse view +pub struct NostrverseState { + /// Reference to the room being viewed + pub room_ref: RoomRef, + /// Parsed room data (if loaded) + pub room: Option<Room>, + /// Objects in the room + pub objects: Vec<RoomObject>, + /// Users currently in the room + pub users: Vec<RoomUser>, + /// Currently selected object ID + pub selected_object: Option<String>, + /// Whether we're in edit mode + pub edit_mode: bool, +} + +impl NostrverseState { + pub fn new(room_ref: RoomRef) -> Self { + Self { + room_ref, + room: None, + objects: Vec::new(), + users: Vec::new(), + selected_object: None, + edit_mode: false, + } + } + + /// Add or update a user in the room + pub fn update_user(&mut self, user: RoomUser) { + if let Some(existing) = self.users.iter_mut().find(|u| u.pubkey == user.pubkey) { + *existing = user; + } else { + self.users.push(user); + } + } + + /// Remove a user from the room + pub fn remove_user(&mut self, pubkey: &Pubkey) { + self.users.retain(|u| &u.pubkey != pubkey); + } + + /// Get a mutable reference to an object by ID + pub fn get_object_mut(&mut self, id: &str) -> Option<&mut RoomObject> { + self.objects.iter_mut().find(|o| o.id == id) + } +} diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -0,0 +1,175 @@ +//! Room 3D rendering for nostrverse via renderbud + +use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui}; + +use super::room_state::{NostrverseAction, NostrverseState}; + +/// Response from rendering the nostrverse view +pub struct NostrverseResponse { + pub response: Response, + pub action: Option<NostrverseAction>, +} + +/// Render the nostrverse room view with 3D scene +pub fn show_room_view( + ui: &mut Ui, + state: &mut NostrverseState, + renderer: &renderbud::egui::EguiRenderer, +) -> NostrverseResponse { + let available_size = ui.available_size(); + let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag()); + + // Update renderer target size and handle input + { + let mut r = renderer.renderer.lock().unwrap(); + r.set_target_size((rect.width() as u32, rect.height() as u32)); + + // Handle mouse drag for camera look + if response.dragged() { + let delta = response.drag_delta(); + r.on_mouse_drag(delta.x, delta.y); + } + + // Handle scroll for speed adjustment + if response.hover_pos().is_some() { + let scroll = ui.input(|i| i.raw_scroll_delta.y); + if scroll.abs() > 0.0 { + r.on_scroll(scroll * 0.01); + } + } + + // WASD + QE movement + let dt = ui.input(|i| i.stable_dt); + let mut forward = 0.0_f32; + let mut right = 0.0_f32; + let mut up = 0.0_f32; + + ui.input(|i| { + if i.key_down(egui::Key::W) { + forward += 1.0; + } + if i.key_down(egui::Key::S) { + forward -= 1.0; + } + if i.key_down(egui::Key::D) { + right += 1.0; + } + if i.key_down(egui::Key::A) { + right -= 1.0; + } + if i.key_down(egui::Key::E) || i.key_down(egui::Key::Space) { + up += 1.0; + } + if i.key_down(egui::Key::Q) { + up -= 1.0; + } + }); + + if forward != 0.0 || right != 0.0 || up != 0.0 { + r.process_movement(forward, right, up, dt); + ui.ctx().request_repaint(); + } + } + + // Register the 3D scene paint callback + ui.painter().add(egui_wgpu::Callback::new_paint_callback( + rect, + renderbud::egui::SceneRender, + )); + + // Draw 2D overlays on top of the 3D scene + let painter = ui.painter_at(rect); + draw_info_overlay(&painter, state, rect); + + NostrverseResponse { + response, + action: None, + } +} + +fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rect) { + let room_name = state + .room + .as_ref() + .map(|r| r.name.as_str()) + .unwrap_or("Loading..."); + + let info_text = format!("{} | Objects: {}", room_name, state.objects.len()); + + // Background for readability + let text_pos = Pos2::new(rect.left() + 10.0, rect.top() + 10.0); + painter.rect_filled( + Rect::from_min_size( + Pos2::new(rect.left() + 4.0, rect.top() + 4.0), + egui::vec2(200.0, 24.0), + ), + 4.0, + Color32::from_rgba_unmultiplied(0, 0, 0, 160), + ); + + painter.text( + text_pos, + egui::Align2::LEFT_TOP, + info_text, + egui::FontId::proportional(14.0), + Color32::from_rgba_unmultiplied(200, 200, 210, 220), + ); +} + +/// Render the object inspection panel (side panel when object is selected) +pub fn render_inspection_panel( + ui: &mut Ui, + state: &mut NostrverseState, +) -> Option<NostrverseAction> { + let selected_id = state.selected_object.as_ref()?; + let obj = state.objects.iter().find(|o| &o.id == selected_id)?; + + let mut action = None; + + egui::Frame::default() + .fill(Color32::from_rgba_unmultiplied(30, 35, 45, 240)) + .inner_margin(12.0) + .outer_margin(8.0) + .corner_radius(8.0) + .stroke(Stroke::new(1.0, Color32::from_rgb(80, 90, 110))) + .show(ui, |ui| { + ui.set_min_width(180.0); + + ui.horizontal(|ui| { + ui.strong("Object Inspector"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("X").clicked() { + action = Some(NostrverseAction::SelectObject(None)); + } + }); + }); + + ui.separator(); + + ui.label(format!("Name: {}", obj.name)); + ui.label(format!( + "Position: ({:.1}, {:.1}, {:.1})", + obj.position.x, obj.position.y, obj.position.z + )); + ui.label(format!( + "Scale: ({:.1}, {:.1}, {:.1})", + obj.scale.x, obj.scale.y, obj.scale.z + )); + + if let Some(url) = &obj.model_url { + ui.separator(); + ui.small(format!("Model: {}", url)); + } + + ui.separator(); + + let id_display = if obj.id.len() > 16 { + format!("{}...", &obj.id[..16]) + } else { + obj.id.clone() + }; + ui.small(format!("ID: {}", id_display)); + }); + + action +}