13 KiB
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:
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 swapsPersonRegistry::global(cx).read(cx).get(...)forself.directory.read(cx).person(...). Reading the directory during render is enough for GPUI to re-render the panel when the directory notifies. - Store
Personvalues, notEntity<Person>: nothing observes person entities today, and a panel re-renders wholesale anyway. Onecx.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,TIMEOUTtimeout) 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:
- The registry pump converts nostr notifications into events.
cx.emitis synchronous — subscribers run during the registry's update:
#[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
...
}
- Each directory subscribes at creation. The subscription lives in a field, so it is dropped when the panel (and its directory) is dropped:
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.
- 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 callscx.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. touchListStateitem heights):cx.observe(&directory, |_, _, cx| cx.notify()).detach().
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.
- Events + notify fix (small). Add
EventEmitter<PersonEvent>toPersonRegistry, emit from every mutation path, and makeinsertnotify the registry for new persons (fixes bug 1 for the current design too). No call site changes. - Add
Directory(medium). New type per the sketch above, subscribing to registry events and fetching via the coordinator. Unit-testable inTestAppContextby feeding events directly. - Convert call sites (bulk, mechanical). One panel/screen at a time:
chat_ui::ChatPanel— directory per panel,trackin the room-event handler; pass the directory intochat_ui::text::rendered_textinstead ofEntity<PersonRegistry>.nip29_ui—message::render,render_activity,replies,GroupPanel::render_row/render_reply_targettake&Entity<Directory>as a parameter; the panel owns the directory.workspacesidebar — one directory for the sidebar (header::render_user,rows::render_rows,group_view::member_row).workspacedialogs — per-dialog directories:contact_list.rs,new_chat.rs,profile.rs,screening.rs. They drop with the dialog.chat::Roomrender 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).
- Slim the registry (small). Delete the global person map,
seen,bulk_insert, and unscopedload()(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
PartialEqforPersonis required for cache correctness; cheap to implement alongside the refactor. ProfileDialog::savereads old metadata through the registry; it should read the current user's profile from the registry's retained own-profile.- Naming:
DirectoryvsRostervsPersons. Keeping thePersonvalue 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.