feat: bind user metadata to per-view directories #64
@@ -8,7 +8,7 @@ use instant::Duration;
|
||||
use itertools::Itertools;
|
||||
use nip4e::DeviceRegistry;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person, PersonRegistry};
|
||||
use person::{Directory, Person};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
@@ -431,9 +431,10 @@ impl Room {
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Construct a direct message rumor event
|
||||
//
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||
tags.extend(PersonRegistry::emoji_tags(&content, cx));
|
||||
tags.extend(self.member(&sender, cx).emoji_tags(&content));
|
||||
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
|
||||
@@ -17,7 +17,7 @@ use gpui::{
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person, PersonRegistry};
|
||||
use person::{Directory, Person};
|
||||
use regex::Regex;
|
||||
use settings::{AppSettings, SignerKind};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -1770,7 +1770,18 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let emojis = PersonRegistry::current_emojis(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
let emojis = current_user
|
||||
.map(|current_user| {
|
||||
self.directory
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emojis()
|
||||
.to_vec()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Button::new("emoji")
|
||||
.icon(IconName::Emoji)
|
||||
|
||||
@@ -6,7 +6,6 @@ use chat::Message;
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
@@ -234,9 +233,9 @@ impl Group {
|
||||
&mut self,
|
||||
content: &str,
|
||||
replies: Vec<EventId>,
|
||||
emojis: Vec<Tag>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let emojis = PersonRegistry::emoji_tags(content, cx);
|
||||
self.dispatch(
|
||||
move |id, prev| id.message(content, &replies, &emojis, prev),
|
||||
cx,
|
||||
@@ -247,9 +246,9 @@ impl Group {
|
||||
&mut self,
|
||||
target: EventId,
|
||||
emoji: String,
|
||||
emojis: Vec<Tag>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let emojis = PersonRegistry::emoji_tags(&emoji, cx);
|
||||
self.dispatch(
|
||||
move |id, prev| id.reaction(target, &emoji, &emojis, prev),
|
||||
cx,
|
||||
|
||||
@@ -532,11 +532,11 @@ impl GroupPanel {
|
||||
}
|
||||
|
||||
let replies: Vec<EventId> = self.reply_to.take().into_iter().collect();
|
||||
let emojis = self.emoji_tags(&content, cx);
|
||||
|
||||
let Ok(send) = self
|
||||
.group
|
||||
.update(cx, |group, cx| group.send_message(&content, replies, cx))
|
||||
else {
|
||||
let Ok(send) = self.group.update(cx, |group, cx| {
|
||||
group.send_message(&content, replies, emojis, cx)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -613,6 +613,18 @@ impl GroupPanel {
|
||||
.and_then(|group| Notice::of(group.read(cx)))
|
||||
}
|
||||
|
||||
/// The signed-in user's custom emoji tags referenced in `content`
|
||||
fn emoji_tags(&self, content: &str, cx: &App) -> Vec<Tag> {
|
||||
let Some(current_user) = NostrRegistry::global(cx).read(cx).current_user() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
self.directory
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emoji_tags(content)
|
||||
}
|
||||
|
||||
fn react(
|
||||
&mut self,
|
||||
group: WeakEntity<Group>,
|
||||
@@ -621,7 +633,10 @@ impl GroupPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, cx)) else {
|
||||
let emojis = self.emoji_tags(&emoji, cx);
|
||||
|
||||
let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, emojis, cx))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use gpui::{
|
||||
};
|
||||
use nip29::{Activity, Group, Reaction};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use person::{Directory, PersonRegistry};
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
@@ -164,7 +164,22 @@ fn react_menu(
|
||||
)
|
||||
});
|
||||
|
||||
let emojis = PersonRegistry::current_emojis(cx);
|
||||
let emojis = panel
|
||||
.upgrade()
|
||||
.and_then(|panel| {
|
||||
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
|
||||
Some(
|
||||
panel
|
||||
.read(cx)
|
||||
.directory
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emojis()
|
||||
.to_vec(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if emojis.is_empty() {
|
||||
return menu;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, Entity, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -39,8 +40,11 @@ impl Directory {
|
||||
})
|
||||
}
|
||||
|
||||
/// Track keys: load known profiles from the database, request the rest
|
||||
/// Load known profiles from the database, request the rest
|
||||
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>) {
|
||||
/// Profiles and emoji lists loaded from the database
|
||||
type Loaded = (Vec<Person>, Vec<(PublicKey, Vec<(String, Url)>)>);
|
||||
|
||||
let missing: Vec<PublicKey> = keys
|
||||
.into_iter()
|
||||
.filter(|key| {
|
||||
@@ -54,29 +58,53 @@ impl Directory {
|
||||
}
|
||||
|
||||
// Accept events that arrive while the database query is running
|
||||
self.with_pending(|pending| pending.extend(missing.iter().copied()));
|
||||
self.with_pending(|pending| {
|
||||
pending.extend(missing.iter().copied());
|
||||
});
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let authors = missing.clone();
|
||||
|
||||
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
let mut events: Vec<Event> =
|
||||
client.database().query(filter).await?.into_iter().collect();
|
||||
let task: Task<Result<Loaded, Error>> = cx.background_spawn(async move {
|
||||
let mut metadata_events: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::Metadata).authors(authors.clone()))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Keep only the newest event per author
|
||||
events.sort_by_key(|event| Reverse(event.created_at));
|
||||
metadata_events.sort_by_key(|event| Reverse(event.created_at));
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
Ok(events
|
||||
let persons = metadata_events
|
||||
.into_iter()
|
||||
.filter(|event| seen.insert(event.pubkey))
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Person::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
|
||||
let mut emoji_events: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::Emojis).authors(authors))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Keep only the newest event per author
|
||||
emoji_events.sort_by_key(|event| Reverse(event.created_at));
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let emojis = emoji_events
|
||||
.into_iter()
|
||||
.filter(|event| seen.insert(event.pubkey))
|
||||
.map(|event| (event.pubkey, emojis_from_tags(&event.tags)))
|
||||
.collect();
|
||||
|
||||
Ok((persons, emojis))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -84,17 +112,38 @@ impl Directory {
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let remaining = match loaded {
|
||||
Ok(persons) => {
|
||||
Ok((persons, emojis)) => {
|
||||
let found: HashSet<PublicKey> =
|
||||
persons.iter().map(Person::public_key).collect();
|
||||
this.bulk_insert(persons, cx);
|
||||
|
||||
// Emojis apply on top of loaded or already received data
|
||||
let mut updated = false;
|
||||
|
||||
for (public_key, emojis) in emojis {
|
||||
if emojis.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let person = this
|
||||
.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| Person::new(public_key, Metadata::default()));
|
||||
person.set_emojis(emojis);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
missing
|
||||
.into_iter()
|
||||
.filter(|key| !found.contains(key))
|
||||
.collect()
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("Failed to load persons from the local database: {error}");
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load persons from the database: {e}");
|
||||
missing
|
||||
}
|
||||
};
|
||||
@@ -122,17 +171,36 @@ impl Directory {
|
||||
Person::new(*public_key, Metadata::default())
|
||||
}
|
||||
|
||||
/// Accept events for tracked keys and drop the rest
|
||||
/// Merge event data into the tracked person
|
||||
fn handle_event(&mut self, event: PersonEvent, cx: &mut Context<Self>) {
|
||||
let PersonEvent::Updated(person) = event;
|
||||
let public_key = person.public_key();
|
||||
let public_key = match &event {
|
||||
PersonEvent::Metadata(person) => person.public_key(),
|
||||
PersonEvent::Emojis { public_key, .. }
|
||||
| PersonEvent::Announcement { public_key, .. }
|
||||
| PersonEvent::MessagingRelays { public_key, .. } => *public_key,
|
||||
};
|
||||
|
||||
if self.persons.contains_key(&public_key)
|
||||
|| self.with_pending(|pending| pending.remove(&public_key))
|
||||
if !self.persons.contains_key(&public_key)
|
||||
&& !self.with_pending(|pending| pending.remove(&public_key))
|
||||
{
|
||||
self.persons.insert(public_key, person);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let person = self
|
||||
.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| Person::new(public_key, Metadata::default()));
|
||||
|
||||
match event {
|
||||
PersonEvent::Metadata(updated) => person.set_metadata(updated.metadata()),
|
||||
PersonEvent::Emojis { emojis, .. } => person.set_emojis(emojis),
|
||||
PersonEvent::Announcement { announcement, .. } => {
|
||||
person.set_announcement(announcement);
|
||||
}
|
||||
PersonEvent::MessagingRelays { relays, .. } => person.set_messaging_relays(relays),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Insert loaded persons, keeping data already received from relays
|
||||
@@ -161,7 +229,6 @@ impl Directory {
|
||||
Ok(pending) => pending,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
|
||||
then(&mut pending)
|
||||
}
|
||||
}
|
||||
|
||||
+29
-201
@@ -1,9 +1,8 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::{EventExt, emojis_from_tags};
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -27,7 +26,23 @@ impl Global for GlobalPersonRegistry {}
|
||||
/// A change to a person's data
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PersonEvent {
|
||||
Updated(Person),
|
||||
/// 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 {}
|
||||
@@ -43,10 +58,6 @@ enum Dispatch {
|
||||
/// Person Registry
|
||||
#[derive(Debug)]
|
||||
pub struct PersonRegistry {
|
||||
/// Collection of all persons (user profiles)
|
||||
persons: HashMap<PublicKey, Entity<Person>>,
|
||||
/// Set of public keys that have been seen
|
||||
seen: RwLock<HashSet<PublicKey>>,
|
||||
/// Public keys with a metadata request in flight
|
||||
in_flight: RwLock<HashSet<PublicKey>>,
|
||||
sender: flume::Sender<PublicKey>,
|
||||
@@ -69,7 +80,6 @@ impl PersonRegistry {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||
@@ -115,17 +125,7 @@ impl PersonRegistry {
|
||||
}
|
||||
});
|
||||
|
||||
// Load all user profiles from the database
|
||||
cx.defer(move |cx| {
|
||||
_ = entity.update(cx, |this, cx| {
|
||||
this.load(cx);
|
||||
this.load_emojis(cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
persons: HashMap::new(),
|
||||
seen: RwLock::new(HashSet::new()),
|
||||
in_flight: RwLock::new(HashSet::new()),
|
||||
sender: metadata_tx,
|
||||
tasks,
|
||||
@@ -159,12 +159,6 @@ impl PersonRegistry {
|
||||
log::warn!("Channel closed, dropping metadata event");
|
||||
}
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
if let Err(e) = get_metadata(client, public_keys).await {
|
||||
log::warn!("Failed to get metadata for contact list: {e}");
|
||||
}
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
_ = tx.send_async(Dispatch::Relays(event.into_owned())).await;
|
||||
}
|
||||
@@ -211,72 +205,6 @@ impl PersonRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all user profiles from the database
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
let persons = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Person::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(persons)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(persons) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.bulk_insert(persons, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Load cached emoji lists from the database
|
||||
fn load_emojis(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
type EmojiLists = Vec<(PublicKey, Vec<(String, Url)>)>;
|
||||
|
||||
let task: Task<Result<EmojiLists, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Emojis).limit(200);
|
||||
let mut events: Vec<Event> =
|
||||
client.database().query(filter).await?.into_iter().collect();
|
||||
|
||||
// Keep only the newest list per author
|
||||
events.sort_by_key(|event| Reverse(event.created_at));
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let lists = events
|
||||
.into_iter()
|
||||
.filter(|event| seen.insert(event.pubkey))
|
||||
.map(|event| (event.pubkey, emojis_from_tags(&event.tags)))
|
||||
.collect();
|
||||
|
||||
Ok(lists)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(lists) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
for (public_key, emojis) in lists {
|
||||
this.set_emojis_for(public_key, emojis, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Subscribe to the signed-in user's emoji list
|
||||
fn get_emojis(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -302,33 +230,18 @@ impl PersonRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_announcement(announcement);
|
||||
cx.notify();
|
||||
});
|
||||
cx.emit(PersonEvent::Updated(person.read(cx).clone()));
|
||||
} else {
|
||||
let person =
|
||||
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
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>) {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_messaging_relays(urls);
|
||||
cx.notify();
|
||||
});
|
||||
cx.emit(PersonEvent::Updated(person.read(cx).clone()));
|
||||
} else {
|
||||
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
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
|
||||
@@ -343,77 +256,15 @@ impl PersonRegistry {
|
||||
emojis: Vec<(String, Url)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(person) = self.persons.get(&public_key) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_emojis(emojis);
|
||||
cx.notify();
|
||||
});
|
||||
cx.emit(PersonEvent::Updated(person.read(cx).clone()));
|
||||
} else {
|
||||
let person = Person::new(public_key, Metadata::default()).with_emojis(emojis);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
cx.emit(PersonEvent::Emojis { public_key, emojis });
|
||||
}
|
||||
|
||||
/// Insert batch of persons
|
||||
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
for person in persons {
|
||||
let public_key = person.public_key();
|
||||
|
||||
if self.persons.contains_key(&public_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cx.emit(PersonEvent::Updated(person.clone()));
|
||||
self.persons.insert(public_key, cx.new(|_| person));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Insert or update a person
|
||||
/// 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);
|
||||
|
||||
match self.persons.get(&public_key) {
|
||||
Some(this) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_metadata(person.metadata());
|
||||
cx.notify();
|
||||
});
|
||||
cx.emit(PersonEvent::Updated(this.read(cx).clone()));
|
||||
}
|
||||
None => {
|
||||
cx.emit(PersonEvent::Updated(person.clone()));
|
||||
self.persons.insert(public_key, cx.new(|_| person));
|
||||
// Views rendering a placeholder only observed this registry
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get single person by public key
|
||||
pub fn get(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||
if let Some(person) = self.persons.get(public_key) {
|
||||
return person.read(cx).clone();
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
|
||||
if self.seen.write().unwrap().insert(public_key) {
|
||||
let sender = self.sender.clone();
|
||||
|
||||
// Spawn background task to request metadata
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = sender.send_async(public_key).await {
|
||||
log::warn!("Failed to send public key for metadata request: {e}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
// Return a temporary profile with default metadata
|
||||
Person::new(public_key, Metadata::default())
|
||||
cx.emit(PersonEvent::Metadata(Box::new(person)));
|
||||
}
|
||||
|
||||
/// Queue a batched metadata request for keys without one in flight
|
||||
@@ -450,29 +301,6 @@ impl PersonRegistry {
|
||||
};
|
||||
in_flight.remove(public_key);
|
||||
}
|
||||
|
||||
/// The signed-in user's custom emoji
|
||||
pub fn current_emojis(cx: &App) -> Vec<(String, Url)> {
|
||||
let registry = Self::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
registry.read(cx).get(&public_key, cx).emojis().to_vec()
|
||||
}
|
||||
|
||||
/// An `emoji` tag for every signed-in user's custom emoji referenced.
|
||||
pub fn emoji_tags(content: &str, cx: &App) -> Vec<Tag> {
|
||||
let registry = Self::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
registry.read(cx).get(&public_key, cx).emoji_tags(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metadata for all public keys in a event
|
||||
|
||||
@@ -57,21 +57,6 @@ impl Person {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build profile encryption keys announcement
|
||||
pub(crate) fn with_announcement(mut self, announcement: Announcement) -> Self {
|
||||
self.announcement = Some(announcement);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build profile messaging relays
|
||||
pub(crate) fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Get profile public key
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
@@ -92,12 +77,6 @@ impl Person {
|
||||
self.emojis = emojis;
|
||||
}
|
||||
|
||||
/// Build custom emoji from the person's emoji list
|
||||
pub(crate) fn with_emojis(mut self, emojis: Vec<(String, Url)>) -> Self {
|
||||
self.emojis = emojis;
|
||||
self
|
||||
}
|
||||
|
||||
/// An `emoji` tag for every custom emoji referenced as `:shortcode:` in `content`
|
||||
pub fn emoji_tags(&self, content: &str) -> Vec<Tag> {
|
||||
let mut tags = Vec::new();
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||
use settings::AppSettings;
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||
@@ -34,6 +34,8 @@ struct ProfileDialog {
|
||||
public_key: PublicKey,
|
||||
/// Whether the profile belongs to the signed-in user
|
||||
is_current_user: bool,
|
||||
/// Person directory for the profile
|
||||
directory: Entity<Directory>,
|
||||
/// Whether the metadata inputs are shown
|
||||
editing: bool,
|
||||
/// User's name text input
|
||||
@@ -74,9 +76,15 @@ impl ProfileDialog {
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
let directory = Directory::new(cx);
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([public_key], cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
is_current_user,
|
||||
directory,
|
||||
editing: false,
|
||||
name_input,
|
||||
avatar_input,
|
||||
@@ -90,8 +98,7 @@ impl ProfileDialog {
|
||||
}
|
||||
|
||||
fn profile(&self, cx: &App) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(&self.public_key, cx)
|
||||
self.directory.read(cx).person(&self.public_key, cx)
|
||||
}
|
||||
|
||||
fn set_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -238,9 +245,8 @@ impl ProfileDialog {
|
||||
}
|
||||
|
||||
fn save(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let public_key = self.public_key;
|
||||
let old_metadata = persons.read(cx).get(&public_key, cx).metadata();
|
||||
let old_metadata = self.directory.read(cx).person(&public_key, cx).metadata();
|
||||
|
||||
// Extract all new metadata fields
|
||||
let avatar = self.avatar_input.read(cx).value();
|
||||
@@ -274,9 +280,9 @@ impl ProfileDialog {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
// Update the registry
|
||||
persons.update(cx, |this, cx| {
|
||||
this.insert(Person::new(public_key, new_metadata), cx);
|
||||
// Publish the new metadata to every directory
|
||||
PersonRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.insert(Person::new(public_key, new_metadata), cx);
|
||||
});
|
||||
|
||||
this.set_updating(false, cx);
|
||||
|
||||
@@ -142,15 +142,27 @@ impl Sidebar {
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&nostr, window, |this, _, event, window, cx| {
|
||||
if let StateEvent::NoSigner = event {
|
||||
this.banner = onboarding::pick_banner();
|
||||
if this.search_open {
|
||||
this.toggle_search(window, cx);
|
||||
cx.subscribe_in(
|
||||
&nostr,
|
||||
window,
|
||||
|this, state, event, window, cx| match event {
|
||||
StateEvent::NoSigner => {
|
||||
this.banner = onboarding::pick_banner();
|
||||
if this.search_open {
|
||||
this.toggle_search(window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}),
|
||||
StateEvent::SignerChanged => {
|
||||
if let Some(current_user) = state.read(cx).current_user() {
|
||||
this.directory.update(cx, |directory, cx| {
|
||||
directory.track([current_user], cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
# Per-view person directories
|
||||
|
||||
Refactor plan for how user metadata is stored and observed. Today a single global
|
||||
`PersonRegistry` owns every profile and every render path reads through it. The
|
||||
proposal binds metadata to the panel/screen that renders it, and shrinks the
|
||||
global to a thin event pump.
|
||||
|
||||
## Goal
|
||||
|
||||
- A panel that renders profiles owns its metadata store (`Entity<Directory>`),
|
||||
populated from the set of public keys that panel actually displays.
|
||||
- Re-renders are scoped: a metadata update only re-renders panels observing the
|
||||
directory it landed in, not every view that ever read a global.
|
||||
- Cache lifetime follows view lifetime: directories drop with their panel, so
|
||||
the global map never grows unbounded.
|
||||
- The global registry becomes thin: nostr notification pump, batched request
|
||||
coordinator, and the signed-in user's own profile/emojis. No global person map.
|
||||
|
||||
## Current behavior, and two latent bugs
|
||||
|
||||
Flow today: render calls `PersonRegistry::global(cx).read(cx).get(&pk, cx)` →
|
||||
registry returns a cloned `Person` from its map, or a default placeholder and
|
||||
pushes the key to a batching channel → relay events arrive via
|
||||
`handle_notifications` → `insert()` updates or creates `Entity<Person>`.
|
||||
|
||||
Bug 1 — updates never reach the UI reliably. `get()` returns a cloned value, so
|
||||
views observe the *registry*, never the `Entity<Person>` handles. But the
|
||||
mutation paths (`insert`, `set_announcement`, `set_messaging_relays`,
|
||||
`set_emojis_for`) only call `cx.notify()` on the **person entity** in the
|
||||
existing-person branch, and `insert` notifies nothing for new persons. So live
|
||||
metadata arriving for an already-rendered placeholder does not trigger a
|
||||
re-render; the UI is patched by unrelated renders (messages arriving, etc.).
|
||||
Only `bulk_insert` (startup DB load) notifies the registry.
|
||||
|
||||
Bug 2 — the startup DB load is unscoped. `load()` queries
|
||||
`Filter::new().kind(Metadata).limit(200)` — any 200 metadata events in the local
|
||||
database, not the people the app will show.
|
||||
|
||||
Both problems disappear when the store is bound to the view that reads it.
|
||||
|
||||
## Proposed architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ notifications ┌─────────────────────────────┐
|
||||
│ nostr Client │─────────────────▶│ PersonRegistry (thin, global)│
|
||||
└──────────────┘ │ • pump → emits PersonEvent │
|
||||
│ • fetch(keys): dedupe + │
|
||||
│ batched relay subscribe │
|
||||
│ • current user's profile + │
|
||||
│ emojis (send paths) │
|
||||
└──────────┬──────────────────┘
|
||||
PersonEvent │ fetch(keys)
|
||||
┌────────────────────────┼───────────────────────┐
|
||||
▼ ▼ ▼
|
||||
Entity<Directory> Entity<Directory> Entity<Directory>
|
||||
(ChatPanel) (Sidebar) (ContactListPanel)
|
||||
▲ ▲ ▲
|
||||
└───────── panels read during render ────────────┘
|
||||
```
|
||||
|
||||
New `Directory` type in `crates/person`:
|
||||
|
||||
```rust
|
||||
pub struct Directory {
|
||||
persons: HashMap<PublicKey, Person>, // values, not entities
|
||||
pending: HashSet<PublicKey>, // on-miss dedupe, replaces global `seen`
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>, // PersonRegistry events
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
pub fn new(cx: &mut App) -> Entity<Self>;
|
||||
/// Diff against known keys, load missing from the local DB scoped by
|
||||
/// author, request the rest through the registry coordinator.
|
||||
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>);
|
||||
/// Hit returns the stored person, miss returns a default placeholder and
|
||||
/// requests it (same ergonomics as `PersonRegistry::get` today).
|
||||
pub fn person(&self, public_key: &PublicKey, cx: &App) -> Person;
|
||||
}
|
||||
```
|
||||
|
||||
- Panels create a directory and call `track(authors)` whenever their key set
|
||||
changes (room event appends messages, contacts load, members load). Render
|
||||
swaps `PersonRegistry::global(cx).read(cx).get(...)` for
|
||||
`self.directory.read(cx).person(...)`. Reading the directory during render is
|
||||
enough for GPUI to re-render the panel when the directory notifies.
|
||||
- Store `Person` values, not `Entity<Person>`: nothing observes person entities
|
||||
today, and a panel re-renders wholesale anyway. One `cx.notify()` per
|
||||
incoming batch. Per-person entities can come later if fine-grained
|
||||
invalidation is ever profiled as needed.
|
||||
- The registry emits `PersonEvent::Updated(Person)` for every metadata /
|
||||
announcement / relay-list / emoji event it pumps. Directories merge events
|
||||
for keys they track and notify.
|
||||
- `fetch(keys)` keeps the existing batching channel (20-key batches,
|
||||
`TIMEOUT` timeout) plus an in-flight set so two panels tracking the same
|
||||
person trigger one relay subscription. Results flow back through the pump,
|
||||
so the coordinator stays stateless beyond in-flight tracking.
|
||||
|
||||
### Event flow
|
||||
|
||||
How a relay event becomes a panel re-render:
|
||||
|
||||
1. The registry pump converts nostr notifications into events. `cx.emit` is
|
||||
synchronous — subscribers run during the registry's update:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PersonEvent {
|
||||
Updated(Person), // metadata, emojis, announcement, or relays merged in
|
||||
}
|
||||
|
||||
impl EventEmitter<PersonEvent> for PersonRegistry {}
|
||||
|
||||
// inside the registry's foreground loop (Context<PersonRegistry>):
|
||||
match dispatch {
|
||||
Dispatch::Person(person) => cx.emit(PersonEvent::Updated(person)),
|
||||
// announcements, relay lists, emojis merge into `person` upstream
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
2. Each directory subscribes at creation. The subscription lives in a field,
|
||||
so it is dropped when the panel (and its directory) is dropped:
|
||||
|
||||
```rust
|
||||
impl Directory {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let registry = PersonRegistry::global(cx);
|
||||
let subscription = cx.subscribe(®istry, |this, _registry, event, cx| {
|
||||
this.handle_event(event.clone(), cx);
|
||||
});
|
||||
|
||||
cx.new(|_| Self {
|
||||
persons: HashMap::new(),
|
||||
pending: HashSet::new(),
|
||||
_subscriptions: smallvec![subscription],
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: PersonEvent, cx: &mut Context<Self>) {
|
||||
let PersonEvent::Updated(person) = event;
|
||||
let public_key = person.public_key();
|
||||
|
||||
// Only react to keys this panel actually renders
|
||||
if self.persons.contains_key(&public_key) || self.pending.remove(&public_key) {
|
||||
self.persons.insert(public_key, person);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `pending` check is the on-miss path: when `person()` was called during
|
||||
render for an unknown key, it returned a placeholder, queued a fetch, and
|
||||
marked the key pending. When the event lands, the key is accepted, merged,
|
||||
and the directory notifies.
|
||||
|
||||
3. The panel re-renders because it read the directory during render. GPUI
|
||||
tracks entities read while rendering a view, so
|
||||
`self.directory.read(cx).person(...)` registers the dependency implicitly.
|
||||
When the directory calls `cx.notify()`, GPUI re-renders exactly the views
|
||||
that read it — the chat panel, not the sidebar, not other rooms. Explicit
|
||||
wiring is only needed if the panel must run *logic* on change (e.g. touch
|
||||
`ListState` item heights): `cx.observe(&directory, |_, _, cx| cx.notify()).detach()`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Relay as Nostr relays
|
||||
participant Pump as PersonRegistry (pump task)
|
||||
participant Dir as Directory (owned by ChatPanel)
|
||||
participant Panel as ChatPanel render
|
||||
|
||||
Panel->>Dir: person(&author) on miss
|
||||
Dir->>Pump: fetch(key) via batching channel
|
||||
Relay->>Pump: kind:0 metadata event
|
||||
Pump->>Dir: cx.emit(PersonEvent::Updated)
|
||||
Dir->>Dir: merge + cx.notify()
|
||||
Panel->>Panel: re-render with real name/avatar
|
||||
```
|
||||
|
||||
This is what fixes bug 1: today the same incoming metadata calls `cx.notify()`
|
||||
on the `Entity<Person>`, an entity nobody observes, and the update dies there.
|
||||
In the new design the notify lands on the directory, which the view observed
|
||||
when it read it during render.
|
||||
|
||||
### Send paths
|
||||
|
||||
`emoji_tags` / `current_emojis` are used when *signing* events
|
||||
(`chat::Room::rumor`, `nip29::Group::send_message/send_reaction`) and by emoji
|
||||
menus, regardless of which panel is visible. These keep reading the registry,
|
||||
which retains the signed-in user's own profile and emoji list. The API stays
|
||||
`PersonRegistry::current_emojis(cx)` / `::emoji_tags(content, cx)` so call
|
||||
sites don't change. The `Kind::ContactList` metadata warm-up moves out of the
|
||||
pump — panels fetch the keys they display.
|
||||
|
||||
### Why this fits GPUI
|
||||
|
||||
Observation becomes exact: a panel re-renders when its own directory changes.
|
||||
This is also what makes render caching (`cached_view`-style reuse) correct —
|
||||
which additionally requires `Person`'s `PartialEq` to compare *content*
|
||||
(metadata, emojis, announcement, relays), not just the public key as it does
|
||||
today, otherwise a cached render won't invalidate when a profile actually
|
||||
changes.
|
||||
|
||||
## Migration
|
||||
|
||||
Each phase compiles and ships independently.
|
||||
|
||||
1. **Events + notify fix (small).** Add `EventEmitter<PersonEvent>` to
|
||||
`PersonRegistry`, emit from every mutation path, and make `insert` notify
|
||||
the registry for new persons (fixes bug 1 for the current design too). No
|
||||
call site changes.
|
||||
2. **Add `Directory` (medium).** New type per the sketch above, subscribing to
|
||||
registry events and fetching via the coordinator. Unit-testable in
|
||||
`TestAppContext` by feeding events directly.
|
||||
3. **Convert call sites (bulk, mechanical).** One panel/screen at a time:
|
||||
- `chat_ui::ChatPanel` — directory per panel, `track` in the room-event
|
||||
handler; pass the directory into `chat_ui::text::rendered_text` instead of
|
||||
`Entity<PersonRegistry>`.
|
||||
- `nip29_ui` — `message::render`, `render_activity`, `replies`,
|
||||
`GroupPanel::render_row` / `render_reply_target` take `&Entity<Directory>`
|
||||
as a parameter; the panel owns the directory.
|
||||
- `workspace` sidebar — one directory for the sidebar (`header::render_user`,
|
||||
`rows::render_rows`, `group_view::member_row`).
|
||||
- `workspace` dialogs — per-dialog directories: `contact_list.rs`,
|
||||
`new_chat.rs`, `profile.rs`, `screening.rs`. They drop with the dialog.
|
||||
- `chat::Room` render helpers (`display_member`, `merged_name`,
|
||||
`conversation_tags`) — the room owns a directory or receives names from
|
||||
the panel; send paths keep registry emoji helpers.
|
||||
- `nip4e::DeviceRegistry::notification` — one-off name lookup for a
|
||||
notification; give it a small directory or fetch-once (open question).
|
||||
4. **Slim the registry (small).** Delete the global person map, `seen`,
|
||||
`bulk_insert`, and unscoped `load()` (fixes bug 2). Keep pump, `fetch`,
|
||||
current-user profile/emojis. `person()`/`get()` on the registry goes away;
|
||||
directories are the only read path for rendering.
|
||||
|
||||
## Call site inventory
|
||||
|
||||
| File | Functions |
|
||||
| --- | --- |
|
||||
| `crates/chat/src/room.rs` | `display_member`, `merged_name`, `conversation_tags`, `rumor`, `send` |
|
||||
| `crates/chat_ui/src/lib.rs` | `profile`, `render_message`, `render_report`, `render_reply`, `render_emoji_menu` |
|
||||
| `crates/chat_ui/src/text.rs` | `rendered_text` |
|
||||
| `crates/nip29/src/group.rs` | `send_message`, `send_reaction` (emoji tags only) |
|
||||
| `crates/nip29_ui/src/lib.rs` | `render_row`, `render_reply_target` |
|
||||
| `crates/nip29_ui/src/message.rs` | `render`, `react_menu`, `render_activity`, `replies` |
|
||||
| `crates/nip4e/src/lib.rs` | `DeviceRegistry::notification` |
|
||||
| `crates/workspace/src/dialogs/new_chat.rs` | `filtered_contacts`, `render_contacts`, `render_members` |
|
||||
| `crates/workspace/src/dialogs/profile.rs` | `profile`, `save` |
|
||||
| `crates/workspace/src/dialogs/screening.rs` | `profile`, `mutual_contacts` |
|
||||
| `crates/workspace/src/panels/contact_list.rs` | `render_list_items` |
|
||||
| `crates/workspace/src/sidebar/group_view.rs` | `member_row` |
|
||||
| `crates/workspace/src/sidebar/header.rs` | `render_user` |
|
||||
| `crates/workspace/src/sidebar/rows.rs` | `render_rows` (Contact/Profile/Message) |
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Duplicate relay traffic across panels** is solved by the coordinator's
|
||||
in-flight set; if two panels want the same key after one subscription closed,
|
||||
a second fetch is acceptable (ExitOnEOSE, batched). Revisit only if relays
|
||||
complain.
|
||||
- **True `PartialEq` for `Person`** is required for cache correctness; cheap to
|
||||
implement alongside the refactor.
|
||||
- **`ProfileDialog::save`** reads old metadata through the registry; it should
|
||||
read the current user's profile from the registry's retained own-profile.
|
||||
- **Naming**: `Directory` vs `Roster` vs `Persons`. Keeping the `Person` value
|
||||
type unchanged minimizes churn either way.
|
||||
- **wasm32** target: directories use the same primitives (`instant`, flume,
|
||||
background spawns) the registry already uses — no new platform concerns.
|
||||
Reference in New Issue
Block a user