track message sender's emojis instead of global registry

This commit is contained in:
2026-10-12 17:39:43 +07:00
parent d092666404
commit f87bed75bf
11 changed files with 205 additions and 541 deletions
+3 -2
View File
@@ -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)
+13 -2
View File
@@ -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(&current_user, cx)
.emojis()
.to_vec()
})
.unwrap_or_default();
Button::new("emoji")
.icon(IconName::Emoji)
+2 -3
View File
@@ -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,
+20 -5
View File
@@ -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(&current_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;
};
+17 -2
View File
@@ -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(&current_user, cx)
.emojis()
.to_vec(),
)
})
.unwrap_or_default();
if emojis.is_empty() {
return menu;
}
+87 -20
View File
@@ -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
View File
@@ -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
-21
View File
@@ -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();
+14 -8
View File
@@ -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);
+20 -8
View File
@@ -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(
-269
View File
@@ -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(&registry, |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.