From b711a99192c39ea22d643c5c26e397a132d1994f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 12 Oct 2026 15:29:56 +0700 Subject: [PATCH 1/4] add plan --- docs/person-directory-refactor.md | 269 ++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/person-directory-refactor.md diff --git a/docs/person-directory-refactor.md b/docs/person-directory-refactor.md new file mode 100644 index 00000000..cc600f31 --- /dev/null +++ b/docs/person-directory-refactor.md @@ -0,0 +1,269 @@ +# 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`), + 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`. + +Bug 1 — updates never reach the UI reliably. `get()` returns a cloned value, so +views observe the *registry*, never the `Entity` 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 Entity Entity + (ChatPanel) (Sidebar) (ContactListPanel) + ▲ ▲ ▲ + └───────── panels read during render ────────────┘ +``` + +New `Directory` type in `crates/person`: + +```rust +pub struct Directory { + persons: HashMap, // values, not entities + pending: HashSet, // 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; + /// 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, cx: &mut Context); + /// 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`: 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 for PersonRegistry {} + +// inside the registry's foreground loop (Context): +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 { + 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) { + 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`, 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` 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`. + - `nip29_ui` — `message::render`, `render_activity`, `replies`, + `GroupPanel::render_row` / `render_reply_target` take `&Entity` + 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. -- 2.54.0 From 79825d18257f04705abebc52c5af9af5998c0e50 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 12 Oct 2026 16:13:53 +0700 Subject: [PATCH 2/4] add person change events and batched metadata fetching --- crates/person/src/directory.rs | 167 +++++++++++++++++++++++++++++++++ crates/person/src/lib.rs | 85 +++++++++++++++-- 2 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 crates/person/src/directory.rs diff --git a/crates/person/src/directory.rs b/crates/person/src/directory.rs new file mode 100644 index 00000000..fbe7314d --- /dev/null +++ b/crates/person/src/directory.rs @@ -0,0 +1,167 @@ +use std::cmp::Reverse; +use std::collections::{HashMap, HashSet}; +use std::sync::RwLock; + +use anyhow::Error; +use gpui::{App, AppContext, Context, Entity, Subscription, Task}; +use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; + +use crate::{Person, PersonEvent, PersonRegistry}; + +/// Directory of persons tracked by a view +#[derive(Debug)] +pub struct Directory { + persons: HashMap, + /// Keys awaiting their first update, guarded for render-time access + pending: RwLock>, + tasks: SmallVec<[Task<()>; 2]>, + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl Directory { + /// Create a directory following the registry's events + pub fn new(cx: &mut App) -> Entity { + let registry = PersonRegistry::global(cx); + + cx.new(|cx| { + let subscription = cx.subscribe(®istry, |this: &mut Self, _registry, event, cx| { + this.handle_event(event.clone(), cx); + }); + + Self { + persons: HashMap::new(), + pending: RwLock::new(HashSet::new()), + tasks: SmallVec::new(), + _subscriptions: smallvec![subscription], + } + }) + } + + /// Track keys: load known profiles from the database, request the rest + pub fn track(&mut self, keys: impl IntoIterator, cx: &mut Context) { + let missing: Vec = keys + .into_iter() + .filter(|key| { + !self.persons.contains_key(key) + && !self.with_pending(|pending| pending.contains(key)) + }) + .collect(); + + if missing.is_empty() { + return; + } + + // Accept events that arrive while the database query is running + 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, Error>> = cx.background_spawn(async move { + let filter = Filter::new().kind(Kind::Metadata).authors(authors); + let mut events: Vec = + client.database().query(filter).await?.into_iter().collect(); + + // Keep only the newest event per author + events.sort_by_key(|event| Reverse(event.created_at)); + let mut seen = HashSet::new(); + + Ok(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()) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let loaded = task.await; + + this.update(cx, |this, cx| { + let remaining = match loaded { + Ok(persons) => { + let found: HashSet = + persons.iter().map(Person::public_key).collect(); + this.bulk_insert(persons, cx); + missing + .into_iter() + .filter(|key| !found.contains(key)) + .collect() + } + Err(error) => { + log::warn!("Failed to load persons from the local database: {error}"); + missing + } + }; + + if !remaining.is_empty() { + let registry = PersonRegistry::global(cx); + registry.read(cx).fetch(remaining); + } + }) + .ok(); + })); + } + + /// The person for a key, or a placeholder that queues a request + pub fn person(&self, public_key: &PublicKey, cx: &App) -> Person { + if let Some(person) = self.persons.get(public_key) { + return person.clone(); + } + + if self.with_pending(|pending| pending.insert(*public_key)) { + let registry = PersonRegistry::global(cx); + registry.read(cx).fetch([*public_key]); + } + + Person::new(*public_key, Metadata::default()) + } + + /// Accept events for tracked keys and drop the rest + fn handle_event(&mut self, event: PersonEvent, cx: &mut Context) { + let PersonEvent::Updated(person) = event; + let public_key = person.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(); + } + } + + /// Insert loaded persons, keeping data already received from relays + fn bulk_insert(&mut self, persons: Vec, cx: &mut Context) { + let mut inserted = false; + + for person in persons { + let public_key = person.public_key(); + + if self.persons.contains_key(&public_key) { + continue; + } + + self.persons.insert(public_key, person); + inserted = true; + } + + if inserted { + cx.notify(); + } + } + + /// Run `then` with the pending set, tolerating poisoning: it is only a dedupe cache + fn with_pending(&self, then: impl FnOnce(&mut HashSet) -> R) -> R { + let mut pending = match self.pending.write() { + Ok(pending) => pending, + Err(poisoned) => poisoned.into_inner(), + }; + + then(&mut pending) + } +} diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 91e035e2..1a82e9cf 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -4,14 +4,16 @@ use std::sync::RwLock; use anyhow::{Error, anyhow}; use common::{EventExt, emojis_from_tags}; -use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; +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; +pub use directory::*; pub use person::*; pub fn init(cx: &mut App) { @@ -22,6 +24,14 @@ struct GlobalPersonRegistry(Entity); impl Global for GlobalPersonRegistry {} +/// A change to a person's data +#[derive(Debug, Clone)] +pub enum PersonEvent { + Updated(Person), +} + +impl EventEmitter for PersonRegistry {} + #[derive(Debug, Clone)] enum Dispatch { Person(Person), @@ -37,6 +47,8 @@ pub struct PersonRegistry { persons: HashMap>, /// Set of public keys that have been seen seen: RwLock>, + /// Public keys with a metadata request in flight + in_flight: RwLock>, sender: flume::Sender, tasks: SmallVec<[Task<()>; 4]>, _subscriptions: SmallVec<[Subscription; 1]>, @@ -114,6 +126,7 @@ impl PersonRegistry { Self { persons: HashMap::new(), seen: RwLock::new(HashSet::new()), + in_flight: RwLock::new(HashSet::new()), sender: metadata_tx, tasks, _subscriptions: smallvec![subscription], @@ -283,7 +296,7 @@ impl PersonRegistry { } /// Set profile encryption keys announcement - fn set_announcement(&mut self, event: &Event, cx: &mut App) { + fn set_announcement(&mut self, event: &Event, cx: &mut Context) { let Some(announcement) = Announcement::from_event(event) else { log::warn!("Ignoring announcement with missing or invalid key tag"); return; @@ -294,6 +307,7 @@ impl PersonRegistry { 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); @@ -302,7 +316,7 @@ impl PersonRegistry { } /// Set messaging relays for a person - fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) { + fn set_messaging_relays(&mut self, event: &Event, cx: &mut Context) { let urls: Vec = nip17::extract_relay_list(event).collect(); if let Some(person) = self.persons.get(&event.pubkey) { @@ -310,6 +324,7 @@ impl PersonRegistry { 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); @@ -317,17 +332,23 @@ impl PersonRegistry { } /// Set custom emoji for a person from their emoji list event - fn set_emojis(&mut self, event: &Event, cx: &mut App) { + fn set_emojis(&mut self, event: &Event, cx: &mut Context) { 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 App) { + fn set_emojis_for( + &mut self, + public_key: PublicKey, + emojis: Vec<(String, Url)>, + cx: &mut Context, + ) { 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); @@ -336,18 +357,23 @@ impl PersonRegistry { /// Insert batch of persons fn bulk_insert(&mut self, persons: Vec, cx: &mut Context) { - for person in persons.into_iter() { + for person in persons { let public_key = person.public_key(); - self.persons - .entry(public_key) - .or_insert_with(|| cx.new(|_| person)); + + 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 - pub fn insert(&mut self, person: Person, cx: &mut App) { + pub fn insert(&mut self, person: Person, cx: &mut Context) { let public_key = person.public_key(); + self.release_in_flight(&public_key); match self.persons.get(&public_key) { Some(this) => { @@ -355,9 +381,13 @@ impl PersonRegistry { 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(); } } } @@ -386,6 +416,41 @@ impl PersonRegistry { Person::new(public_key, Metadata::default()) } + /// Queue a batched metadata request for keys without one in flight + pub(crate) fn fetch(&self, keys: I) + where + I: IntoIterator, + { + 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); + } + /// The signed-in user's custom emoji pub fn current_emojis(cx: &App) -> Vec<(String, Url)> { let registry = Self::global(cx); -- 2.54.0 From d0926664040f46c22e585dc0732faac1324c7c98 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 12 Oct 2026 16:50:59 +0700 Subject: [PATCH 3/4] replace global PersonRegistry with per-view person directories --- crates/chat/src/lib.rs | 3 ++ crates/chat/src/room.rs | 50 ++++++++++++++------ crates/chat_ui/src/lib.rs | 52 +++++++++++++-------- crates/chat_ui/src/text.rs | 9 ++-- crates/nip29_ui/src/lib.rs | 45 ++++++++++++++---- crates/nip29_ui/src/message.rs | 24 ++++++---- crates/nip4e/src/lib.rs | 17 +++++-- crates/workspace/src/dialogs/new_chat.rs | 18 +++---- crates/workspace/src/dialogs/screening.rs | 16 +++++-- crates/workspace/src/panels/contact_list.rs | 14 ++++-- crates/workspace/src/sidebar/group_view.rs | 17 +++++-- crates/workspace/src/sidebar/header.rs | 4 +- crates/workspace/src/sidebar/inbox.rs | 3 ++ crates/workspace/src/sidebar/mod.rs | 4 ++ crates/workspace/src/sidebar/rows.rs | 9 ++-- crates/workspace/src/sidebar/search.rs | 15 +++++- 16 files changed, 214 insertions(+), 86 deletions(-) diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 77725881..1ccb2981 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -580,6 +580,7 @@ impl ChatRegistry { let room: Room = room.into().organize(&public_key); let room_id = room.id; let entity = cx.new(|_| room); + entity.update(cx, |room, cx| room.ensure_directory(cx)); self.room_index.insert(room_id, entity.clone()); self.rooms.insert(0, entity); @@ -594,6 +595,7 @@ impl ChatRegistry { if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) { let entity = room.to_owned(); + entity.update(cx, |room, cx| room.ensure_directory(cx)); e.insert(entity.clone()); self.rooms.insert(0, entity); @@ -670,6 +672,7 @@ impl ChatRegistry { } else { let new_room_id = new_room.id; let entity = cx.new(|_| new_room); + entity.update(cx, |room, cx| room.ensure_directory(cx)); self.room_index.insert(new_room_id, entity.clone()); self.rooms.push(entity); diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 3b2cdb0c..7d48ac96 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -3,12 +3,12 @@ use std::hash::{Hash, Hasher}; use anyhow::{Error, anyhow}; use common::EventExt; -use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task}; use instant::Duration; use itertools::Itertools; use nip4e::DeviceRegistry; use nostr_sdk::prelude::*; -use person::{Person, PersonRegistry}; +use person::{Directory, Person, PersonRegistry}; use settings::{RoomConfig, SignerKind}; use state::{NostrRegistry, TIMEOUT, UniversalSigner}; @@ -88,6 +88,9 @@ pub struct Room { /// Configuration config: RoomConfig, + + /// Person directory, attached when the room is registered + directory: Option>, } impl Ord for Room { @@ -136,6 +139,7 @@ impl From<&UnsignedEvent> for Room { members, kind: RoomKind::default(), config: RoomConfig::new(), + directory: None, } } } @@ -236,6 +240,32 @@ impl Room { self.members.len() > 2 } + /// The room's person directory, attached when the room is registered + pub fn directory(&self) -> Option> { + self.directory.clone() + } + + /// Attach the person directory, tracking the room's members + pub(crate) fn ensure_directory(&mut self, cx: &mut Context) { + if self.directory.is_some() { + return; + } + + let directory = Directory::new(cx); + let members = self.members.clone(); + directory.update(cx, |directory, cx| directory.track(members, cx)); + self.directory = Some(directory); + } + + /// The person for a room member + fn member(&self, public_key: &PublicKey, cx: &App) -> Person { + let Some(directory) = self.directory.as_ref() else { + return Person::new(*public_key, Metadata::default()); + }; + + directory.read(cx).person(public_key, cx) + } + /// Gets the display name for the room pub fn display_name(&self, cx: &App) -> SharedString { if let Some(value) = self.subject.clone() { @@ -267,19 +297,16 @@ impl Room { /// /// Display member is always different from the current user. pub fn display_member(&self, cx: &App) -> Person { - let persons = PersonRegistry::global(cx); - persons.read(cx).get(&self.members[0], cx) + self.member(&self.members[0], cx) } /// Merge the names of the first two members of the room. fn merged_name(&self, cx: &App) -> SharedString { - let persons = PersonRegistry::global(cx); - if self.is_group() { let profiles: Vec = self .members .iter() - .map(|public_key| persons.read(cx).get(public_key, cx)) + .map(|public_key| self.member(public_key, cx)) .collect(); let mut name = profiles @@ -446,8 +473,6 @@ impl Room { // Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`) fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec { - let persons = PersonRegistry::global(cx); - // Construct event's tags let mut tags = vec![]; @@ -463,7 +488,7 @@ impl Room { // Add all receiver tags (no intermediate allocation) for public_key in self.members.iter().filter(|pk| *pk != &sender) { - let member = persons.read(cx).get(public_key, cx); + let member = self.member(public_key, cx); tags.push(Tag::from(Nip01Tag::PublicKey { public_key: member.public_key(), relay_hint: member.messaging_relay_hint(), @@ -486,15 +511,14 @@ impl Room { let current_user = nostr.read(cx).current_user()?; // Get sender's profile - let persons = PersonRegistry::global(cx); - let sender = persons.read(cx).get(¤t_user, cx); + let sender = self.member(¤t_user, cx); // Get all members (excluding sender) let receivers: Vec = self .members .iter() .filter(|public_key| public_key != &&sender.public_key()) - .map(|member| persons.read(cx).get(member, cx)) + .map(|member| self.member(member, cx)) .collect(); Some(cx.background_spawn(async move { diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index c90a30e9..c5d71642 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -17,7 +17,7 @@ use gpui::{ }; use itertools::Itertools; use nostr_sdk::prelude::*; -use person::{Person, PersonRegistry}; +use person::{Directory, Person, PersonRegistry}; use regex::Regex; use settings::{AppSettings, SignerKind}; use smallvec::{SmallVec, smallvec}; @@ -73,6 +73,9 @@ pub struct ChatPanel { /// Chat Room room: WeakEntity, + /// Person directory shared with the room + directory: Entity, + /// Message list state list_state: ListState, @@ -133,6 +136,13 @@ impl ChatPanel { let messages = Vec::new(); let list_state = ListState::new(messages.len() + 1, ListAlignment::Bottom, px(1024.)); + // Share the room's person directory + let directory = room + .read_with(cx, |room, _cx| room.directory()) + .ok() + .flatten() + .unwrap_or_else(|| Directory::new(cx)); + // Get room id and name let (id, name) = room .read_with(cx, |this, _cx| { @@ -194,6 +204,7 @@ impl ChatPanel { message_index: HashMap::new(), reactions: BTreeMap::new(), room, + directory, list_state, input, subject_input, @@ -832,8 +843,7 @@ impl ChatPanel { } fn profile(&self, public_key: &PublicKey, cx: &App) -> Person { - let persons = PersonRegistry::global(cx); - persons.read(cx).get(public_key, cx) + self.directory.read(cx).person(public_key, cx) } fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { @@ -1085,7 +1095,7 @@ impl ChatPanel { } if let Some(message) = self.messages.get(index) { - let persons = PersonRegistry::global(cx); + let directory = self.directory.clone(); let show_author = self.is_group_start(index); let text = self .rendered_texts_by_id @@ -1095,7 +1105,7 @@ impl ChatPanel { &message.content, &message.mentions, &message.emojis, - &persons, + &directory, cx, ) }) @@ -1210,8 +1220,9 @@ impl ChatPanel { items } - fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement { + fn render_sent_reports(&self, id: &EventId, cx: &Context) -> impl IntoElement { let reports = self.sent_reports(id); + let panel = cx.weak_entity(); let pending = reports.as_ref().is_some_and(|reports| reports.is_empty()); @@ -1241,28 +1252,29 @@ impl ChatPanel { this.when(!pending, |this| { this.on_click(move |_e, window, cx| { let reports = reports.clone(); + let panel = panel.clone(); window.open_dialog(cx, move |this, _window, cx| { + let items = panel + .update(cx, |panel, cx| { + reports + .iter() + .map(|report| panel.render_report(report, cx)) + .collect::>() + }) + .unwrap_or_default(); + this.title(SharedString::from("Sent Reports")) .show_close(true) - .child(v_flex().gap_4().children({ - let mut items = Vec::with_capacity(reports.len()); - - for report in reports.iter() { - items.push(Self::render_report(report, cx)) - } - - items - })) + .child(v_flex().gap_4().children(items)) }); }) }) }) } - fn render_report(report: &SendReport, cx: &App) -> impl IntoElement { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&report.receiver, cx); + fn render_report(&self, report: &SendReport, cx: &App) -> AnyElement { + let profile = self.directory.read(cx).person(&report.receiver, cx); let name = profile.name(); let avatar = profile.avatar(); @@ -1363,6 +1375,7 @@ impl ChatPanel { }), ) }) + .into_any_element() } fn render_actions( @@ -1652,8 +1665,7 @@ impl ChatPanel { fn render_reply(&self, id: &EventId, cx: &Context) -> impl IntoElement { if let Some(text) = self.message(id) { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&text.author, cx); + let profile = self.directory.read(cx).person(&text.author, cx); div() .w_full() diff --git a/crates/chat_ui/src/text.rs b/crates/chat_ui/src/text.rs index 00ccf58a..8315fcef 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -1,7 +1,7 @@ use chat::{Mention, nevent_refs}; use gpui::{App, Entity}; use nostr_sdk::prelude::Url; -use person::PersonRegistry; +use person::Directory; use ui::markdown::InlineReplacement; pub use ui::markdown::RenderedText; @@ -9,7 +9,7 @@ pub fn rendered_text( content: &str, mentions: &[Mention], emojis: &[(String, Url)], - persons: &Entity, + persons: &Entity, cx: &App, ) -> RenderedText { let mut replacements = mentions @@ -17,7 +17,10 @@ pub fn rendered_text( .map(|mention| { InlineReplacement::new( mention.range.clone(), - format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()), + format!( + "@{}", + persons.read(cx).person(&mention.public_key, cx).name() + ), ) }) .collect::>(); diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index e6eaddf1..ecb19bb3 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -11,7 +11,7 @@ use gpui::{ }; use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row}; use nostr_sdk::prelude::*; -use person::PersonRegistry; +use person::Directory; use serde::Deserialize; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; @@ -308,6 +308,8 @@ pub struct GroupPanel { id: SharedString, focus_handle: FocusHandle, group: WeakEntity, + /// Person directory for members, message authors, and activity subjects + directory: Entity, list_state: ListState, head: Option, /// The message the composer is replying to, if any. @@ -356,12 +358,14 @@ impl GroupPanel { subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| { if !matches!(event, GroupEvent::Sent(_)) { this.sync(cx); + this.track_participants(cx); cx.notify(); } })); subscriptions.push(cx.observe(&group, |this, _group, cx| { this.sync(cx); + this.track_participants(cx); cx.notify(); })); @@ -376,10 +380,11 @@ impl GroupPanel { )); }); - Self { + let mut this = Self { id, focus_handle: cx.focus_handle(), group: group.downgrade(), + directory: Directory::new(cx), list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)), head, reply_to: None, @@ -389,7 +394,29 @@ impl GroupPanel { uploading: false, tasks: smallvec![], _subscriptions: subscriptions, - } + }; + + this.track_participants(cx); + + this + } + + /// Track the keys rendered by this panel: members, message authors, and + /// activity subjects + fn track_participants(&mut self, cx: &mut Context) { + let Some(group) = self.group.upgrade() else { + return; + }; + + let group = group.read(cx); + let mut keys: Vec = group.members().iter().copied().collect(); + keys.extend(group.rows().iter().map(|row| match row { + Row::Message(message) => message.author, + Row::Activity(activity) => activity.subject(), + })); + + self.directory + .update(cx, |directory, cx| directory.track(keys, cx)); } fn item_count(&self, cx: &App) -> usize { @@ -877,11 +904,13 @@ impl GroupPanel { let message = match row { Row::Message(message) => message, - Row::Activity(activity) => return message::render_activity(ix, activity, cx), + Row::Activity(activity) => { + return message::render_activity(ix, activity, &self.directory, cx); + } }; let nostr = NostrRegistry::global(cx); - let persons = PersonRegistry::global(cx); + let directory = self.directory.clone(); let content = self .rendered_texts_by_id @@ -891,7 +920,7 @@ impl GroupPanel { &message.content, &message.mentions, &message.emojis, - &persons, + &directory, cx, ) }) @@ -916,6 +945,7 @@ impl GroupPanel { panel: cx.weak_entity(), admin, pinned, + persons: self.directory.clone(), }, cx, ) @@ -990,8 +1020,7 @@ impl GroupPanel { let context = self.group.upgrade().and_then(|entity| { entity.read_with(cx, |group, cx| { let message = group.message(target)?; - let persons = PersonRegistry::global(cx); - let author = persons.read(cx).get(&message.author, cx); + let author = self.directory.read(cx).person(&message.author, cx); let preview = message.preview(); let line = preview.lines().next().unwrap_or_default().trim(); diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index ea7edcf4..da5ac2e8 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -2,12 +2,12 @@ use chat::Message; use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, + AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, WeakEntity, div, px, }; use nip29::{Activity, Group, Reaction}; use nostr_sdk::prelude::{EventId, Kind}; -use person::PersonRegistry; +use person::{Directory, PersonRegistry}; use settings::AppSettings; use state::NostrRegistry; use theme::{ActiveTheme, TextStyle, Typeset as _}; @@ -25,6 +25,7 @@ pub(crate) struct Actions { pub panel: WeakEntity, pub admin: bool, pub pinned: bool, + pub persons: Entity, } pub(crate) fn render( @@ -37,10 +38,10 @@ pub(crate) fn render( cx: &App, ) -> AnyElement { let nostr = NostrRegistry::global(cx); - let persons = PersonRegistry::global(cx); let hide_avatar = AppSettings::get_hide_avatar(cx); - let author = persons.read(cx).get(&message.author, cx); + let persons = actions.persons.clone(); + let author = persons.read(cx).person(&message.author, cx); let reaction_groups = group_reactions( reactions.iter().map(|reaction| { @@ -66,6 +67,7 @@ pub(crate) fn render( .when(!message.replies_to.is_empty(), |this| { this.children(replies( &message.replies_to, + &persons, &actions.group, &actions.panel, cx, @@ -185,9 +187,13 @@ fn react_menu( }) } -pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyElement { - let persons = PersonRegistry::global(cx); - let subject = persons.read(cx).get(&activity.subject(), cx); +pub(crate) fn render_activity( + ix: usize, + activity: &Activity, + persons: &Entity, + cx: &App, +) -> AnyElement { + let subject = persons.read(cx).person(&activity.subject(), cx); let hide_avatar = AppSettings::get_hide_avatar(cx); h_flex() @@ -262,6 +268,7 @@ fn action(activity: &Activity) -> String { fn replies( targets: &[EventId], + persons: &Entity, group: &WeakEntity, panel: &WeakEntity, cx: &App, @@ -275,8 +282,7 @@ fn replies( for (ix, id) in targets.iter().enumerate() { let context = entity.read_with(cx, |group, cx| { let message = group.message(*id)?; - let persons = PersonRegistry::global(cx); - let author = persons.read(cx).get(&message.author, cx); + let author = persons.read(cx).person(&message.author, cx); Some((author.name(), message.preview())) }); diff --git a/crates/nip4e/src/lib.rs b/crates/nip4e/src/lib.rs index de761c21..698957b8 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -11,7 +11,7 @@ use gpui::{ SharedString, Styled, Subscription, Task, Window, div, px, relative, }; use nostr_sdk::prelude::*; -use person::PersonRegistry; +use person::Directory; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner}; @@ -63,6 +63,8 @@ pub struct DeviceRegistry { pub(crate) pending_request: bool, /// Whether an announcement has been made for this device pub(crate) announcement_existed: Arc, + /// Person directory for requesters shown in notifications + directory: Entity, /// Relay notification listener notification_listener: Option>>, tasks: Vec>>, @@ -122,6 +124,7 @@ impl DeviceRegistry { signer, pending_request: false, announcement_existed: Arc::new(AtomicBool::new(false)), + directory: Directory::new(cx), notification_listener: None, tasks: vec![], _subscriptions: subscriptions, @@ -690,7 +693,11 @@ impl DeviceRegistry { } /// Build a notification for the encryption request. - fn notification(&self, event: Event, cx: &Context) -> Result { + fn notification( + &mut self, + event: Event, + cx: &mut Context, + ) -> Result { const MSG: &str = "You've requested an encryption key from another device. \ Approve to allow Coop to share with it. Before approving, \ compare the code with the one shown on the requesting \ @@ -705,8 +712,8 @@ impl DeviceRegistry { .context("Request has no valid requester key")?; let code = authorization_code(&requester); - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&requester, cx); + let directory = self.directory.clone(); + directory.update(cx, |directory, cx| directory.track([requester], cx)); let code_label = code.clone(); let entity = cx.entity().downgrade(); @@ -719,6 +726,8 @@ impl DeviceRegistry { .with_kind(NotificationKind::Info) .title("Encryption Key Request") .content(move |_this, _window, cx| { + let profile = directory.read(cx).person(&requester, cx); + v_flex() .gap(px(theme::SPACE)) .text_style(TextStyle::Callout) diff --git a/crates/workspace/src/dialogs/new_chat.rs b/crates/workspace/src/dialogs/new_chat.rs index 39796ffe..620cadca 100644 --- a/crates/workspace/src/dialogs/new_chat.rs +++ b/crates/workspace/src/dialogs/new_chat.rs @@ -10,7 +10,7 @@ use gpui::{ Window, div, px, uniform_list, }; use nostr_sdk::prelude::*; -use person::PersonRegistry; +use person::Directory; use settings::AppSettings; use state::NostrRegistry; use theme::{ActiveTheme, TextStyle, Typeset as _}; @@ -39,6 +39,8 @@ pub struct NewChat { input: Entity, /// Current user's contacts contacts: Option>, + /// Person directory for contacts and selected members + directory: Entity, /// Public keys selected as members of the chat members: Vec, /// Whether a NIP-05 address is being resolved @@ -69,6 +71,7 @@ impl NewChat { dock, input, contacts: None, + directory: Directory::new(cx), members: Vec::new(), resolving: false, error: None, @@ -112,6 +115,9 @@ impl NewChat { match task.await { Ok(contacts) => { this.update(cx, |this, cx| { + this.directory.update(cx, |directory, cx| { + directory.track(contacts.iter().copied(), cx) + }); this.contacts = Some(contacts); cx.notify(); })?; @@ -133,7 +139,6 @@ impl NewChat { return Vec::new(); }; - let persons = PersonRegistry::global(cx); let query = self.input.read(cx).value().trim().to_lowercase(); if query.is_empty() { @@ -146,7 +151,7 @@ impl NewChat { if public_key.to_hex().to_lowercase().contains(&query) { return true; } - let name = persons.read(cx).get(public_key, cx).name(); + let name = self.directory.read(cx).person(public_key, cx).name(); name.to_lowercase().contains(&query) }) .cloned() @@ -318,7 +323,6 @@ impl NewChat { contacts: &[PublicKey], cx: &mut Context, ) -> Vec { - let persons = PersonRegistry::global(cx); let start = range.start; contacts @@ -327,7 +331,7 @@ impl NewChat { .flatten() .enumerate() .map(|(offset, public_key)| { - let profile = persons.read(cx).get(public_key, cx); + let profile = self.directory.read(cx).person(public_key, cx); let public_key = *public_key; Nav::new(start + offset) @@ -350,13 +354,11 @@ impl NewChat { } fn render_members(&self, cx: &mut Context) -> impl IntoElement { - let persons = PersonRegistry::global(cx); - h_flex() .flex_wrap() .gap_1() .children(self.members.iter().map(|public_key| { - let profile = persons.read(cx).get(public_key, cx); + let profile = self.directory.read(cx).person(public_key, cx); let public_key = *public_key; let id = SharedString::from(format!("m-{}", public_key.to_hex())); diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 18320bae..52b84221 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -9,7 +9,7 @@ use gpui::{ }; use instant::Duration; use nostr_sdk::prelude::*; -use person::{Person, PersonRegistry, shorten_pubkey}; +use person::{Directory, Person, shorten_pubkey}; use smallvec::{SmallVec, smallvec}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -33,6 +33,8 @@ pub struct Screening { last_active: Option, /// All mutual contacts of the person being screened. mutual_contacts: Vec, + /// Person directory for the person and their mutual contacts + directory: Entity, tasks: SmallVec<[Task<()>; 3]>, _subscriptions: SmallVec<[Subscription; 1]>, } @@ -53,12 +55,16 @@ impl Screening { this.verify_identifier(cx); }); + let directory = Directory::new(cx); + directory.update(cx, |directory, cx| directory.track([public_key], cx)); + Self { public_key, verified: false, followed: false, last_active: None, mutual_contacts: vec![], + directory, tasks: smallvec![], _subscriptions: subscriptions, } @@ -208,8 +214,7 @@ impl Screening { } fn profile(&self, cx: &Context) -> Person { - let persons = PersonRegistry::global(cx); - persons.read(cx).get(&self.public_key, cx) + self.directory.read(cx).person(&self.public_key, cx) } fn address(&self, cx: &Context) -> Option { @@ -260,22 +265,23 @@ impl Screening { fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { let contacts = self.mutual_contacts.clone(); + let directory = self.directory.clone(); window.open_dialog(cx, move |this, _window, _cx| { let contacts = contacts.clone(); + let directory = directory.clone(); let total = contacts.len(); this.title("Mutual contacts").child( v_flex().gap_1().pb_2().child( uniform_list("contacts", total, move |range, _window, cx| { - let persons = PersonRegistry::global(cx); let mut items = Vec::with_capacity(total); for ix in range { let Some(contact) = contacts.get(ix) else { continue; }; - let profile = persons.read(cx).get(contact, cx); + let profile = directory.read(cx).person(contact, cx); items.push( h_flex() diff --git a/crates/workspace/src/panels/contact_list.rs b/crates/workspace/src/panels/contact_list.rs index bf6633e0..085572eb 100644 --- a/crates/workspace/src/panels/contact_list.rs +++ b/crates/workspace/src/panels/contact_list.rs @@ -9,7 +9,7 @@ use gpui::{ }; use instant::Duration; use nostr_sdk::prelude::*; -use person::PersonRegistry; +use person::Directory; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius}; @@ -41,6 +41,9 @@ pub struct ContactListPanel { /// All contacts contacts: HashSet, + /// Person directory for the contacts + directory: Entity, + /// Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, @@ -73,6 +76,7 @@ impl ContactListPanel { input, updating: false, contacts: HashSet::new(), + directory: Directory::new(cx), error: None, _subscriptions: subscriptions, tasks: vec![], @@ -110,6 +114,9 @@ impl ContactListPanel { // Update state this.update(cx, |this, cx| { + this.directory.update(cx, |directory, cx| { + directory.track(public_keys.iter().copied(), cx) + }); this.contacts.extend(public_keys); cx.notify(); })?; @@ -123,6 +130,8 @@ impl ContactListPanel { if let Ok(public_key) = PublicKey::parse(&value) { if self.contacts.insert(public_key) { + self.directory + .update(cx, |directory, cx| directory.track([public_key], cx)); self.input.update(cx, |this, cx| { this.set_value("", window, cx); }); @@ -218,11 +227,10 @@ impl ContactListPanel { } fn render_list_items(&mut self, cx: &mut Context) -> Vec { - let persons = PersonRegistry::global(cx); let mut items = Vec::new(); for (ix, public_key) in self.contacts.iter().enumerate() { - let profile = persons.read(cx).get(public_key, cx); + let profile = self.directory.read(cx).person(public_key, cx); items.push( h_flex() diff --git a/crates/workspace/src/sidebar/group_view.rs b/crates/workspace/src/sidebar/group_view.rs index 01ae3f2e..687109f1 100644 --- a/crates/workspace/src/sidebar/group_view.rs +++ b/crates/workspace/src/sidebar/group_view.rs @@ -8,7 +8,6 @@ use gpui::{ }; use nip29::Group; use nostr_sdk::prelude::*; -use person::PersonRegistry; use settings::AppSettings; use state::NostrRegistry; use theme::{ActiveTheme, TextStyle, Typeset as _}; @@ -51,6 +50,19 @@ impl Sidebar { pub(super) fn show_group(&mut self, group: &Entity, cx: &mut Context) { self.group = Some(group.downgrade()); self.group_open = true; + + let group = group.read(cx); + let mut keys: Vec = group.members().iter().copied().collect(); + keys.extend( + group + .admins() + .entries() + .iter() + .map(|(public_key, _)| *public_key), + ); + self.directory + .update(cx, |directory, cx| directory.track(keys, cx)); + cx.notify(); } @@ -253,9 +265,8 @@ impl Sidebar { cx: &mut Context, ) -> AnyElement { let hide_avatar = AppSettings::get_hide_avatar(cx); - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); + let person = self.directory.read(cx).person(public_key, cx); let group = group.clone(); let public_key = *public_key; diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs index 35309497..f0e6e469 100644 --- a/crates/workspace/src/sidebar/header.rs +++ b/crates/workspace/src/sidebar/header.rs @@ -5,7 +5,6 @@ use gpui::{ Stateful, Styled, div, px, }; use nostr_sdk::prelude::*; -use person::PersonRegistry; use theme::TABBAR_HEIGHT; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -22,8 +21,7 @@ impl Sidebar { cx: &mut Context, ) -> Stateful
{ let updater = AutoUpdater::try_global(cx); - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(current_user, cx); + let profile = self.directory.read(cx).person(current_user, cx); let avatar = profile.avatar(); let avatar_seed = profile.avatar_seed(); diff --git a/crates/workspace/src/sidebar/inbox.rs b/crates/workspace/src/sidebar/inbox.rs index 8e0bb126..0013bbdc 100644 --- a/crates/workspace/src/sidebar/inbox.rs +++ b/crates/workspace/src/sidebar/inbox.rs @@ -69,6 +69,9 @@ impl Sidebar { match task.await { Ok(contacts) => { this.update(cx, |this, cx| { + this.directory.update(cx, |directory, cx| { + directory.track(contacts.iter().copied(), cx) + }); this.contacts = Some(contacts); cx.notify(); })?; diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 45d7d471..00ec1093 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -13,6 +13,7 @@ use gpui::{ use instant::Duration; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; +use person::Directory; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _}; @@ -61,6 +62,8 @@ pub struct Sidebar { contacts_open: bool, /// The signed-in user's contacts, loaded when the contact picker opens. contacts: Option>, + /// Person directory for contacts, search hits, and group members + directory: Entity, /// Contacts selected in the contact picker. selected_contacts: HashSet, /// Scroll state of the contact picker's list. @@ -196,6 +199,7 @@ impl Sidebar { chats_open: None, contacts_open: false, contacts: None, + directory: Directory::new(cx), selected_contacts: HashSet::new(), contacts_scroll: UniformListScrollHandle::new(), requests_open: false, diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs index 3c46914b..0ed656a7 100644 --- a/crates/workspace/src/sidebar/rows.rs +++ b/crates/workspace/src/sidebar/rows.rs @@ -9,7 +9,6 @@ use gpui::{ }; use nip29::Group; use nostr_sdk::prelude::*; -use person::PersonRegistry; use settings::AppSettings; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; use ui::avatar::Avatar; @@ -196,8 +195,7 @@ impl Sidebar { .into_any_element() } SidebarRow::Contact { public_key } => { - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); + let person = self.directory.read(cx).person(public_key, cx); let selected = self.selected_contacts.contains(public_key); let sidebar = cx.entity().downgrade(); let public_key = public_key.to_owned(); @@ -224,8 +222,7 @@ impl Sidebar { .into_any_element() } SidebarRow::Profile { public_key } => { - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); + let person = self.directory.read(cx).person(public_key, cx); let public_key = public_key.to_owned(); Nav::new(index) @@ -330,7 +327,7 @@ impl Sidebar { .into_any_element() } SidebarRow::Message { hit } => { - let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx); + let person = self.directory.read(cx).person(&hit.author, cx); let name = hit.room.read(cx).display_name(cx); let detail = format!("{} · {}", name, hit.created_at.to_ago()); let dock = self.dock.clone(); diff --git a/crates/workspace/src/sidebar/search.rs b/crates/workspace/src/sidebar/search.rs index 715487eb..39e9d429 100644 --- a/crates/workspace/src/sidebar/search.rs +++ b/crates/workspace/src/sidebar/search.rs @@ -106,6 +106,14 @@ impl Sidebar { .chain(groups.into_iter().map(SearchResult::Group)) .chain(messages.into_iter().map(SearchResult::Message)) .collect(); + + let authors = self.results.iter().filter_map(|result| match result { + SearchResult::Message(hit) => Some(hit.author), + _ => None, + }); + self.directory + .update(cx, |directory, cx| directory.track(authors, cx)); + cx.notify(); } @@ -133,7 +141,7 @@ impl Sidebar { this.update(cx, |this, cx| { this.global_finding = false; - this.global_results = Some(match result { + let keys: Vec = match result { Ok(profiles) => { // Relays may return the same profile more than once let mut seen = HashSet::new(); @@ -146,7 +154,12 @@ impl Sidebar { log::debug!("Global search failed: {error}"); Vec::new() } + }; + + this.directory.update(cx, |directory, cx| { + directory.track(keys.iter().copied(), cx) }); + this.global_results = Some(keys); cx.notify(); }) .ok(); -- 2.54.0 From f87bed75bfc3ad88126570c2ab559e141d62c6e9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 12 Oct 2026 17:39:43 +0700 Subject: [PATCH 4/4] track message sender's emojis instead of global registry --- crates/chat/src/room.rs | 5 +- crates/chat_ui/src/lib.rs | 15 +- crates/nip29/src/group.rs | 5 +- crates/nip29_ui/src/lib.rs | 25 ++- crates/nip29_ui/src/message.rs | 19 +- crates/person/src/directory.rs | 107 ++++++++-- crates/person/src/lib.rs | 230 +++----------------- crates/person/src/person.rs | 21 -- crates/workspace/src/dialogs/profile.rs | 22 +- crates/workspace/src/sidebar/mod.rs | 28 ++- docs/person-directory-refactor.md | 269 ------------------------ 11 files changed, 205 insertions(+), 541 deletions(-) delete mode 100644 docs/person-directory-refactor.md diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 7d48ac96..ad37fc8a 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -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) diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index c5d71642..7325fcf8 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -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) -> 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) diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index 147173dc..23589d09 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -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, + emojis: Vec, cx: &mut Context, ) -> Task> { - 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, cx: &mut Context, ) -> Task> { - let emojis = PersonRegistry::emoji_tags(&emoji, cx); self.dispatch( move |id, prev| id.reaction(target, &emoji, &emojis, prev), cx, diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index ecb19bb3..71c550e4 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -532,11 +532,11 @@ impl GroupPanel { } let replies: Vec = 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 { + 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, @@ -621,7 +633,10 @@ impl GroupPanel { window: &mut Window, cx: &mut Context, ) { - 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; }; diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index da5ac2e8..1286627f 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -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; } diff --git a/crates/person/src/directory.rs b/crates/person/src/directory.rs index fbe7314d..eb8588a9 100644 --- a/crates/person/src/directory.rs +++ b/crates/person/src/directory.rs @@ -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, cx: &mut Context) { + /// Profiles and emoji lists loaded from the database + type Loaded = (Vec, Vec<(PublicKey, Vec<(String, Url)>)>); + let missing: Vec = 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, Error>> = cx.background_spawn(async move { - let filter = Filter::new().kind(Kind::Metadata).authors(authors); - let mut events: Vec = - client.database().query(filter).await?.into_iter().collect(); + let task: Task> = cx.background_spawn(async move { + let mut metadata_events: Vec = 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 = 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 = 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) { - 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) } } diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 1a82e9cf..aec3cf1d 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -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), + /// 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, + }, } impl EventEmitter for PersonRegistry {} @@ -43,10 +58,6 @@ enum Dispatch { /// Person Registry #[derive(Debug)] pub struct PersonRegistry { - /// Collection of all persons (user profiles) - persons: HashMap>, - /// Set of public keys that have been seen - seen: RwLock>, /// Public keys with a metadata request in flight in_flight: RwLock>, sender: flume::Sender, @@ -69,7 +80,6 @@ impl PersonRegistry { fn new(cx: &mut Context) -> 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::(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) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let task: Task, 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) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - type EmojiLists = Vec<(PublicKey, Vec<(String, Url)>)>; - - let task: Task> = cx.background_spawn(async move { - let filter = Filter::new().kind(Kind::Emojis).limit(200); - let mut events: Vec = - 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) { 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) { - let urls: Vec = 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, ) { - 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, cx: &mut Context) { - 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) { 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 { - 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 diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index b88548c6..1cee8030 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -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(mut self, relays: I) -> Self - where - I: IntoIterator, - { - 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 { let mut tags = Vec::new(); diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index 4683b8b9..fc8313ec 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -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, /// 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) { @@ -238,9 +245,8 @@ impl ProfileDialog { } fn save(&mut self, window: &mut Window, cx: &mut Context) { - 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); diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 00ec1093..3b4e2fa8 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -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( diff --git a/docs/person-directory-refactor.md b/docs/person-directory-refactor.md deleted file mode 100644 index cc600f31..00000000 --- a/docs/person-directory-refactor.md +++ /dev/null @@ -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`), - 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`. - -Bug 1 — updates never reach the UI reliably. `get()` returns a cloned value, so -views observe the *registry*, never the `Entity` 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 Entity Entity - (ChatPanel) (Sidebar) (ContactListPanel) - ▲ ▲ ▲ - └───────── panels read during render ────────────┘ -``` - -New `Directory` type in `crates/person`: - -```rust -pub struct Directory { - persons: HashMap, // values, not entities - pending: HashSet, // 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; - /// 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, cx: &mut Context); - /// 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`: 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 for PersonRegistry {} - -// inside the registry's foreground loop (Context): -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 { - 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) { - 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`, 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` 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`. - - `nip29_ui` — `message::render`, `render_activity`, `replies`, - `GroupPanel::render_row` / `render_reply_target` take `&Entity` - 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. -- 2.54.0