From a785b9eea4713dafd1f645f5ec842f92c6c2c368 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 07:54:44 +0700 Subject: [PATCH] add profile store --- Cargo.lock | 1 + crates/signed_state/Cargo.toml | 1 + crates/signed_state/src/lib.rs | 20 ++- crates/signed_state/src/profile.rs | 225 +++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 crates/signed_state/src/profile.rs diff --git a/Cargo.lock b/Cargo.lock index dee9af4..fe4b891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7784,6 +7784,7 @@ dependencies = [ "anyhow", "flume 0.11.1", "gpui", + "log", "nostr", "nostr-connect", "nostr-sdk", diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index aec9e9c..cea9b9a 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -15,6 +15,7 @@ nostr-connect.workspace = true gpui.workspace = true flume.workspace = true anyhow.workspace = true +log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rustls = "0.23" diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index ac4bc56..25fe3ec 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,18 +1,19 @@ mod backend; +mod profile; mod repo; mod repo_list; -pub use backend::{Backend, BackendEvent}; -pub use repo::RepoStore; -pub use repo_list::RepoListStore; - use std::path::Path; +pub use backend::{Backend, BackendEvent}; use gpui::{App, AppContext, Entity}; +pub use profile::{Profile, ProfileStore, shorten_pubkey}; +pub use repo::RepoStore; +pub use repo_list::RepoListStore; use signed_nostr::NostrBackend; -/// Initialize the backend and install it as a global. Call once at startup, -/// before opening any window that uses the stores. +/// Initialize the backend and stores, and install them as globals. Call once +/// at startup, before opening any window that uses the stores. #[cfg(not(target_arch = "wasm32"))] pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { // rustls uses the `aws_lc_rs` provider by default; ignore if already installed. @@ -29,6 +30,9 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { let entity = cx.new(|cx| Backend::new(inner, cx)); Backend::set_global(entity.clone(), cx); + + ProfileStore::set_global(cx.new(ProfileStore::new), cx); + entity } @@ -36,7 +40,11 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { #[cfg(target_arch = "wasm32")] pub fn init(cx: &mut App) -> Entity { let inner = NostrBackend::new().expect("failed to initialize nostr backend"); + let entity = cx.new(|cx| Backend::new(inner, cx)); Backend::set_global(entity.clone(), cx); + + ProfileStore::set_global(cx.new(ProfileStore::new), cx); + entity } diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs new file mode 100644 index 0000000..b72f62c --- /dev/null +++ b/crates/signed_state/src/profile.rs @@ -0,0 +1,225 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use anyhow::Error; +use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task}; +use nostr_sdk::prelude::*; + +use crate::backend::{Backend, BackendEvent}; + +/// A user profile (kind `0` metadata), as plain data for the UI. +#[derive(Debug, Clone)] +pub struct Profile { + public_key: PublicKey, + metadata: Metadata, +} + +impl Profile { + pub fn new(public_key: PublicKey, metadata: Metadata) -> Self { + Self { + public_key, + metadata, + } + } + + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + pub fn metadata(&self) -> &Metadata { + &self.metadata + } + + /// Display name, falling back to `name`, then a shortened npub. + pub fn name(&self) -> SharedString { + if let Some(display_name) = self.metadata.display_name.as_ref() + && !display_name.is_empty() + { + return SharedString::from(display_name.trim().to_owned()); + } + + if let Some(name) = self.metadata.name.as_ref() + && !name.is_empty() + { + return SharedString::from(name.trim().to_owned()); + } + + SharedString::from(shorten_pubkey(self.public_key, 4)) + } + + /// Avatar URL, if set. + pub fn picture(&self) -> Option { + self.metadata + .picture + .as_ref() + .filter(|p| !p.is_empty()) + .map(|p| SharedString::from(p.clone())) + } +} + +/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form. +pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String { + let npub = public_key.to_bech32().unwrap(); + format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..]) +} + +/// Global profile cache. Profiles are fetched in batches and kept as plain +/// data; the whole store notifies on change. +pub struct ProfileStore { + profiles: HashMap, + /// Public keys we've already requested this session. + seen: HashSet, + /// Public keys queued for the next batched fetch. + queued: HashSet, + fetching: bool, + tasks: Vec>>, + _subscription: Subscription, +} + +struct GlobalProfileStore(Entity); + +impl Global for GlobalProfileStore {} + +impl ProfileStore { + /// Retrieve the global profile store. + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub(crate) fn set_global(entity: Entity, cx: &mut App) { + cx.set_global(GlobalProfileStore(entity)); + } + + pub(crate) fn new(cx: &mut Context) -> Self { + let backend = Backend::global(cx); + + let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event { + BackendEvent::NostrUpdate(update) if update.kind == Kind::Metadata => { + this.apply_author(update.author, cx); + } + BackendEvent::Published(event) if event.kind == Kind::Metadata => { + let metadata = Metadata::from_json(&event.content).unwrap_or_default(); + this.profiles + .insert(event.pubkey, Profile::new(event.pubkey, metadata)); + cx.notify(); + } + _ => {} + }); + + let mut store = Self { + profiles: HashMap::new(), + seen: HashSet::new(), + queued: HashSet::new(), + fetching: false, + tasks: Vec::new(), + _subscription: subscription, + }; + + store.load(cx); + store + } + + /// Get a profile. Returns a placeholder (default metadata) and queues a + /// fetch if the profile isn't cached yet. + pub fn get(&mut self, public_key: PublicKey, cx: &mut Context) -> Profile { + if let Some(profile) = self.profiles.get(&public_key) { + return profile.clone(); + } + + if self.seen.insert(public_key) { + self.queued.insert(public_key); + self.queue_fetch(cx); + } + + Profile::new(public_key, Metadata::default()) + } + + /// Load recently seen profiles from the local database. + fn load(&mut self, cx: &mut Context) { + let client = Backend::global(cx).read(cx).client(); + + let task = cx.spawn(async move |this, cx| { + let filter = Filter::new().kind(Kind::Metadata).limit(200); + let events = client.database().query(filter).await?; + + this.update(cx, |this, cx| { + for event in events { + let metadata = Metadata::from_json(&event.content).unwrap_or_default(); + this.profiles + .insert(event.pubkey, Profile::new(event.pubkey, metadata)); + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Re-read the latest metadata of an author from the local database. + fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context) { + let client = Backend::global(cx).read(cx).client(); + + let task = cx.spawn(async move |this, cx| { + let filter = Filter::new().kind(Kind::Metadata).author(public_key); + let events = client.database().query(filter).await?; + + if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) { + let metadata = Metadata::from_json(event.content).unwrap_or_default(); + + this.update(cx, |this, cx| { + this.profiles + .insert(public_key, Profile::new(public_key, metadata)); + cx.notify(); + })?; + } + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Drain the queue in a batched fetch, debounced to collect requests. + fn queue_fetch(&mut self, cx: &mut Context) { + if self.fetching { + return; + } + self.fetching = true; + + let client = Backend::global(cx).read(cx).client(); + + let task = cx.spawn(async move |this, cx| { + loop { + // Collect more requests before firing the batch. + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + + let batch = this.update(cx, |this, _cx| std::mem::take(&mut this.queued))?; + + if batch.is_empty() { + this.update(cx, |this, _cx| { + this.fetching = false; + })?; + break; + } + + let filter = Filter::new() + .kind(Kind::Metadata) + .authors(batch.into_iter().collect::>()); + + // Gossip routes the fetch to each author's relays. Fetched + // events land in the database and surface via NostrUpdate. + if let Err(e) = client.fetch_events(filter).await { + log::warn!("profile fetch failed: {e}"); + } + } + + Ok(()) + }); + + self.tasks.push(task); + } +}