Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
830917d897 | ||
|
|
f87bed75bf | ||
|
|
d092666404 | ||
|
|
79825d1825 | ||
|
|
b711a99192 |
@@ -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
@@ -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(¤t_user, cx);
|
||||||
let sender = persons.read(cx).get(¤t_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
@@ -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(¤t_user, cx)
|
||||||
|
.emojis()
|
||||||
|
.to_vec()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Button::new("emoji")
|
Button::new("emoji")
|
||||||
.icon(IconName::Emoji)
|
.icon(IconName::Emoji)
|
||||||
|
|||||||
@@ -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<_>>();
|
||||||
|
|||||||
@@ -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
@@ -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(¤t_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();
|
||||||
|
|
||||||
|
|||||||
@@ -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(¤t_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
@@ -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)
|
||||||
|
|||||||
@@ -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(®istry, |this: &mut Self, _registry, event, cx| {
|
||||||
|
this.handle_event(event.clone(), cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
persons: HashMap::new(),
|
||||||
|
pending: RwLock::new(HashSet::new()),
|
||||||
|
tasks: SmallVec::new(),
|
||||||
|
_subscriptions: smallvec![subscription],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ pub const CLIENT_NAME: &str = "Coop";
|
|||||||
pub const APP_ID: &str = "su.reya.coop";
|
pub const APP_ID: &str = "su.reya.coop";
|
||||||
|
|
||||||
/// Keyring name
|
/// Keyring name
|
||||||
pub(crate) const MASTER_KEYRING: &str = "Coop Master Key";
|
pub const USER_KEYRING: &str = "Coop Safe Storage";
|
||||||
pub const USER_KEYRING: &str = "Coop User Credential";
|
|
||||||
|
|
||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
|
/// Default timeout for Nostr Connect
|
||||||
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
@@ -31,9 +33,8 @@ pub(crate) const INDEXER_RELAYS: [&str; 2] =
|
|||||||
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||||
|
|
||||||
/// Default bootstrap relays
|
/// Default bootstrap relays
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||||
"wss://relay.ditto.pub",
|
"wss://relay.ditto.pub",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://relay.nostr.net",
|
|
||||||
"wss://profiles.nostr1.com",
|
"wss://profiles.nostr1.com",
|
||||||
];
|
];
|
||||||
|
|||||||
+255
-70
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use browser_signer_proxy::prelude::*;
|
use browser_signer_proxy::prelude::*;
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
@@ -53,6 +53,8 @@ pub enum StateEvent {
|
|||||||
NoSigner,
|
NoSigner,
|
||||||
/// The signer has changed
|
/// The signer has changed
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
|
/// A stored credential needs a passphrase to unlock
|
||||||
|
PassphraseRequired,
|
||||||
/// An error occurred
|
/// An error occurred
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -75,16 +77,14 @@ impl StateEvent {
|
|||||||
pub struct NostrRegistry {
|
pub struct NostrRegistry {
|
||||||
/// Nostr client
|
/// Nostr client
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|
||||||
/// Universal signer
|
/// Universal signer
|
||||||
signer: UniversalSigner,
|
signer: UniversalSigner,
|
||||||
|
|
||||||
/// Current user's public key
|
/// Current user's public key
|
||||||
current_user: Option<PublicKey>,
|
current_user: Option<PublicKey>,
|
||||||
|
/// Whether the stored credential needs a passphrase to unlock
|
||||||
|
passphrase_required: bool,
|
||||||
/// Whether the initial credential check has concluded
|
/// Whether the initial credential check has concluded
|
||||||
ready: bool,
|
ready: bool,
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
/// Tasks for asynchronous operations
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,8 @@ impl NostrRegistry {
|
|||||||
.gossip(NostrGossipMemory::unbounded())
|
.gossip(NostrGossipMemory::unbounded())
|
||||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||||
.connect_timeout(Duration::from_secs(10))
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.verify_subscriptions(true)
|
||||||
|
.ban_relay_on_mismatch(true)
|
||||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||||
timeout: Duration::from_secs(600),
|
timeout: Duration::from_secs(600),
|
||||||
})
|
})
|
||||||
@@ -140,11 +142,11 @@ impl NostrRegistry {
|
|||||||
this.mark_ready(cx);
|
this.mark_ready(cx);
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
} else if let Some(secret) = cli_key {
|
} else if let Some(secret) = cli_key {
|
||||||
// Use CLI-provided key -- same path as get_user_credential
|
// Use CLI-provided key -- same path as restore_session
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
} else {
|
} else {
|
||||||
this.get_user_credential(cx);
|
this.restore_session(cx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -153,6 +155,7 @@ impl NostrRegistry {
|
|||||||
client,
|
client,
|
||||||
signer,
|
signer,
|
||||||
current_user: None,
|
current_user: None,
|
||||||
|
passphrase_required: false,
|
||||||
ready: false,
|
ready: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,7 @@ impl NostrRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update the signer
|
/// Update the signer
|
||||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
@@ -196,6 +199,7 @@ impl NostrRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.signer.swap_inner(new_signer);
|
this.signer.swap_inner(new_signer);
|
||||||
this.current_user = Some(public_key);
|
this.current_user = Some(public_key);
|
||||||
|
this.passphrase_required = false;
|
||||||
this.mark_ready(cx);
|
this.mark_ready(cx);
|
||||||
cx.emit(StateEvent::SignerChanged);
|
cx.emit(StateEvent::SignerChanged);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -247,93 +251,254 @@ impl NostrRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the user's credential and set the signer if valid
|
/// Restore the signer from the stored credential
|
||||||
fn get_user_credential(&mut self, cx: &mut Context<Self>) {
|
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||||
let master_keyring = self.get_master_key(cx);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match user_keyring.await {
|
let result = async {
|
||||||
Ok(Some((_username, secret))) => {
|
match user_keyring.await {
|
||||||
let content = String::from_utf8(secret)?;
|
Ok(Some((_username, secret))) => {
|
||||||
|
let content = String::from_utf8(secret)?;
|
||||||
|
|
||||||
if content.starts_with("nsec1") {
|
if content.starts_with("nsec1") {
|
||||||
let secret_key = SecretKey::parse(&content)?;
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||||
let keys = Keys::new(secret_key);
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
} else if content.starts_with("bunker://") {
|
||||||
|
// The master key used for Nostr Connect is embedded in
|
||||||
|
// the stored URI so reconnects reuse the same local key
|
||||||
|
let (base, keys) = extract_master_key(&content);
|
||||||
|
let uri = NostrConnectUri::parse(base)?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
// Construct the nostr connect signer
|
||||||
this.set_signer(keys, cx);
|
let mut signer = NostrConnect::new(
|
||||||
cx.notify();
|
uri,
|
||||||
})?;
|
keys,
|
||||||
} else if content.starts_with("bunker://") {
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
let keys = master_keyring.await;
|
None,
|
||||||
let timeout = Duration::from_secs(30);
|
)?;
|
||||||
let uri = NostrConnectUri::parse(content)?;
|
|
||||||
|
|
||||||
// Construct the nostr connect signer
|
// Handle auth url with the default browser
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
this.update(cx, |this, cx| {
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
this.set_signer(signer, cx);
|
||||||
|
})?;
|
||||||
this.update(cx, |this, cx| {
|
} else if content.starts_with("ncryptsec1") {
|
||||||
this.set_signer(signer, cx);
|
this.update(cx, |this, cx| {
|
||||||
cx.notify();
|
this.passphrase_required = true;
|
||||||
})?;
|
this.mark_ready(cx);
|
||||||
} else if content == "proxy" {
|
cx.emit(StateEvent::PassphraseRequired);
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
})?;
|
||||||
|
} else if content == "proxy" {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.mark_ready(cx);
|
||||||
|
this.connect_proxy(cx);
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.mark_ready(cx);
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.mark_ready(cx);
|
this.mark_ready(cx);
|
||||||
this.connect_proxy(cx);
|
cx.emit(StateEvent::NoSigner);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
|
||||||
this.update(cx, |this, cx| this.mark_ready(cx))?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
this.update(cx, |this, cx| {
|
Ok::<(), Error>(())
|
||||||
this.mark_ready(cx);
|
}
|
||||||
cx.emit(StateEvent::NoSigner);
|
.await;
|
||||||
})?;
|
|
||||||
}
|
if let Err(e) = result {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.mark_ready(cx);
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the master key that used for Nostr Connect
|
/// Unlock the stored encrypted key with its passphrase
|
||||||
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
|
pub fn restore_with_passphrase(
|
||||||
if cfg!(target_arch = "wasm32") {
|
&mut self,
|
||||||
return cx.background_spawn(async move { Keys::generate() });
|
password: &str,
|
||||||
}
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let password = password.to_owned();
|
||||||
|
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||||
|
|
||||||
let task = cx.read_credentials(MASTER_KEYRING);
|
cx.spawn(async move |this, cx| {
|
||||||
|
let content = user_keyring
|
||||||
|
.await?
|
||||||
|
.map(|(_username, secret)| String::from_utf8(secret))
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
if !content.starts_with("ncryptsec1") {
|
||||||
let (keys, new_key) = match task.await {
|
return Err(anyhow!("stored credential is not passphrase-encrypted"));
|
||||||
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
|
|
||||||
Ok(secret_key) => (Keys::new(secret_key), false),
|
|
||||||
_ => (Keys::generate(), true),
|
|
||||||
},
|
|
||||||
_ => (Keys::generate(), true),
|
|
||||||
};
|
|
||||||
|
|
||||||
if new_key {
|
|
||||||
let keys_clone = keys.clone();
|
|
||||||
let username = keys_clone.public_key().to_hex();
|
|
||||||
let password = keys_clone.secret_key().to_secret_bytes();
|
|
||||||
|
|
||||||
cx.update(|cx| {
|
|
||||||
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
|
|
||||||
cx.background_spawn(async move { task.await.ok() }).detach();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keys
|
let decrypt_task = cx.background_spawn(async move {
|
||||||
|
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
|
||||||
|
let secret = encrypted.decrypt(&password)?;
|
||||||
|
Ok::<_, Error>(Keys::new(secret))
|
||||||
|
});
|
||||||
|
|
||||||
|
let keys = decrypt_task.await?;
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Import an `nsec1...` secret key, storing it NIP-49 encrypted with `password`
|
||||||
|
pub fn import_nsec(
|
||||||
|
&mut self,
|
||||||
|
nsec: &str,
|
||||||
|
password: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let keys = match SecretKey::parse(nsec.trim()) {
|
||||||
|
Ok(secret) => Keys::new(secret),
|
||||||
|
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = password.to_owned();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let job = cx.background_spawn(async move {
|
||||||
|
let encrypted =
|
||||||
|
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
|
||||||
|
let ncryptsec = encrypted.to_bech32()?;
|
||||||
|
Ok::<_, Error>((keys, ncryptsec))
|
||||||
|
});
|
||||||
|
|
||||||
|
let (keys, ncryptsec) = job.await?;
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
|
let write = cx.update(|cx| {
|
||||||
|
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
||||||
|
});
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import an NIP-49 encrypted secret key (`ncryptsec1...`), decrypting it with `password`
|
||||||
|
pub fn import_ncryptsec(
|
||||||
|
&mut self,
|
||||||
|
ncryptsec: &str,
|
||||||
|
password: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let ncryptsec = ncryptsec.trim().to_owned();
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = password.to_owned();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let stored = ncryptsec.clone();
|
||||||
|
let decrypt_task = cx.background_spawn(async move {
|
||||||
|
let encrypted = EncryptedSecretKey::from_bech32(&ncryptsec)?;
|
||||||
|
let secret = encrypted.decrypt(&password)?;
|
||||||
|
Ok::<_, Error>(Keys::new(secret))
|
||||||
|
});
|
||||||
|
|
||||||
|
let keys = decrypt_task.await?;
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
|
let write = cx.update(|cx| {
|
||||||
|
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
|
||||||
|
});
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a `bunker://...` URI (NIP-46), connecting to the remote signer
|
||||||
|
pub fn import_bunker(
|
||||||
|
&mut self,
|
||||||
|
uri: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let uri_string = uri.trim().to_owned();
|
||||||
|
|
||||||
|
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
||||||
|
Ok(uri) => uri,
|
||||||
|
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let credential = with_master_key(&uri_string, &keys);
|
||||||
|
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
// Construct the nostr connect signer
|
||||||
|
let mut signer = NostrConnect::new(
|
||||||
|
connect_uri,
|
||||||
|
keys,
|
||||||
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Handle auth url with the default browser
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
// Verify the bunker responds before persisting the credential
|
||||||
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(signer, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign out and remove the stored credential
|
||||||
|
pub fn sign_out(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.current_user = None;
|
||||||
|
self.passphrase_required = false;
|
||||||
|
|
||||||
|
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
|
||||||
|
cx.emit(StateEvent::SignerChanged);
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the browser proxy
|
/// Start the browser proxy
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -514,3 +679,23 @@ impl NostrRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract the base URI and the master key from a bunker credential with `master=<nsec>`
|
||||||
|
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||||
|
match credential.split_once("master=") {
|
||||||
|
Some((base, nsec)) => {
|
||||||
|
let keys = SecretKey::parse(nsec)
|
||||||
|
.map(Keys::new)
|
||||||
|
.unwrap_or_else(|_| Keys::generate());
|
||||||
|
(base.trim_end_matches(['?', '&']), keys)
|
||||||
|
}
|
||||||
|
None => (credential, Keys::generate()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed the master key in a bunker URI so reconnects reuse the same local key
|
||||||
|
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||||
|
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||||
|
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
||||||
|
format!("{uri}{separator}master={nsec}")
|
||||||
|
}
|
||||||
|
|||||||
+20
-43
@@ -5,32 +5,9 @@ use std::pin::Pin;
|
|||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use nostr_connect::client::AuthUrlHandler;
|
use nostr_connect::client::AuthUrlHandler;
|
||||||
|
use nostr_sdk::error::Error as SignerError;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
|
||||||
|
|
||||||
impl fmt::Display for UniversalSignerError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error for UniversalSignerError {
|
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
||||||
Some(&*self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UniversalSignerError {
|
|
||||||
pub fn new<E>(err: E) -> Self
|
|
||||||
where
|
|
||||||
E: Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
UniversalSignerError(Box::new(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UniversalSigner {
|
pub struct UniversalSigner {
|
||||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
@@ -50,7 +27,7 @@ impl UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Swap the inner signer in-place. All clones see the new signer.
|
/// Swap the inner signer in-place. All clones see the new signer.
|
||||||
pub(crate) fn swap_inner<T>(&self, new_signer: T)
|
pub fn swap_inner<T>(&self, new_signer: T)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
@@ -65,7 +42,7 @@ impl UniversalSigner {
|
|||||||
&self,
|
&self,
|
||||||
public_key: &PublicKey,
|
public_key: &PublicKey,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Result<String, UniversalSignerError> {
|
) -> Result<String, SignerError> {
|
||||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
inner.nip44_encrypt_async(public_key, content).await
|
inner.nip44_encrypt_async(public_key, content).await
|
||||||
}
|
}
|
||||||
@@ -75,7 +52,7 @@ impl UniversalSigner {
|
|||||||
&self,
|
&self,
|
||||||
public_key: &PublicKey,
|
public_key: &PublicKey,
|
||||||
payload: &str,
|
payload: &str,
|
||||||
) -> Result<String, UniversalSignerError> {
|
) -> Result<String, SignerError> {
|
||||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
inner.nip44_decrypt_async(public_key, payload).await
|
inner.nip44_decrypt_async(public_key, payload).await
|
||||||
}
|
}
|
||||||
@@ -84,21 +61,21 @@ impl UniversalSigner {
|
|||||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||||
fn nip44_decrypt_async<'a>(
|
fn nip44_decrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -113,22 +90,22 @@ where
|
|||||||
{
|
{
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,11 +113,11 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,17 +125,17 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncGetPublicKey for UniversalSigner {
|
impl AsyncGetPublicKey for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -169,7 +146,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncSignEvent for UniversalSigner {
|
impl AsyncSignEvent for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -181,7 +158,7 @@ impl AsyncSignEvent for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncNip44 for UniversalSigner {
|
impl AsyncNip44 for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
@@ -211,7 +188,7 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
|||||||
auth_url: Url,
|
auth_url: Url,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
webbrowser::open(auth_url.as_str()).unwrap();
|
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::{Error, anyhow};
|
use anyhow::Error;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
@@ -6,11 +6,11 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
use state::NostrRegistry;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
use ui::{Disableable, WindowExtension, v_flex};
|
||||||
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
||||||
@@ -25,146 +25,157 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a passphrase-only dialog to unlock the stored encrypted key
|
||||||
|
pub fn open_unlock(window: &mut Window, cx: &mut App) {
|
||||||
|
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
|
this.width(px(450.))
|
||||||
|
.show_close(false)
|
||||||
|
.overlay_closable(false)
|
||||||
|
.keyboard(false)
|
||||||
|
.title("Unlock your identity")
|
||||||
|
.child(import.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum Mode {
|
||||||
|
/// Import a credential (nsec, ncryptsec or bunker)
|
||||||
|
Import,
|
||||||
|
/// Unlock the stored encrypted key with its passphrase
|
||||||
|
Unlock,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ImportIdentity {
|
pub struct ImportIdentity {
|
||||||
|
mode: Mode,
|
||||||
/// Secret key input
|
/// Secret key input
|
||||||
key_input: Entity<InputState>,
|
key_input: Entity<InputState>,
|
||||||
/// Password input (if required)
|
/// Passphrase input
|
||||||
pass_input: Entity<InputState>,
|
pass_input: Entity<InputState>,
|
||||||
|
/// Repeated passphrase input when encrypting a plaintext key
|
||||||
|
repass_input: Entity<InputState>,
|
||||||
/// Error message
|
/// Error message
|
||||||
error: Entity<Option<SharedString>>,
|
error: Entity<Option<SharedString>>,
|
||||||
/// Whether the user is currently loading
|
/// Whether the user is currently loading
|
||||||
loading: bool,
|
loading: bool,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
_subscription: Option<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImportIdentity {
|
impl ImportIdentity {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec or bunker://"));
|
Self::build(Mode::Import, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_unlock(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
Self::build(Mode::Unlock, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
||||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
|
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let error = cx.new(|_| None);
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
let input_subscription =
|
let subscriptions = vec![
|
||||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.login(window, cx);
|
this.login(cx);
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
|
cx.subscribe_in(&pass_input, window, |this, _input, event, _window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.login(cx);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
cx.subscribe_in(&repass_input, window, |this, _input, event, _window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.login(cx);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
mode,
|
||||||
key_input,
|
key_input,
|
||||||
pass_input,
|
pass_input,
|
||||||
|
repass_input,
|
||||||
error,
|
error,
|
||||||
loading: false,
|
loading: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscription: Some(input_subscription),
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn login(&mut self, cx: &mut Context<Self>) {
|
||||||
let value = self.key_input.read(cx).value();
|
let password = self.pass_input.read(cx).value().to_string();
|
||||||
let password = self.pass_input.read(cx).value();
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
// Set loading state
|
if self.mode == Mode::Unlock {
|
||||||
|
if password.is_empty() {
|
||||||
|
self.set_error("Passphrase is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_loading(true, cx);
|
||||||
|
let task = nostr.update(cx, |this, cx| this.restore_with_passphrase(&password, cx));
|
||||||
|
self.finish(task, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = self.key_input.read(cx).value().trim().to_string();
|
||||||
self.set_loading(true, cx);
|
self.set_loading(true, cx);
|
||||||
|
|
||||||
if value.starts_with("ncryptsec1") {
|
|
||||||
self.ncryptsec(value, password, window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if value.starts_with("bunker://") {
|
if value.starts_with("bunker://") {
|
||||||
match NostrConnectUri::parse(value) {
|
let task = nostr.update(cx, |this, cx| this.import_bunker(&value, cx));
|
||||||
Ok(uri) => {
|
self.finish(task, cx);
|
||||||
self.bunker(uri, window, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.set_error(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(secret) = SecretKey::parse(&value) {
|
if value.starts_with("ncryptsec1") {
|
||||||
let keys = Keys::new(secret);
|
if password.is_empty() {
|
||||||
let nostr = NostrRegistry::global(cx);
|
self.set_error("Passphrase is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update the signer
|
let task = nostr.update(cx, |this, cx| this.import_ncryptsec(&value, &password, cx));
|
||||||
nostr.update(cx, |this, cx| {
|
self.finish(task, cx);
|
||||||
this.set_signer(keys, cx);
|
return;
|
||||||
});
|
|
||||||
} else {
|
|
||||||
self.set_error("Invalid key", cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A plaintext key is encrypted with the passphrase before it is stored
|
||||||
|
if value.starts_with("nsec1") || SecretKey::parse(&value).is_ok() {
|
||||||
|
if password.is_empty() {
|
||||||
|
self.set_error("Passphrase is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let repass = self.repass_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
if password != repass {
|
||||||
|
self.set_error("Passphrases do not match", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = nostr.update(cx, |this, cx| this.import_nsec(&value, &password, cx));
|
||||||
|
self.finish(task, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_error("Enter an nsec, ncryptsec or bunker://", cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
|
/// Run the import task, surfacing errors while success closes the dialog
|
||||||
where
|
/// through the `SignerChanged` event
|
||||||
S: Into<String>,
|
fn finish(&mut self, task: Task<Result<PublicKey, Error>>, cx: &mut Context<Self>) {
|
||||||
{
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let nostr = NostrRegistry::global(cx);
|
if let Err(e) = task.await {
|
||||||
let content: String = content.into();
|
this.update(cx, |this, cx| {
|
||||||
let password: String = pwd.into();
|
this.set_error(e.to_string(), cx);
|
||||||
|
})?;
|
||||||
if password.is_empty() {
|
|
||||||
self.set_error("Password is required", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else {
|
|
||||||
self.set_error("Secret Key is invalid", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Decrypt in the background to ensure it doesn't block the UI
|
|
||||||
let task = cx.background_spawn(async move {
|
|
||||||
if let Ok(content) = enc.decrypt(&password) {
|
|
||||||
Ok(Keys::new(content))
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Invalid password"))
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(keys) => {
|
|
||||||
nostr.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_signer(keys, cx);
|
|
||||||
window.close_dialog(cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_error(e.to_string(), cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bunker(&mut self, uri: NostrConnectUri, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let master_keys = nostr.read(cx).get_master_key(cx);
|
|
||||||
let password = uri.to_string();
|
|
||||||
let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes());
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let keys = master_keys.await;
|
|
||||||
let timeout = Duration::from_secs(30);
|
|
||||||
|
|
||||||
// Construct the nostr connect signer
|
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(signer, cx);
|
|
||||||
cx.background_spawn(async move { save.await.ok() }).detach();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
@@ -214,36 +225,62 @@ impl ImportIdentity {
|
|||||||
impl Render for ImportIdentity {
|
impl Render for ImportIdentity {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
||||||
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
|
||||||
|
|
||||||
let is_wasm = cfg!(target_arch = "wasm32");
|
let is_wasm = cfg!(target_arch = "wasm32");
|
||||||
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
let is_unlock = self.mode == Mode::Unlock;
|
||||||
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
|
||||||
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
|
let value = self.key_input.read(cx).value().trim().to_string();
|
||||||
|
let require_password = value.starts_with("ncryptsec1");
|
||||||
|
let require_encrypt = value.starts_with("nsec1");
|
||||||
|
let key_warning = require_password || require_encrypt;
|
||||||
|
let bunker_warning = value.starts_with("bunker://");
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap(px(theme::SPACE))
|
.gap_4()
|
||||||
.text_style(TextStyle::Callout)
|
.text_style(TextStyle::Callout)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap(px(theme::SPACE))
|
||||||
.child(
|
.when(!is_unlock, |this| {
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("Use secret key or bunker connection")
|
|
||||||
.child(Input::new(&self.key_input)),
|
|
||||||
)
|
|
||||||
.when(require_password, |this| {
|
|
||||||
this.child(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child("Decrypt Password:")
|
.child("Use secret key or bunker connection")
|
||||||
|
.child(Input::new(&self.key_input)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(is_unlock || require_password, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(if is_unlock {
|
||||||
|
"Passphrase to unlock your identity:"
|
||||||
|
} else {
|
||||||
|
"Decrypt Password:"
|
||||||
|
})
|
||||||
.child(Input::new(&self.pass_input)),
|
.child(Input::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.when(require_encrypt, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Passphrase to encrypt your key on this device:")
|
||||||
|
.child(Input::new(&self.pass_input)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Repeat Passphrase:")
|
||||||
|
.child(Input::new(&self.repass_input)),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(bunker_warning, |this| {
|
.when(bunker_warning, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
@@ -271,21 +308,19 @@ impl Render for ImportIdentity {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("login")
|
Button::new("login")
|
||||||
.label("Continue")
|
.label(if is_unlock { "Unlock" } else { "Continue" })
|
||||||
.primary()
|
.primary()
|
||||||
.font_semibold()
|
|
||||||
.loading(self.loading)
|
.loading(self.loading)
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.login(window, cx);
|
this.login(cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.when(!is_wasm, |this| {
|
.when(!is_wasm && !is_unlock, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("proxy")
|
Button::new("proxy")
|
||||||
.label("Connect via Web Extension (Experimental)")
|
.label("Connect via Web Extension (Experimental)")
|
||||||
.ghost()
|
.ghost()
|
||||||
.loading(self.loading)
|
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.proxy(cx);
|
this.proxy(cx);
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+27
-28
@@ -21,7 +21,7 @@ use ui::notification::{Notification, NotificationKind};
|
|||||||
use ui::{Root, WindowExtension, v_flex};
|
use ui::{Root, WindowExtension, v_flex};
|
||||||
|
|
||||||
use crate::dialogs::restore::RestoreEncryption;
|
use crate::dialogs::restore::RestoreEncryption;
|
||||||
use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings};
|
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
|
||||||
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ impl Workspace {
|
|||||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||||
let left_sidebar = sidebar.clone();
|
let left_sidebar = sidebar.clone();
|
||||||
|
|
||||||
|
let workspace = cx.entity().downgrade();
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
@@ -87,10 +88,10 @@ impl Workspace {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to the nostr events
|
// Subscribe to the nostr events
|
||||||
cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| {
|
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
|
||||||
if let StateEvent::SignerChanged = event {
|
StateEvent::SignerChanged => window.close_all_dialogs(cx),
|
||||||
window.close_all_dialogs(cx);
|
StateEvent::PassphraseRequired => import::open_unlock(window, cx),
|
||||||
}
|
_ => {}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -183,6 +184,27 @@ impl Workspace {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
App::on_action(cx, {
|
||||||
|
let workspace = workspace.clone();
|
||||||
|
move |action, cx| {
|
||||||
|
let Some(workspace) = workspace.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
workspace.update(cx, |this, cx| {
|
||||||
|
this.show_group_meta(action, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
App::on_action(cx, move |action, cx| {
|
||||||
|
let Some(workspace) = workspace.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
workspace.update(cx, |this, cx| {
|
||||||
|
this.hide_group_meta(action, cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
cx.defer_in(window, move |this, window, cx| {
|
cx.defer_in(window, move |this, window, cx| {
|
||||||
let sidebar = PanelHandle::new(left_sidebar);
|
let sidebar = PanelHandle::new(left_sidebar);
|
||||||
let greeter = PanelHandle::new(greeter::init(window, cx));
|
let greeter = PanelHandle::new(greeter::init(window, cx));
|
||||||
@@ -198,29 +220,6 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen app-wide, so a group panel can show or hide the sidebar without holding it.
|
|
||||||
let workspace = cx.entity().downgrade();
|
|
||||||
|
|
||||||
App::on_action(cx, {
|
|
||||||
let workspace = workspace.clone();
|
|
||||||
|
|
||||||
move |action: &nip29_ui::ShowGroupMeta, cx| {
|
|
||||||
let Some(workspace) = workspace.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
workspace.update(cx, |this, cx| this.show_group_meta(action, cx));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
App::on_action(cx, move |action: &nip29_ui::HideGroupMeta, cx| {
|
|
||||||
let Some(workspace) = workspace.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
workspace.update(cx, |this, cx| this.hide_group_meta(action, cx));
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
sidebar,
|
sidebar,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user