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