Files
coop/crates/person/src/lib.rs
T
2026-10-20 13:43:37 +00:00

355 lines
11 KiB
Rust

use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use anyhow::{Error, anyhow};
use common::emojis_from_tags;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
mod directory;
mod person;
mod wot;
pub use directory::*;
pub use person::*;
pub use wot::*;
pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
}
struct GlobalPersonRegistry(Entity<PersonRegistry>);
impl Global for GlobalPersonRegistry {}
/// A change to a person's data
#[derive(Debug, Clone)]
pub enum PersonEvent {
/// Profile metadata
Metadata(Box<Person>),
/// Custom emoji list (NIP-30)
Emojis {
public_key: PublicKey,
emojis: Vec<(String, Url)>,
},
/// Profile encryption keys announcement (NIP-4e)
Announcement {
public_key: PublicKey,
announcement: Announcement,
},
/// Messaging relay list (NIP-65)
MessagingRelays {
public_key: PublicKey,
relays: Vec<RelayUrl>,
},
}
impl EventEmitter<PersonEvent> for PersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Person),
Announcement(Event),
Relays(Event),
Emojis(Event),
}
/// Person Registry
#[derive(Debug)]
pub struct PersonRegistry {
/// Public keys with a metadata request in flight
in_flight: RwLock<HashSet<PublicKey>>,
/// Web of trust graph of the signed-in user
wot: Entity<WebOfTrust>,
sender: flume::Sender<PublicKey>,
tasks: SmallVec<[Task<()>; 4]>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl PersonRegistry {
/// Retrieve the global person registry
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalPersonRegistry>().0.clone()
}
/// Set the global person registry instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalPersonRegistry(state));
}
/// Create a new person registry instance
fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let wot = cx.new(WebOfTrust::new);
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100);
let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
let mut tasks = smallvec![];
let client2 = client.clone();
tasks.push(cx.background_spawn(async move {
Self::handle_notifications(&client2, &tx).await;
}));
let client3 = client.clone();
tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client3, &metadata_rx).await;
}));
tasks.push(cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
match event {
Dispatch::Person(person) => {
this.insert(person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
}
Dispatch::Relays(event) => {
this.set_messaging_relays(&event, cx);
}
Dispatch::Emojis(event) => {
this.set_emojis(&event, cx);
}
};
})
.ok();
}
}));
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
if event.signer_changed() {
this.get_emojis(cx);
this.wot.update(cx, |wot, cx| {
wot.reset(cx);
wot.hydrate(cx);
});
}
});
Self {
in_flight: RwLock::new(HashSet::new()),
wot,
sender: metadata_tx,
tasks,
_subscriptions: smallvec![subscription],
}
}
/// The web of trust graph of the signed-in user
pub fn wot(&self) -> Entity<WebOfTrust> {
self.wot.clone()
}
/// Handle nostr notifications
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
let mut notifications = client.notifications();
let mut processed: HashSet<EventId> = HashSet::new();
while let Some(notification) = notifications.next().await {
let ClientNotification::Message { message, .. } = notification else {
// Skip if the notification is not a message
continue;
};
if let RelayMessage::Event { event, .. } = *message {
// Skip if the event has already been processed
if !processed.insert(event.id) {
continue;
}
match event.kind {
Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
if tx.send_async(Dispatch::Person(person)).await.is_err() {
log::warn!("Channel closed, dropping metadata event");
}
}
Kind::InboxRelays => {
_ = tx.send_async(Dispatch::Relays(event.into_owned())).await;
}
Kind::Emojis => {
_ = tx.send_async(Dispatch::Emojis(event.into_owned())).await;
}
Kind::Custom(10044) => {
_ = tx
.send_async(Dispatch::Announcement(event.into_owned()))
.await;
}
_ => {}
}
}
}
}
/// Handle request for metadata
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
match flume::Selector::new()
.recv(rx, |result| result.ok())
.wait_timeout(Duration::from_secs(TIMEOUT))
{
Ok(Some(public_key)) => {
batch.insert(public_key);
// Process the batch if it's full
if batch.len() >= 20
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
}
_ => {
if !batch.is_empty()
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
}
}
}
}
/// Subscribe to the signed-in user's emoji list
fn get_emojis(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
self.tasks.push(cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new().kind(Kind::Emojis).author(public_key).limit(1);
if let Err(e) = client.subscribe(vec![filter]).close_on(opts).await {
log::warn!("Failed to subscribe to emoji list: {e}");
}
}));
}
/// Set profile encryption keys announcement
fn set_announcement(&mut self, event: &Event, cx: &mut Context<Self>) {
let Some(announcement) = Announcement::from_event(event) else {
log::warn!("Ignoring announcement with missing or invalid key tag");
return;
};
cx.emit(PersonEvent::Announcement {
public_key: event.pubkey,
announcement,
});
}
/// Set messaging relays for a person
fn set_messaging_relays(&mut self, event: &Event, cx: &mut Context<Self>) {
cx.emit(PersonEvent::MessagingRelays {
public_key: event.pubkey,
relays: nip17::extract_relay_list(event).collect(),
});
}
/// Set custom emoji for a person from their emoji list event
fn set_emojis(&mut self, event: &Event, cx: &mut Context<Self>) {
self.set_emojis_for(event.pubkey, emojis_from_tags(&event.tags), cx);
}
/// Set custom emoji for a person
fn set_emojis_for(
&mut self,
public_key: PublicKey,
emojis: Vec<(String, Url)>,
cx: &mut Context<Self>,
) {
cx.emit(PersonEvent::Emojis { public_key, emojis });
}
/// Publish a person's profile metadata to every directory
pub fn insert(&mut self, person: Person, cx: &mut Context<Self>) {
let public_key = person.public_key();
self.release_in_flight(&public_key);
cx.emit(PersonEvent::Metadata(Box::new(person)));
}
/// Queue a batched metadata request for keys without one in flight
pub(crate) fn fetch<I>(&self, keys: I)
where
I: IntoIterator<Item = PublicKey>,
{
let mut queue = Vec::new();
{
let mut in_flight = match self.in_flight.write() {
Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(),
};
for key in keys {
if in_flight.insert(key) {
queue.push(key);
}
}
}
for public_key in queue {
if let Err(error) = self.sender.send(public_key) {
log::warn!("Failed to queue metadata request: {error}");
}
}
}
/// Allow future fetches for a key once its metadata has arrived
fn release_in_flight(&self, public_key: &PublicKey) {
let mut in_flight = match self.in_flight.write() {
Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(),
};
in_flight.remove(public_key);
}
}
/// Get metadata for all public keys in a event
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
where
I: IntoIterator<Item = PublicKey>,
{
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
let limit = authors.len();
if authors.is_empty() {
return Err(anyhow!("You need at least one public key"));
}
// Construct the subscription option
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct the filter for metadata
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(authors)
.limit(limit);
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
client.subscribe(target).close_on(opts).await?;
Ok(())
}