feat: bind user metadata to per-view directories (#64)

Reviewed-on: #64
This commit was merged in pull request #64.
This commit is contained in:
2026-10-12 11:14:03 +00:00
parent 7c42466b0a
commit a365a8f584
21 changed files with 613 additions and 320 deletions
+3
View File
@@ -580,6 +580,7 @@ impl ChatRegistry {
let room: Room = room.into().organize(&public_key); let room: Room = room.into().organize(&public_key);
let room_id = room.id; let room_id = room.id;
let entity = cx.new(|_| room); let entity = cx.new(|_| room);
entity.update(cx, |room, cx| room.ensure_directory(cx));
self.room_index.insert(room_id, entity.clone()); self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity); self.rooms.insert(0, entity);
@@ -594,6 +595,7 @@ impl ChatRegistry {
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) { if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
let entity = room.to_owned(); let entity = room.to_owned();
entity.update(cx, |room, cx| room.ensure_directory(cx));
e.insert(entity.clone()); e.insert(entity.clone());
self.rooms.insert(0, entity); self.rooms.insert(0, entity);
@@ -670,6 +672,7 @@ impl ChatRegistry {
} else { } else {
let new_room_id = new_room.id; let new_room_id = new_room.id;
let entity = cx.new(|_| new_room); 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.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity); self.rooms.push(entity);
+39 -14
View File
@@ -3,12 +3,12 @@ use std::hash::{Hash, Hasher};
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::EventExt; use common::EventExt;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task};
use instant::Duration; 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::{Person, PersonRegistry}; use person::{Directory, Person};
use settings::{RoomConfig, SignerKind}; use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner}; use state::{NostrRegistry, TIMEOUT, UniversalSigner};
@@ -88,6 +88,9 @@ pub struct Room {
/// Configuration /// Configuration
config: RoomConfig, config: RoomConfig,
/// Person directory, attached when the room is registered
directory: Option<Entity<Directory>>,
} }
impl Ord for Room { impl Ord for Room {
@@ -136,6 +139,7 @@ impl From<&UnsignedEvent> for Room {
members, members,
kind: RoomKind::default(), kind: RoomKind::default(),
config: RoomConfig::new(), config: RoomConfig::new(),
directory: None,
} }
} }
} }
@@ -236,6 +240,32 @@ impl Room {
self.members.len() > 2 self.members.len() > 2
} }
/// The room's person directory, attached when the room is registered
pub fn directory(&self) -> Option<Entity<Directory>> {
self.directory.clone()
}
/// Attach the person directory, tracking the room's members
pub(crate) fn ensure_directory(&mut self, cx: &mut Context<Self>) {
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 /// Gets the display name for the room
pub fn display_name(&self, cx: &App) -> SharedString { pub fn display_name(&self, cx: &App) -> SharedString {
if let Some(value) = self.subject.clone() { if let Some(value) = self.subject.clone() {
@@ -267,19 +297,16 @@ impl Room {
/// ///
/// Display member is always different from the current user. /// Display member is always different from the current user.
pub fn display_member(&self, cx: &App) -> Person { pub fn display_member(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx); self.member(&self.members[0], cx)
persons.read(cx).get(&self.members[0], cx)
} }
/// Merge the names of the first two members of the room. /// Merge the names of the first two members of the room.
fn merged_name(&self, cx: &App) -> SharedString { fn merged_name(&self, cx: &App) -> SharedString {
let persons = PersonRegistry::global(cx);
if self.is_group() { if self.is_group() {
let profiles: Vec<Person> = self let profiles: Vec<Person> = self
.members .members
.iter() .iter()
.map(|public_key| persons.read(cx).get(public_key, cx)) .map(|public_key| self.member(public_key, cx))
.collect(); .collect();
let mut name = profiles let mut name = profiles
@@ -404,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)
@@ -446,8 +474,6 @@ impl Room {
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`) // Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> { fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
let persons = PersonRegistry::global(cx);
// Construct event's tags // Construct event's tags
let mut tags = vec![]; let mut tags = vec![];
@@ -463,7 +489,7 @@ impl Room {
// Add all receiver tags (no intermediate allocation) // Add all receiver tags (no intermediate allocation)
for public_key in self.members.iter().filter(|pk| *pk != &sender) { 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 { tags.push(Tag::from(Nip01Tag::PublicKey {
public_key: member.public_key(), public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(), relay_hint: member.messaging_relay_hint(),
@@ -486,15 +512,14 @@ impl Room {
let current_user = nostr.read(cx).current_user()?; let current_user = nostr.read(cx).current_user()?;
// Get sender's profile // Get sender's profile
let persons = PersonRegistry::global(cx); let sender = self.member(&current_user, cx);
let sender = persons.read(cx).get(&current_user, cx);
// Get all members (excluding sender) // Get all members (excluding sender)
let receivers: Vec<Person> = self let receivers: Vec<Person> = self
.members .members
.iter() .iter()
.filter(|public_key| public_key != &&sender.public_key()) .filter(|public_key| public_key != &&sender.public_key())
.map(|member| persons.read(cx).get(member, cx)) .map(|member| self.member(member, cx))
.collect(); .collect();
Some(cx.background_spawn(async move { Some(cx.background_spawn(async move {
+44 -21
View File
@@ -17,7 +17,7 @@ use gpui::{
}; };
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{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};
@@ -73,6 +73,9 @@ pub struct ChatPanel {
/// Chat Room /// Chat Room
room: WeakEntity<Room>, room: WeakEntity<Room>,
/// Person directory shared with the room
directory: Entity<Directory>,
/// Message list state /// Message list state
list_state: ListState, list_state: ListState,
@@ -133,6 +136,13 @@ impl ChatPanel {
let messages = Vec::new(); let messages = Vec::new();
let list_state = ListState::new(messages.len() + 1, ListAlignment::Bottom, px(1024.)); 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 // Get room id and name
let (id, name) = room let (id, name) = room
.read_with(cx, |this, _cx| { .read_with(cx, |this, _cx| {
@@ -194,6 +204,7 @@ impl ChatPanel {
message_index: HashMap::new(), message_index: HashMap::new(),
reactions: BTreeMap::new(), reactions: BTreeMap::new(),
room, room,
directory,
list_state, list_state,
input, input,
subject_input, subject_input,
@@ -832,8 +843,7 @@ impl ChatPanel {
} }
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person { fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
let persons = PersonRegistry::global(cx); self.directory.read(cx).person(public_key, cx)
persons.read(cx).get(public_key, cx)
} }
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) { fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
@@ -1085,7 +1095,7 @@ impl ChatPanel {
} }
if let Some(message) = self.messages.get(index) { 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 show_author = self.is_group_start(index);
let text = self let text = self
.rendered_texts_by_id .rendered_texts_by_id
@@ -1095,7 +1105,7 @@ impl ChatPanel {
&message.content, &message.content,
&message.mentions, &message.mentions,
&message.emojis, &message.emojis,
&persons, &directory,
cx, cx,
) )
}) })
@@ -1210,8 +1220,9 @@ impl ChatPanel {
items items
} }
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement { fn render_sent_reports(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
let reports = self.sent_reports(id); let reports = self.sent_reports(id);
let panel = cx.weak_entity();
let pending = reports.as_ref().is_some_and(|reports| reports.is_empty()); let pending = reports.as_ref().is_some_and(|reports| reports.is_empty());
@@ -1241,28 +1252,29 @@ impl ChatPanel {
this.when(!pending, |this| { this.when(!pending, |this| {
this.on_click(move |_e, window, cx| { this.on_click(move |_e, window, cx| {
let reports = reports.clone(); let reports = reports.clone();
let panel = panel.clone();
window.open_dialog(cx, move |this, _window, cx| { 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::<Vec<_>>()
})
.unwrap_or_default();
this.title(SharedString::from("Sent Reports")) this.title(SharedString::from("Sent Reports"))
.show_close(true) .show_close(true)
.child(v_flex().gap_4().children({ .child(v_flex().gap_4().children(items))
let mut items = Vec::with_capacity(reports.len());
for report in reports.iter() {
items.push(Self::render_report(report, cx))
}
items
}))
}); });
}) })
}) })
}) })
} }
fn render_report(report: &SendReport, cx: &App) -> impl IntoElement { fn render_report(&self, report: &SendReport, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx); let profile = self.directory.read(cx).person(&report.receiver, cx);
let profile = persons.read(cx).get(&report.receiver, cx);
let name = profile.name(); let name = profile.name();
let avatar = profile.avatar(); let avatar = profile.avatar();
@@ -1363,6 +1375,7 @@ impl ChatPanel {
}), }),
) )
}) })
.into_any_element()
} }
fn render_actions( fn render_actions(
@@ -1652,8 +1665,7 @@ impl ChatPanel {
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement { fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
if let Some(text) = self.message(id) { if let Some(text) = self.message(id) {
let persons = PersonRegistry::global(cx); let profile = self.directory.read(cx).person(&text.author, cx);
let profile = persons.read(cx).get(&text.author, cx);
div() div()
.w_full() .w_full()
@@ -1758,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(&current_user, cx)
.emojis()
.to_vec()
})
.unwrap_or_default();
Button::new("emoji") Button::new("emoji")
.icon(IconName::Emoji) .icon(IconName::Emoji)
+6 -3
View File
@@ -1,7 +1,7 @@
use chat::{Mention, nevent_refs}; use chat::{Mention, nevent_refs};
use gpui::{App, Entity}; use gpui::{App, Entity};
use nostr_sdk::prelude::Url; use nostr_sdk::prelude::Url;
use person::PersonRegistry; use person::Directory;
use ui::markdown::InlineReplacement; use ui::markdown::InlineReplacement;
pub use ui::markdown::RenderedText; pub use ui::markdown::RenderedText;
@@ -9,7 +9,7 @@ pub fn rendered_text(
content: &str, content: &str,
mentions: &[Mention], mentions: &[Mention],
emojis: &[(String, Url)], emojis: &[(String, Url)],
persons: &Entity<PersonRegistry>, persons: &Entity<Directory>,
cx: &App, cx: &App,
) -> RenderedText { ) -> RenderedText {
let mut replacements = mentions let mut replacements = mentions
@@ -17,7 +17,10 @@ pub fn rendered_text(
.map(|mention| { .map(|mention| {
InlineReplacement::new( InlineReplacement::new(
mention.range.clone(), mention.range.clone(),
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()), format!(
"@{}",
persons.read(cx).person(&mention.public_key, cx).name()
),
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
+2 -3
View File
@@ -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,
+57 -13
View File
@@ -11,7 +11,7 @@ use gpui::{
}; };
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row}; use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::Directory;
use serde::Deserialize; use serde::Deserialize;
use settings::AppSettings; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
@@ -308,6 +308,8 @@ pub struct GroupPanel {
id: SharedString, id: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
group: WeakEntity<Group>, group: WeakEntity<Group>,
/// Person directory for members, message authors, and activity subjects
directory: Entity<Directory>,
list_state: ListState, list_state: ListState,
head: Option<EventId>, head: Option<EventId>,
/// The message the composer is replying to, if any. /// The message the composer is replying to, if any.
@@ -356,12 +358,14 @@ impl GroupPanel {
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| { subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
if !matches!(event, GroupEvent::Sent(_)) { if !matches!(event, GroupEvent::Sent(_)) {
this.sync(cx); this.sync(cx);
this.track_participants(cx);
cx.notify(); cx.notify();
} }
})); }));
subscriptions.push(cx.observe(&group, |this, _group, cx| { subscriptions.push(cx.observe(&group, |this, _group, cx| {
this.sync(cx); this.sync(cx);
this.track_participants(cx);
cx.notify(); cx.notify();
})); }));
@@ -376,10 +380,11 @@ impl GroupPanel {
)); ));
}); });
Self { let mut this = Self {
id, id,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
group: group.downgrade(), group: group.downgrade(),
directory: Directory::new(cx),
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)), list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
head, head,
reply_to: None, reply_to: None,
@@ -389,7 +394,29 @@ impl GroupPanel {
uploading: false, uploading: false,
tasks: smallvec![], tasks: smallvec![],
_subscriptions: subscriptions, _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<Self>) {
let Some(group) = self.group.upgrade() else {
return;
};
let group = group.read(cx);
let mut keys: Vec<PublicKey> = 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 { fn item_count(&self, cx: &App) -> usize {
@@ -505,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;
}; };
@@ -586,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(&current_user, cx)
.emoji_tags(content)
}
fn react( fn react(
&mut self, &mut self,
group: WeakEntity<Group>, group: WeakEntity<Group>,
@@ -594,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;
}; };
@@ -877,11 +919,13 @@ impl GroupPanel {
let message = match row { let message = match row {
Row::Message(message) => message, 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 nostr = NostrRegistry::global(cx);
let persons = PersonRegistry::global(cx); let directory = self.directory.clone();
let content = self let content = self
.rendered_texts_by_id .rendered_texts_by_id
@@ -891,7 +935,7 @@ impl GroupPanel {
&message.content, &message.content,
&message.mentions, &message.mentions,
&message.emojis, &message.emojis,
&persons, &directory,
cx, cx,
) )
}) })
@@ -916,6 +960,7 @@ impl GroupPanel {
panel: cx.weak_entity(), panel: cx.weak_entity(),
admin, admin,
pinned, pinned,
persons: self.directory.clone(),
}, },
cx, cx,
) )
@@ -990,8 +1035,7 @@ impl GroupPanel {
let context = self.group.upgrade().and_then(|entity| { let context = self.group.upgrade().and_then(|entity| {
entity.read_with(cx, |group, cx| { entity.read_with(cx, |group, cx| {
let message = group.message(target)?; let message = group.message(target)?;
let persons = PersonRegistry::global(cx); let author = self.directory.read(cx).person(&message.author, cx);
let author = persons.read(cx).get(&message.author, cx);
let preview = message.preview(); let preview = message.preview();
let line = preview.lines().next().unwrap_or_default().trim(); let line = preview.lines().next().unwrap_or_default().trim();
+31 -10
View File
@@ -2,12 +2,12 @@ use chat::Message;
use common::TimestampExt; use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
WeakEntity, div, px, WeakEntity, div, px,
}; };
use nip29::{Activity, Group, Reaction}; use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind}; use nostr_sdk::prelude::{EventId, Kind};
use person::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 _};
@@ -25,6 +25,7 @@ pub(crate) struct Actions {
pub panel: WeakEntity<GroupPanel>, pub panel: WeakEntity<GroupPanel>,
pub admin: bool, pub admin: bool,
pub pinned: bool, pub pinned: bool,
pub persons: Entity<Directory>,
} }
pub(crate) fn render( pub(crate) fn render(
@@ -37,10 +38,10 @@ pub(crate) fn render(
cx: &App, cx: &App,
) -> AnyElement { ) -> AnyElement {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let persons = PersonRegistry::global(cx);
let hide_avatar = AppSettings::get_hide_avatar(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( let reaction_groups = group_reactions(
reactions.iter().map(|reaction| { reactions.iter().map(|reaction| {
@@ -66,6 +67,7 @@ pub(crate) fn render(
.when(!message.replies_to.is_empty(), |this| { .when(!message.replies_to.is_empty(), |this| {
this.children(replies( this.children(replies(
&message.replies_to, &message.replies_to,
&persons,
&actions.group, &actions.group,
&actions.panel, &actions.panel,
cx, cx,
@@ -162,7 +164,22 @@ fn react_menu(
) )
}); });
let emojis = PersonRegistry::current_emojis(cx); let emojis = panel
.upgrade()
.and_then(|panel| {
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
Some(
panel
.read(cx)
.directory
.read(cx)
.person(&current_user, cx)
.emojis()
.to_vec(),
)
})
.unwrap_or_default();
if emojis.is_empty() { if emojis.is_empty() {
return menu; return menu;
} }
@@ -185,9 +202,13 @@ fn react_menu(
}) })
} }
pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyElement { pub(crate) fn render_activity(
let persons = PersonRegistry::global(cx); ix: usize,
let subject = persons.read(cx).get(&activity.subject(), cx); activity: &Activity,
persons: &Entity<Directory>,
cx: &App,
) -> AnyElement {
let subject = persons.read(cx).person(&activity.subject(), cx);
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
h_flex() h_flex()
@@ -262,6 +283,7 @@ fn action(activity: &Activity) -> String {
fn replies( fn replies(
targets: &[EventId], targets: &[EventId],
persons: &Entity<Directory>,
group: &WeakEntity<Group>, group: &WeakEntity<Group>,
panel: &WeakEntity<GroupPanel>, panel: &WeakEntity<GroupPanel>,
cx: &App, cx: &App,
@@ -275,8 +297,7 @@ fn replies(
for (ix, id) in targets.iter().enumerate() { for (ix, id) in targets.iter().enumerate() {
let context = entity.read_with(cx, |group, cx| { let context = entity.read_with(cx, |group, cx| {
let message = group.message(*id)?; let message = group.message(*id)?;
let persons = PersonRegistry::global(cx); let author = persons.read(cx).person(&message.author, cx);
let author = persons.read(cx).get(&message.author, cx);
Some((author.name(), message.preview())) Some((author.name(), message.preview()))
}); });
+13 -4
View File
@@ -11,7 +11,7 @@ use gpui::{
SharedString, Styled, Subscription, Task, Window, div, px, relative, SharedString, Styled, Subscription, Task, Window, div, px, relative,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::Directory;
use settings::AppSettings; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner}; use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
@@ -63,6 +63,8 @@ pub struct DeviceRegistry {
pub(crate) pending_request: bool, pub(crate) pending_request: bool,
/// Whether an announcement has been made for this device /// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>, pub(crate) announcement_existed: Arc<AtomicBool>,
/// Person directory for requesters shown in notifications
directory: Entity<Directory>,
/// Relay notification listener /// Relay notification listener
notification_listener: Option<Task<Result<(), Error>>>, notification_listener: Option<Task<Result<(), Error>>>,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
@@ -122,6 +124,7 @@ impl DeviceRegistry {
signer, signer,
pending_request: false, pending_request: false,
announcement_existed: Arc::new(AtomicBool::new(false)), announcement_existed: Arc::new(AtomicBool::new(false)),
directory: Directory::new(cx),
notification_listener: None, notification_listener: None,
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
@@ -690,7 +693,11 @@ impl DeviceRegistry {
} }
/// Build a notification for the encryption request. /// Build a notification for the encryption request.
fn notification(&self, event: Event, cx: &Context<Self>) -> Result<Notification, Error> { fn notification(
&mut self,
event: Event,
cx: &mut Context<Self>,
) -> Result<Notification, Error> {
const MSG: &str = "You've requested an encryption key from another device. \ const MSG: &str = "You've requested an encryption key from another device. \
Approve to allow Coop to share with it. Before approving, \ Approve to allow Coop to share with it. Before approving, \
compare the code with the one shown on the requesting \ compare the code with the one shown on the requesting \
@@ -705,8 +712,8 @@ impl DeviceRegistry {
.context("Request has no valid requester key")?; .context("Request has no valid requester key")?;
let code = authorization_code(&requester); let code = authorization_code(&requester);
let persons = PersonRegistry::global(cx); let directory = self.directory.clone();
let profile = persons.read(cx).get(&requester, cx); directory.update(cx, |directory, cx| directory.track([requester], cx));
let code_label = code.clone(); let code_label = code.clone();
let entity = cx.entity().downgrade(); let entity = cx.entity().downgrade();
@@ -719,6 +726,8 @@ impl DeviceRegistry {
.with_kind(NotificationKind::Info) .with_kind(NotificationKind::Info)
.title("Encryption Key Request") .title("Encryption Key Request")
.content(move |_this, _window, cx| { .content(move |_this, _window, cx| {
let profile = directory.read(cx).person(&requester, cx);
v_flex() v_flex()
.gap(px(theme::SPACE)) .gap(px(theme::SPACE))
.text_style(TextStyle::Callout) .text_style(TextStyle::Callout)
+234
View File
@@ -0,0 +1,234 @@
use std::cmp::Reverse;
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};
use state::NostrRegistry;
use crate::{Person, PersonEvent, PersonRegistry};
/// Directory of persons tracked by a view
#[derive(Debug)]
pub struct Directory {
persons: HashMap<PublicKey, Person>,
/// Keys awaiting their first update, guarded for render-time access
pending: RwLock<HashSet<PublicKey>>,
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<Self> {
let registry = PersonRegistry::global(cx);
cx.new(|cx| {
let subscription = cx.subscribe(&registry, |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],
}
})
}
/// Load known profiles from the database, request the rest
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>) {
/// Profiles and emoji lists loaded from the database
type Loaded = (Vec<Person>, Vec<(PublicKey, Vec<(String, Url)>)>);
let missing: Vec<PublicKey> = keys
.into_iter()
.filter(|key| {
!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<Result<Loaded, Error>> = cx.background_spawn(async move {
let mut metadata_events: Vec<Event> = client
.database()
.query(Filter::new().kind(Kind::Metadata).authors(authors.clone()))
.await?
.into_iter()
.collect();
// Keep only the newest event per author
metadata_events.sort_by_key(|event| Reverse(event.created_at));
let mut seen = HashSet::new();
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();
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| {
let loaded = task.await;
this.update(cx, |this, cx| {
let remaining = match loaded {
Ok((persons, emojis)) => {
let found: HashSet<PublicKey> =
persons.iter().map(Person::public_key).collect();
this.bulk_insert(persons, cx);
// Emojis apply on top of loaded or already received data
let mut updated = false;
for (public_key, emojis) in emojis {
if emojis.is_empty() {
continue;
}
let person = this
.persons
.entry(public_key)
.or_insert_with(|| Person::new(public_key, Metadata::default()));
person.set_emojis(emojis);
updated = true;
}
if updated {
cx.notify();
}
missing
.into_iter()
.filter(|key| !found.contains(key))
.collect()
}
Err(e) => {
log::warn!("Failed to load persons from the database: {e}");
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())
}
/// Merge event data into the tracked person
fn handle_event(&mut self, event: PersonEvent, cx: &mut Context<Self>) {
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))
{
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
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
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<R>(&self, then: impl FnOnce(&mut HashSet<PublicKey>) -> R) -> R {
let mut pending = match self.pending.write() {
Ok(pending) => pending,
Err(poisoned) => poisoned.into_inner(),
};
then(&mut pending)
}
}
+79 -186
View File
@@ -1,17 +1,18 @@
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, 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::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT}; use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
mod directory;
mod person; mod person;
pub use directory::*;
pub use person::*; pub use person::*;
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
@@ -22,6 +23,30 @@ struct GlobalPersonRegistry(Entity<PersonRegistry>);
impl Global for GlobalPersonRegistry {} impl Global for GlobalPersonRegistry {}
/// A change to a person's data
#[derive(Debug, Clone)]
pub enum PersonEvent {
/// 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 {}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Dispatch { enum Dispatch {
Person(Person), Person(Person),
@@ -33,10 +58,8 @@ enum Dispatch {
/// Person Registry /// Person Registry
#[derive(Debug)] #[derive(Debug)]
pub struct PersonRegistry { pub struct PersonRegistry {
/// Collection of all persons (user profiles) /// Public keys with a metadata request in flight
persons: HashMap<PublicKey, Entity<Person>>, in_flight: RwLock<HashSet<PublicKey>>,
/// Set of public keys that have been seen
seen: RwLock<HashSet<PublicKey>>,
sender: flume::Sender<PublicKey>, sender: flume::Sender<PublicKey>,
tasks: SmallVec<[Task<()>; 4]>, tasks: SmallVec<[Task<()>; 4]>,
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
@@ -57,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);
@@ -103,17 +125,8 @@ 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(), in_flight: RwLock::new(HashSet::new()),
seen: RwLock::new(HashSet::new()),
sender: metadata_tx, sender: metadata_tx,
tasks, tasks,
_subscriptions: smallvec![subscription], _subscriptions: smallvec![subscription],
@@ -146,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;
} }
@@ -198,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);
@@ -283,130 +224,82 @@ impl PersonRegistry {
} }
/// Set profile encryption keys announcement /// 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<Self>) {
let Some(announcement) = Announcement::from_event(event) else { let Some(announcement) = Announcement::from_event(event) else {
log::warn!("Ignoring announcement with missing or invalid key tag"); log::warn!("Ignoring announcement with missing or invalid key tag");
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(); });
});
} 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 App) { 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();
});
} 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
fn set_emojis(&mut self, event: &Event, cx: &mut App) { fn set_emojis(&mut self, event: &Event, cx: &mut Context<Self>) {
self.set_emojis_for(event.pubkey, emojis_from_tags(&event.tags), cx); self.set_emojis_for(event.pubkey, emojis_from_tags(&event.tags), cx);
} }
/// Set custom emoji for a person /// 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(
if let Some(person) = self.persons.get(&public_key) { &mut self,
person.update(cx, |person, cx| { public_key: PublicKey,
person.set_emojis(emojis); emojis: Vec<(String, Url)>,
cx.notify(); cx: &mut Context<Self>,
}); ) {
} else { cx.emit(PersonEvent::Emojis { public_key, emojis });
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>) { pub fn insert(&mut self, person: Person, cx: &mut Context<Self>) {
for person in persons.into_iter() {
let public_key = person.public_key();
self.persons
.entry(public_key)
.or_insert_with(|| cx.new(|_| person));
}
cx.notify();
}
/// Insert or update a person
pub fn insert(&mut self, person: Person, cx: &mut App) {
let public_key = person.public_key(); let public_key = person.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();
});
}
None => {
self.persons.insert(public_key, cx.new(|_| person));
}
}
} }
/// Get single person by public key /// Queue a batched metadata request for keys without one in flight
pub fn get(&self, public_key: &PublicKey, cx: &App) -> Person { pub(crate) fn fetch<I>(&self, keys: I)
if let Some(person) = self.persons.get(public_key) { where
return person.read(cx).clone(); I: IntoIterator<Item = PublicKey>,
} {
let mut queue = Vec::new();
{
let mut in_flight = match self.in_flight.write() {
Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(),
};
let public_key = *public_key; for key in keys {
if in_flight.insert(key) {
if self.seen.write().unwrap().insert(public_key) { queue.push(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 for public_key in queue {
Person::new(public_key, Metadata::default()) if let Err(error) = self.sender.send(public_key) {
log::warn!("Failed to queue metadata request: {error}");
}
}
} }
/// The signed-in user's custom emoji /// Allow future fetches for a key once its metadata has arrived
pub fn current_emojis(cx: &App) -> Vec<(String, Url)> { fn release_in_flight(&self, public_key: &PublicKey) {
let registry = Self::global(cx); let mut in_flight = match self.in_flight.write() {
let nostr = NostrRegistry::global(cx); Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(),
let Some(public_key) = nostr.read(cx).current_user() else {
return Vec::new();
}; };
in_flight.remove(public_key);
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)
} }
} }
-21
View File
@@ -57,21 +57,6 @@ impl Person {
} }
} }
/// Build profile encryption keys announcement
pub(crate) fn with_announcement(mut self, announcement: Announcement) -> Self {
self.announcement = Some(announcement);
self
}
/// Build profile messaging relays
pub(crate) fn with_messaging_relays<I>(mut self, relays: I) -> Self
where
I: IntoIterator<Item = RelayUrl>,
{
self.messaging_relays = relays.into_iter().collect();
self
}
/// Get profile public key /// 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();
+10 -8
View File
@@ -10,7 +10,7 @@ use gpui::{
Window, div, px, uniform_list, Window, div, px, uniform_list,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::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 _};
@@ -39,6 +39,8 @@ pub struct NewChat {
input: Entity<InputState>, input: Entity<InputState>,
/// Current user's contacts /// Current user's contacts
contacts: Option<Vec<PublicKey>>, contacts: Option<Vec<PublicKey>>,
/// Person directory for contacts and selected members
directory: Entity<Directory>,
/// Public keys selected as members of the chat /// Public keys selected as members of the chat
members: Vec<PublicKey>, members: Vec<PublicKey>,
/// Whether a NIP-05 address is being resolved /// Whether a NIP-05 address is being resolved
@@ -69,6 +71,7 @@ impl NewChat {
dock, dock,
input, input,
contacts: None, contacts: None,
directory: Directory::new(cx),
members: Vec::new(), members: Vec::new(),
resolving: false, resolving: false,
error: None, error: None,
@@ -112,6 +115,9 @@ impl NewChat {
match task.await { match task.await {
Ok(contacts) => { Ok(contacts) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| {
directory.track(contacts.iter().copied(), cx)
});
this.contacts = Some(contacts); this.contacts = Some(contacts);
cx.notify(); cx.notify();
})?; })?;
@@ -133,7 +139,6 @@ impl NewChat {
return Vec::new(); return Vec::new();
}; };
let persons = PersonRegistry::global(cx);
let query = self.input.read(cx).value().trim().to_lowercase(); let query = self.input.read(cx).value().trim().to_lowercase();
if query.is_empty() { if query.is_empty() {
@@ -146,7 +151,7 @@ impl NewChat {
if public_key.to_hex().to_lowercase().contains(&query) { if public_key.to_hex().to_lowercase().contains(&query) {
return true; 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) name.to_lowercase().contains(&query)
}) })
.cloned() .cloned()
@@ -318,7 +323,6 @@ impl NewChat {
contacts: &[PublicKey], contacts: &[PublicKey],
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Vec<AnyElement> { ) -> Vec<AnyElement> {
let persons = PersonRegistry::global(cx);
let start = range.start; let start = range.start;
contacts contacts
@@ -327,7 +331,7 @@ impl NewChat {
.flatten() .flatten()
.enumerate() .enumerate()
.map(|(offset, public_key)| { .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; let public_key = *public_key;
Nav::new(start + offset) Nav::new(start + offset)
@@ -350,13 +354,11 @@ impl NewChat {
} }
fn render_members(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_members(&self, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
h_flex() h_flex()
.flex_wrap() .flex_wrap()
.gap_1() .gap_1()
.children(self.members.iter().map(|public_key| { .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 public_key = *public_key;
let id = SharedString::from(format!("m-{}", public_key.to_hex())); let id = SharedString::from(format!("m-{}", public_key.to_hex()));
+14 -8
View File
@@ -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);
+11 -5
View File
@@ -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, shorten_pubkey};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
@@ -33,6 +33,8 @@ pub struct Screening {
last_active: Option<Timestamp>, last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened. /// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>, mutual_contacts: Vec<PublicKey>,
/// Person directory for the person and their mutual contacts
directory: Entity<Directory>,
tasks: SmallVec<[Task<()>; 3]>, tasks: SmallVec<[Task<()>; 3]>,
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
} }
@@ -53,12 +55,16 @@ impl Screening {
this.verify_identifier(cx); this.verify_identifier(cx);
}); });
let directory = Directory::new(cx);
directory.update(cx, |directory, cx| directory.track([public_key], cx));
Self { Self {
public_key, public_key,
verified: false, verified: false,
followed: false, followed: false,
last_active: None, last_active: None,
mutual_contacts: vec![], mutual_contacts: vec![],
directory,
tasks: smallvec![], tasks: smallvec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -208,8 +214,7 @@ impl Screening {
} }
fn profile(&self, cx: &Context<Self>) -> Person { fn profile(&self, cx: &Context<Self>) -> Person {
let persons = PersonRegistry::global(cx); self.directory.read(cx).person(&self.public_key, cx)
persons.read(cx).get(&self.public_key, cx)
} }
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> { fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
@@ -260,22 +265,23 @@ impl Screening {
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone(); let contacts = self.mutual_contacts.clone();
let directory = self.directory.clone();
window.open_dialog(cx, move |this, _window, _cx| { window.open_dialog(cx, move |this, _window, _cx| {
let contacts = contacts.clone(); let contacts = contacts.clone();
let directory = directory.clone();
let total = contacts.len(); let total = contacts.len();
this.title("Mutual contacts").child( this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child( v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| { uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total); let mut items = Vec::with_capacity(total);
for ix in range { for ix in range {
let Some(contact) = contacts.get(ix) else { let Some(contact) = contacts.get(ix) else {
continue; continue;
}; };
let profile = persons.read(cx).get(contact, cx); let profile = directory.read(cx).person(contact, cx);
items.push( items.push(
h_flex() h_flex()
+11 -3
View File
@@ -9,7 +9,7 @@ use gpui::{
}; };
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::Directory;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
@@ -41,6 +41,9 @@ pub struct ContactListPanel {
/// All contacts /// All contacts
contacts: HashSet<PublicKey>, contacts: HashSet<PublicKey>,
/// Person directory for the contacts
directory: Entity<Directory>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
@@ -73,6 +76,7 @@ impl ContactListPanel {
input, input,
updating: false, updating: false,
contacts: HashSet::new(), contacts: HashSet::new(),
directory: Directory::new(cx),
error: None, error: None,
_subscriptions: subscriptions, _subscriptions: subscriptions,
tasks: vec![], tasks: vec![],
@@ -110,6 +114,9 @@ impl ContactListPanel {
// Update state // Update state
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| {
directory.track(public_keys.iter().copied(), cx)
});
this.contacts.extend(public_keys); this.contacts.extend(public_keys);
cx.notify(); cx.notify();
})?; })?;
@@ -123,6 +130,8 @@ impl ContactListPanel {
if let Ok(public_key) = PublicKey::parse(&value) { if let Ok(public_key) = PublicKey::parse(&value) {
if self.contacts.insert(public_key) { if self.contacts.insert(public_key) {
self.directory
.update(cx, |directory, cx| directory.track([public_key], cx));
self.input.update(cx, |this, cx| { self.input.update(cx, |this, cx| {
this.set_value("", window, cx); this.set_value("", window, cx);
}); });
@@ -218,11 +227,10 @@ impl ContactListPanel {
} }
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> { fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
let mut items = Vec::new(); let mut items = Vec::new();
for (ix, public_key) in self.contacts.iter().enumerate() { 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( items.push(
h_flex() h_flex()
+14 -3
View File
@@ -8,7 +8,6 @@ use gpui::{
}; };
use nip29::Group; use nip29::Group;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings; use settings::AppSettings;
use state::NostrRegistry; use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _}; use theme::{ActiveTheme, TextStyle, Typeset as _};
@@ -51,6 +50,19 @@ impl Sidebar {
pub(super) fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) { pub(super) fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
self.group = Some(group.downgrade()); self.group = Some(group.downgrade());
self.group_open = true; self.group_open = true;
let group = group.read(cx);
let mut keys: Vec<PublicKey> = 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(); cx.notify();
} }
@@ -253,9 +265,8 @@ impl Sidebar {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let hide_avatar = AppSettings::get_hide_avatar(cx); 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 group = group.clone();
let public_key = *public_key; let public_key = *public_key;
+1 -3
View File
@@ -5,7 +5,6 @@ use gpui::{
Stateful, Styled, div, px, Stateful, Styled, div, px,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use theme::TABBAR_HEIGHT; use theme::TABBAR_HEIGHT;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
@@ -22,8 +21,7 @@ impl Sidebar {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Stateful<Div> { ) -> Stateful<Div> {
let updater = AutoUpdater::try_global(cx); let updater = AutoUpdater::try_global(cx);
let persons = PersonRegistry::global(cx); let profile = self.directory.read(cx).person(current_user, cx);
let profile = persons.read(cx).get(current_user, cx);
let avatar = profile.avatar(); let avatar = profile.avatar();
let avatar_seed = profile.avatar_seed(); let avatar_seed = profile.avatar_seed();
+3
View File
@@ -69,6 +69,9 @@ impl Sidebar {
match task.await { match task.await {
Ok(contacts) => { Ok(contacts) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| {
directory.track(contacts.iter().copied(), cx)
});
this.contacts = Some(contacts); this.contacts = Some(contacts);
cx.notify(); cx.notify();
})?; })?;
+24 -8
View File
@@ -13,6 +13,7 @@ use gpui::{
use instant::Duration; use instant::Duration;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::Directory;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent}; use state::{FIND_DELAY, NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _}; use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
@@ -61,6 +62,8 @@ pub struct Sidebar {
contacts_open: bool, contacts_open: bool,
/// The signed-in user's contacts, loaded when the contact picker opens. /// The signed-in user's contacts, loaded when the contact picker opens.
contacts: Option<Vec<PublicKey>>, contacts: Option<Vec<PublicKey>>,
/// Person directory for contacts, search hits, and group members
directory: Entity<Directory>,
/// Contacts selected in the contact picker. /// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>, selected_contacts: HashSet<PublicKey>,
/// Scroll state of the contact picker's list. /// Scroll state of the contact picker's list.
@@ -139,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(
@@ -196,6 +211,7 @@ impl Sidebar {
chats_open: None, chats_open: None,
contacts_open: false, contacts_open: false,
contacts: None, contacts: None,
directory: Directory::new(cx),
selected_contacts: HashSet::new(), selected_contacts: HashSet::new(),
contacts_scroll: UniformListScrollHandle::new(), contacts_scroll: UniformListScrollHandle::new(),
requests_open: false, requests_open: false,
+3 -6
View File
@@ -9,7 +9,6 @@ use gpui::{
}; };
use nip29::Group; use nip29::Group;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings; use settings::AppSettings;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar; use ui::avatar::Avatar;
@@ -196,8 +195,7 @@ impl Sidebar {
.into_any_element() .into_any_element()
} }
SidebarRow::Contact { public_key } => { SidebarRow::Contact { public_key } => {
let persons = PersonRegistry::global(cx); let person = self.directory.read(cx).person(public_key, cx);
let person = persons.read(cx).get(public_key, cx);
let selected = self.selected_contacts.contains(public_key); let selected = self.selected_contacts.contains(public_key);
let sidebar = cx.entity().downgrade(); let sidebar = cx.entity().downgrade();
let public_key = public_key.to_owned(); let public_key = public_key.to_owned();
@@ -224,8 +222,7 @@ impl Sidebar {
.into_any_element() .into_any_element()
} }
SidebarRow::Profile { public_key } => { SidebarRow::Profile { public_key } => {
let persons = PersonRegistry::global(cx); let person = self.directory.read(cx).person(public_key, cx);
let person = persons.read(cx).get(public_key, cx);
let public_key = public_key.to_owned(); let public_key = public_key.to_owned();
Nav::new(index) Nav::new(index)
@@ -330,7 +327,7 @@ impl Sidebar {
.into_any_element() .into_any_element()
} }
SidebarRow::Message { hit } => { 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 name = hit.room.read(cx).display_name(cx);
let detail = format!("{} · {}", name, hit.created_at.to_ago()); let detail = format!("{} · {}", name, hit.created_at.to_ago());
let dock = self.dock.clone(); let dock = self.dock.clone();
+14 -1
View File
@@ -106,6 +106,14 @@ impl Sidebar {
.chain(groups.into_iter().map(SearchResult::Group)) .chain(groups.into_iter().map(SearchResult::Group))
.chain(messages.into_iter().map(SearchResult::Message)) .chain(messages.into_iter().map(SearchResult::Message))
.collect(); .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(); cx.notify();
} }
@@ -133,7 +141,7 @@ impl Sidebar {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.global_finding = false; this.global_finding = false;
this.global_results = Some(match result { let keys: Vec<PublicKey> = match result {
Ok(profiles) => { Ok(profiles) => {
// Relays may return the same profile more than once // Relays may return the same profile more than once
let mut seen = HashSet::new(); let mut seen = HashSet::new();
@@ -146,7 +154,12 @@ impl Sidebar {
log::debug!("Global search failed: {error}"); log::debug!("Global search failed: {error}");
Vec::new() Vec::new()
} }
};
this.directory.update(cx, |directory, cx| {
directory.track(keys.iter().copied(), cx)
}); });
this.global_results = Some(keys);
cx.notify(); cx.notify();
}) })
.ok(); .ok();