use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; use anyhow::Error; use flume::{Receiver, Sender}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task, WeakEntity, }; use nostr_sdk::prelude::*; use utils::shorten_pubkey; use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; /// A user profile as plain data for the UI, from the kind-0 metadata. #[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())) } } /// How long to wait for more requests before firing a batched sync. const BATCH_TIMEOUT: Duration = Duration::from_millis(500); /// Global profile cache. /// /// Profiles are fetched in batches and kept as plain data. pub struct ProfileStore { profiles: HashMap, /// Public keys requested this session, main thread only. seen: RefCell>, /// Sender for queuing fetch requests, batched by a background task. sender: Sender, _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(updates) => { for update in updates .iter() .filter(|update| 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(); } _ => {} }); // Fetch requests are queued on a channel, batched into one sync per debounce window. let client = backend.read(cx).client(); let (sender, receiver) = flume::unbounded::(); let entity = cx.entity().downgrade(); cx.spawn(async move |_this, cx| { Self::handle_requests(entity, &client, &receiver, cx).await }) .detach(); let weak = cx.entity().downgrade(); cx.defer(move |cx| { if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) { log::warn!("profile store dropped before initial load could run: {error}"); } }); Self { profiles: HashMap::new(), seen: RefCell::new(HashSet::new()), sender, _subscription: subscription, } } /// Get a profile. /// /// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet. pub fn get(&self, public_key: &PublicKey) -> Profile { if let Some(profile) = self.profiles.get(public_key) { return profile.clone(); } let public_key = *public_key; if self.seen.borrow_mut().insert(public_key) && let Err(e) = self.sender.send(public_key) { log::warn!("failed to queue profile fetch: {e}"); } Profile::new(public_key, Metadata::default()) } /// Load recently seen profiles from the local database. fn load(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let client = backend.read(cx).client(); let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).limit(200); let events = client.database().query(filter).await?; // Parse off the main thread. // Only plain profiles cross back. let profiles: Vec = events .into_iter() .map(|event| { let metadata = Metadata::from_json(&event.content).unwrap_or_default(); Profile::new(event.pubkey, metadata) }) .collect(); Ok::<_, Error>(profiles) }); let task: Task> = cx.spawn(async move |this, cx| { let profiles = work.await?; this.update(cx, |this, cx| { for profile in profiles { this.profiles.insert(profile.public_key(), profile); } cx.notify(); })?; Ok(()) }); task.detach(); } /// Re-read the latest metadata of an author from the local database. fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context) { let backend = Backend::global(cx); let client = backend.read(cx).client(); let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).author(public_key); let events = client.database().query(filter).await?; // Parse off the main thread. // Only the profile crosses back. let profile = events .into_iter() .max_by_key(|e| e.created_at) .map(|event| { let metadata = Metadata::from_json(event.content).unwrap_or_default(); Profile::new(event.pubkey, metadata) }); Ok::<_, Error>(profile) }); let task: Task> = cx.spawn(async move |this, cx| { let profile = work.await?; this.update(cx, |this, cx| { if let Some(profile) = profile { this.profiles.insert(profile.public_key(), profile); cx.notify(); } })?; Ok(()) }); task.detach(); } /// Re-read the latest metadata of every requested author from the local database. /// /// Used after a sync, which produces no NostrUpdate events. fn apply_seen(&mut self, cx: &mut Context) { let authors: Vec = self.seen.borrow().iter().copied().collect(); if authors.is_empty() { return; } let backend = Backend::global(cx); let client = backend.read(cx).client(); let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).authors(authors); let events = client.database().query(filter).await?; // Pick the latest metadata per author off the main thread. let mut latest: HashMap = HashMap::new(); for event in events { match latest.get(&event.pubkey) { Some((ts, _)) if *ts >= event.created_at => {} _ => { latest.insert( event.pubkey, ( event.created_at, Metadata::from_json(&event.content).unwrap_or_default(), ), ); } } } let profiles: Vec = latest .into_iter() .map(|(public_key, (_, metadata))| Profile::new(public_key, metadata)) .collect(); Ok::<_, Error>(profiles) }); let task: Task> = cx.spawn(async move |this, cx| { let profiles = work.await?; this.update(cx, |this, cx| { for profile in profiles { this.profiles.insert(profile.public_key(), profile); } cx.notify(); })?; Ok(()) }); task.detach(); } /// Sync metadata for requested authors in batches, debounced to collect requests. /// /// After each batch, the seen profiles are re-read from the database on the main thread. async fn handle_requests( this: WeakEntity, client: &Client, receiver: &Receiver, cx: &mut AsyncApp, ) -> Result<(), Error> { let mut batch: HashSet = HashSet::new(); loop { // Wait for the first request of a batch. match receiver.recv_async().await { Ok(public_key) => { batch.insert(public_key); } Err(_) => return Ok(()), } // Collect everything that arrives within the debounce window. // The channel has no async timeout, race the receive against a timer. let deadline = Instant::now() + BATCH_TIMEOUT; loop { let now = Instant::now(); if now >= deadline { break; } let timer = cx.background_executor().timer(deadline - now); futures::pin_mut!(timer); let recv = receiver.recv_async(); futures::pin_mut!(recv); match futures::future::select(recv, timer).await { futures::future::Either::Left((Ok(public_key), _)) => { batch.insert(public_key); } futures::future::Either::Left((Err(_), _)) => return Ok(()), futures::future::Either::Right(_) => break, } } let filter = Filter::new() .kind(Kind::Metadata) .authors(batch.drain().collect::>()); // Negentropy-sync with the bootstrap relays. // Synced events are written to the database directly, no NostrUpdate. // Re-apply from the database afterwards. match sync_bootstrap_only(client, filter, SyncOptions::default()).await { Ok(_) => { this.update(cx, |this, cx| this.apply_seen(cx)).ok(); } Err(e) => log::warn!("profile sync failed: {e}"), } } } }