feat: revamp the chat panel ui (#7)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m40s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-02-19 07:25:07 +00:00
parent e327178161
commit f6ce53ef9c
53 changed files with 4613 additions and 3738 deletions

View File

@@ -39,6 +39,8 @@ pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.damus.io",

View File

@@ -1,62 +0,0 @@
use gpui::SharedString;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum DeviceState {
#[default]
Initial,
Requesting,
Set,
}
/// Announcement
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Announcement {
/// The public key of the device that created this announcement.
public_key: PublicKey,
/// The name of the device that created this announcement.
client_name: Option<String>,
}
impl From<&Event> for Announcement {
fn from(val: &Event) -> Self {
let public_key = val
.tags
.iter()
.find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "P")
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.unwrap_or(val.pubkey);
let client_name = val
.tags
.find(TagKind::Client)
.and_then(|tag| tag.content())
.map(|c| c.to_string());
Self::new(public_key, client_name)
}
}
impl Announcement {
pub fn new(public_key: PublicKey, client_name: Option<String>) -> Self {
Self {
public_key,
client_name,
}
}
/// Returns the public key of the device that created this announcement.
pub fn public_key(&self) -> PublicKey {
self.public_key
}
/// Returns the client name of the device that created this announcement.
pub fn client_name(&self) -> SharedString {
self.client_name
.as_ref()
.map(SharedString::from)
.unwrap_or(SharedString::from("Unknown"))
}
}

View File

@@ -1,46 +0,0 @@
use std::collections::HashSet;
use std::sync::{Arc, OnceLock};
use nostr_sdk::prelude::*;
use smol::lock::RwLock;
static TRACKER: OnceLock<Arc<RwLock<EventTracker>>> = OnceLock::new();
pub fn tracker() -> &'static Arc<RwLock<EventTracker>> {
TRACKER.get_or_init(|| Arc::new(RwLock::new(EventTracker::default())))
}
/// Event tracker
#[derive(Debug, Clone, Default)]
pub struct EventTracker {
/// Tracking events sent by Coop in the current session
sent_ids: HashSet<EventId>,
/// Events that need to be resent later
pending_resend: HashSet<(EventId, RelayUrl)>,
}
impl EventTracker {
/// Check if an event was sent by Coop in the current session.
pub fn is_sent_by_coop(&self, id: &EventId) -> bool {
self.sent_ids.contains(id)
}
/// Mark an event as sent by Coop.
pub fn sent(&mut self, id: EventId) {
self.sent_ids.insert(id);
}
/// Get all events that need to be resent later for a specific relay.
pub fn pending_resend(&mut self, relay: &RelayUrl) -> Vec<EventId> {
self.pending_resend
.extract_if(|(_id, url)| url == relay)
.map(|(id, _url)| id)
.collect()
}
/// Add an event (id and relay url) to the pending resend set.
pub fn add_to_pending(&mut self, id: EventId, url: RelayUrl) {
self.pending_resend.insert((id, url));
}
}

View File

@@ -5,23 +5,21 @@ use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
use nostr_lmdb::NostrLmdb;
use nostr_sdk::prelude::*;
mod constants;
mod event;
mod nip05;
mod signer;
pub use constants::*;
pub use event::*;
pub use nip05::*;
pub use signer::*;
pub fn init(cx: &mut App) {
pub fn init(window: &mut Window, cx: &mut App) {
// rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already
// been installed. We can ignore this `Result`.
@@ -32,10 +30,7 @@ pub fn init(cx: &mut App) {
// Initialize the tokio runtime
gpui_tokio::init(cx);
// Initialize the event tracker
let _tracker = tracker();
NostrRegistry::set_global(cx.new(NostrRegistry::new), cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
}
struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -87,7 +82,7 @@ impl NostrRegistry {
}
/// Create a new nostr instance
fn new(cx: &mut Context<Self>) -> Self {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
// Construct the nostr lmdb instance
let lmdb = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
@@ -96,13 +91,9 @@ impl NostrRegistry {
});
// Construct the nostr signer
let app_keys = Self::create_or_init_app_keys().unwrap_or(Keys::generate());
let app_keys = get_or_init_app_keys().unwrap_or(Keys::generate());
let signer = Arc::new(CoopSigner::new(app_keys.clone()));
// Construct the relay states entity
let nip65 = cx.new(|_| RelayState::default());
let nip17 = cx.new(|_| RelayState::default());
// Construct the nostr client
let client = ClientBuilder::default()
.signer(signer.clone())
@@ -122,25 +113,33 @@ impl NostrRegistry {
})
.build();
// Construct the relay states entity
let nip65 = cx.new(|_| RelayState::default());
let nip17 = cx.new(|_| RelayState::default());
let mut subscriptions = vec![];
subscriptions.push(
// Observe the NIP-65 state
cx.observe(&nip65, |this, state, cx| {
if state.read(cx).configured() {
if state.read(cx).configured().is_some() {
this.get_profile(cx);
this.get_messaging_relays(cx);
}
}),
);
cx.defer(|cx| {
let nostr = NostrRegistry::global(cx);
subscriptions.push(
// Observe the NIP-17 state
cx.observe(&nip17, |this, nip17, cx| {
if let Some(event) = nip17.read(cx).configured().cloned() {
this.subscribe_to_giftwrap_events(&event, cx);
};
}),
);
// Connect to the bootstrapping relays
nostr.update(cx, |this, cx| {
this.connect(cx);
});
cx.defer_in(window, |this, _window, cx| {
this.connect(cx);
});
Self {
@@ -160,36 +159,35 @@ impl NostrRegistry {
fn connect(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Add search relay to the relay pool
for url in SEARCH_RELAYS.into_iter() {
client.add_relay(url).and_connect().await?;
}
// Add bootstrap relay to the relay pool
for url in BOOTSTRAP_RELAYS.into_iter() {
client.add_relay(url).and_connect().await?;
}
Ok(())
});
self.tasks.push(cx.spawn(async move |this, cx| {
// Wait for the task to complete
task.await?;
// Update the state
this.update(cx, |this, cx| {
this.set_connected(cx);
})?;
// Small delay
cx.background_executor()
.timer(Duration::from_millis(200))
.await_on_background(async move {
// Add search relay to the relay pool
for url in INDEXER_RELAYS.into_iter() {
client
.add_relay(url)
.capabilities(RelayCapabilities::DISCOVERY)
.await
.ok();
}
// Add search relay to the relay pool
for url in SEARCH_RELAYS.into_iter() {
client.add_relay(url).await.ok();
}
// Add bootstrap relay to the relay pool
for url in BOOTSTRAP_RELAYS.into_iter() {
client.add_relay(url).await.ok();
}
client.connect().await;
})
.await;
// Update the state
this.update(cx, |this, cx| {
this.set_connected(cx);
this.get_signer(cx);
})?;
@@ -244,30 +242,6 @@ impl NostrRegistry {
cx.notify();
}
/// Get a relay hint (messaging relay) for a given public key
///
/// Used for building chat messages
pub fn relay_hint(&self, public_key: &PublicKey, cx: &App) -> Task<Option<RelayUrl>> {
let client = self.client();
let public_key = public_key.to_owned();
cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::InboxRelays)
.limit(1);
if let Ok(events) = client.database().query(filter).await {
if let Some(event) = events.first_owned() {
let relays: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
return relays.first().cloned();
}
}
None
})
}
/// Get a list of messaging relays with current signer's public key
pub fn messaging_relays(&self, cx: &App) -> Task<Vec<RelayUrl>> {
let client = self.client();
@@ -292,12 +266,11 @@ impl NostrRegistry {
.await
.ok()
.and_then(|events| events.first_owned())
.map(|event| nip17::extract_owned_relay_list(event).collect())
.map(|event| nip17::extract_owned_relay_list(event).take(3).collect())
.unwrap_or_default();
for relay in relays.iter() {
client.add_relay(relay).await.ok();
client.connect_relay(relay).await.ok();
client.add_relay(relay).and_connect().await.ok();
}
relays
@@ -305,15 +278,36 @@ impl NostrRegistry {
}
/// Reset all relay states
pub fn reset_relay_states(&mut self, cx: &mut Context<Self>) {
pub fn reset_relays(&mut self, cx: &mut Context<Self>) {
let client = self.client();
self.nip65.update(cx, |this, cx| {
*this = RelayState::default();
cx.notify();
});
self.nip17.update(cx, |this, cx| {
*this = RelayState::default();
cx.notify();
});
self.tasks.push(cx.background_spawn(async move {
let relays = client.relays().await;
for (relay_url, relay) in relays.iter() {
let url = relay_url.as_str();
let default_relay = BOOTSTRAP_RELAYS.contains(&url)
|| SEARCH_RELAYS.contains(&url)
|| INDEXER_RELAYS.contains(&url);
if !default_relay {
relay.unsubscribe_all().await?;
relay.disconnect();
}
}
Ok(())
}));
}
/// Set the signer for the nostr client and verify the public key
@@ -329,6 +323,9 @@ impl NostrRegistry {
// Update signer
signer.switch(new, owned).await;
// Unsubscribe from all subscriptions
client.unsubscribe_all().await?;
// Verify signer
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
@@ -343,89 +340,14 @@ impl NostrRegistry {
// Update states
this.update(cx, |this, cx| {
this.reset_relay_states(cx);
this.reset_relays(cx);
this.get_relay_list(cx);
})?;
Ok(())
}));
}
/*
async fn get_adv_events_by(client: &Client, event: &Event) -> Result<(), Error> {
// Subscription options
let opts = SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(TIMEOUT)))
.exit_policy(ReqExitPolicy::ExitOnEOSE);
// Extract write relays from event
let write_relays: Vec<&RelayUrl> = nip65::extract_relay_list(event)
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url)
} else {
None
}
})
.collect();
// Ensure relay connections
for relay in write_relays.iter() {
client.add_relay(*relay).await?;
client.connect_relay(*relay).await?;
}
// Construct filter for inbox relays
let inbox = Filter::new()
.kind(Kind::InboxRelays)
.author(event.pubkey)
.limit(1);
// Construct filter for encryption announcement
let announcement = Filter::new()
.kind(Kind::Custom(10044))
.author(event.pubkey)
.limit(1);
// Construct target for subscription
let target = write_relays
.into_iter()
.map(|relay| (relay, vec![inbox.clone(), announcement.clone()]))
.collect::<HashMap<_, _>>();
client.subscribe(target).close_on(opts).await?;
Ok(())
}
*/
/// Get or create a new app keys
fn create_or_init_app_keys() -> Result<Keys, Error> {
let dir = config_dir().join(".app_keys");
let content = match std::fs::read(&dir) {
Ok(content) => content,
Err(_) => {
// Generate new keys if file doesn't exist
let keys = Keys::generate();
let secret_key = keys.secret_key();
// Create directory and write secret key
std::fs::create_dir_all(dir.parent().unwrap())?;
std::fs::write(&dir, secret_key.to_secret_bytes())?;
// Set permissions to readonly
let mut perms = std::fs::metadata(&dir)?.permissions();
perms.set_mode(0o400);
std::fs::set_permissions(&dir, perms)?;
return Ok(keys);
}
};
let secret_key = SecretKey::from_slice(&content)?;
let keys = Keys::new(secret_key);
Ok(keys)
}
// Get relay list for current user
fn get_relay_list(&mut self, cx: &mut Context<Self>) {
let client = self.client();
@@ -454,14 +376,13 @@ impl NostrRegistry {
let mut stream = client
.stream_events(target)
.policy(ReqExitPolicy::WaitForEvents(1))
.timeout(Duration::from_secs(TIMEOUT))
.await?;
while let Some((_url, res)) = stream.next().await {
match res {
Ok(event) => {
log::info!("Received relay list event: {event:?}");
// Construct a filter to continuously receive relay list events
let filter = Filter::new()
.kind(Kind::RelayList)
@@ -477,7 +398,7 @@ impl NostrRegistry {
// Subscribe to the relay list events
client.subscribe(target).await?;
return Ok(RelayState::Configured);
return Ok(RelayState::Configured(Box::new(event)));
}
Err(e) => {
log::error!("Failed to receive relay list event: {e}");
@@ -531,14 +452,13 @@ impl NostrRegistry {
// Stream events from the write relays
let mut stream = client
.stream_events(filter)
.policy(ReqExitPolicy::WaitForEvents(1))
.timeout(Duration::from_secs(TIMEOUT))
.await?;
while let Some((_url, res)) = stream.next().await {
match res {
Ok(event) => {
log::info!("Received messaging relays event: {event:?}");
// Construct a filter to continuously receive relay list events
let filter = Filter::new()
.kind(Kind::InboxRelays)
@@ -548,7 +468,7 @@ impl NostrRegistry {
// Subscribe to the relay list events
client.subscribe(filter).await?;
return Ok(RelayState::Configured);
return Ok(RelayState::Configured(Box::new(event)));
}
Err(e) => {
log::error!("Failed to get messaging relays: {e}");
@@ -578,6 +498,41 @@ impl NostrRegistry {
}));
}
/// Continuously get gift wrap events for the current user in their messaging relays
fn subscribe_to_giftwrap_events(&mut self, relay_list: &Event, cx: &mut Context<Self>) {
let client = self.client();
let signer = self.signer();
let relay_urls: Vec<RelayUrl> = nip17::extract_relay_list(relay_list).cloned().collect();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key().await?;
for url in relay_urls.iter() {
client.add_relay(url).and_connect().await?;
}
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(USER_GIFTWRAP);
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> = relay_urls
.iter()
.map(|relay| (relay, filter.clone()))
.collect();
let output = client.subscribe(target).with_id(id).await?;
log::info!(
"Successfully subscribed to user gift-wrap messages on: {:?}",
output.success
);
Ok(())
});
task.detach();
}
/// Get profile and contact list for current user
fn get_profile(&mut self, cx: &mut Context<Self>) {
let client = self.client();
@@ -648,6 +603,7 @@ impl NostrRegistry {
fn set_default_signer(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let keys = Keys::generate();
let async_keys = keys.clone();
// Create a write credential task
let write_credential = cx.write_credentials(
@@ -656,21 +612,18 @@ impl NostrRegistry {
&keys.secret_key().to_secret_bytes(),
);
// Update the signer
self.set_signer(keys, false, cx);
// Set the creating signer status
self.set_creating_signer(true, cx);
// Run async tasks in background
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer = async_keys.into_nostr_signer();
// Get default relay list
let relay_list = default_relay_list();
// Publish relay list event
let event = EventBuilder::relay_list(relay_list).sign(signer).await?;
let event = EventBuilder::relay_list(relay_list).sign(&signer).await?;
client
.send_event(&event)
.broadcast()
@@ -683,33 +636,36 @@ impl NostrRegistry {
let metadata = Metadata::new().display_name(&name).picture(avatar);
// Publish metadata event
let event = EventBuilder::metadata(&metadata).sign(signer).await?;
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
client
.send_event(&event)
.broadcast()
.ok_timeout(Duration::from_secs(TIMEOUT))
.ack_policy(AckPolicy::none())
.await?;
// Construct the default contact list
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
// Publish contact list event
let event = EventBuilder::contact_list(contacts).sign(signer).await?;
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
client
.send_event(&event)
.broadcast()
.ok_timeout(Duration::from_secs(TIMEOUT))
.ack_policy(AckPolicy::none())
.await?;
// Construct the default messaging relay list
let relays = default_messaging_relays();
// Publish messaging relay list event
let event = EventBuilder::nip17_relay_list(relays).sign(signer).await?;
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
client
.send_event(&event)
.to_nip65()
.ok_timeout(Duration::from_secs(TIMEOUT))
.ack_policy(AckPolicy::none())
.await?;
// Write user's credentials to the system keyring
@@ -724,7 +680,7 @@ impl NostrRegistry {
this.update(cx, |this, cx| {
this.set_creating_signer(false, cx);
this.get_relay_list(cx);
this.set_signer(keys, false, cx);
})?;
Ok(())
@@ -743,7 +699,6 @@ impl NostrRegistry {
this.update(cx, |this, cx| {
this.set_signer(keys, false, cx);
this.get_relay_list(cx);
})?;
}
_ => {
@@ -786,7 +741,6 @@ impl NostrRegistry {
Ok(signer) => {
this.update(cx, |this, cx| {
this.set_signer(signer, true, cx);
this.get_relay_list(cx);
})
.ok();
}
@@ -882,9 +836,30 @@ impl NostrRegistry {
let client = self.client();
let query = query.to_string();
// Get the address task if the query is a valid NIP-05 address
let address_task = if let Ok(addr) = Nip05Address::parse(&query) {
Some(self.get_address(addr, cx))
} else {
None
};
cx.background_spawn(async move {
let mut results: Vec<PublicKey> = Vec::with_capacity(FIND_LIMIT);
// Return early if the query is a valid NIP-05 address
if let Some(task) = address_task {
if let Ok(public_key) = task.await {
results.push(public_key);
return Ok(results);
}
}
// Return early if the query is a valid public key
if let Ok(public_key) = PublicKey::parse(&query) {
results.push(public_key);
return Ok(results);
}
// Construct the filter for the search query
let filter = Filter::new()
.search(query.to_lowercase())
@@ -980,6 +955,36 @@ impl NostrRegistry {
}
}
/// Get or create a new app keys
fn get_or_init_app_keys() -> Result<Keys, Error> {
let dir = config_dir().join(".app_keys");
let content = match std::fs::read(&dir) {
Ok(content) => content,
Err(_) => {
// Generate new keys if file doesn't exist
let keys = Keys::generate();
let secret_key = keys.secret_key();
// Create directory and write secret key
std::fs::create_dir_all(dir.parent().unwrap())?;
std::fs::write(&dir, secret_key.to_secret_bytes())?;
// Set permissions to readonly
let mut perms = std::fs::metadata(&dir)?.permissions();
perms.set_mode(0o400);
std::fs::set_permissions(&dir, perms)?;
return Ok(keys);
}
};
let secret_key = SecretKey::from_slice(&content)?;
let keys = Keys::new(secret_key);
Ok(keys)
}
fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
vec![
(
@@ -991,7 +996,7 @@ fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
Some(RelayMetadata::Write),
),
(
RelayUrl::parse("wss://relay.primal.net/").unwrap(),
RelayUrl::parse("wss://relay.damus.io/").unwrap(),
Some(RelayMetadata::Read),
),
(
@@ -1003,18 +1008,18 @@ fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
fn default_messaging_relays() -> Vec<RelayUrl> {
vec![
RelayUrl::parse("wss://auth.nostr1.com/").unwrap(),
//RelayUrl::parse("wss://auth.nostr1.com/").unwrap(),
RelayUrl::parse("wss://nip17.com/").unwrap(),
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RelayState {
#[default]
Idle,
Checking,
NotConfigured,
Configured,
Configured(Box<Event>),
}
impl RelayState {
@@ -1030,8 +1035,11 @@ impl RelayState {
matches!(self, RelayState::NotConfigured)
}
pub fn configured(&self) -> bool {
matches!(self, RelayState::Configured)
pub fn configured(&self) -> Option<&Event> {
match self {
RelayState::Configured(event) => Some(event),
_ => None,
}
}
}

View File

@@ -8,11 +8,15 @@ use smol::lock::RwLock;
#[derive(Debug)]
pub struct CoopSigner {
/// User's signer
signer: RwLock<Arc<dyn NostrSigner>>,
/// Signer's public key
/// User's signer public key
signer_pkey: RwLock<Option<PublicKey>>,
/// Specific signer for encryption purposes
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
/// Whether coop is creating a new identity
creating: AtomicBool,
@@ -30,6 +34,7 @@ impl CoopSigner {
Self {
signer: RwLock::new(signer.into_nostr_signer()),
signer_pkey: RwLock::new(None),
encryption_signer: RwLock::new(None),
creating: AtomicBool::new(false),
owned: AtomicBool::new(false),
}
@@ -40,6 +45,11 @@ impl CoopSigner {
self.signer.read().await.clone()
}
/// Get the encryption signer.
pub async fn get_encryption_signer(&self) -> Option<Arc<dyn NostrSigner>> {
self.encryption_signer.read().await.clone()
}
/// Get public key
pub fn public_key(&self) -> Option<PublicKey> {
self.signer_pkey.read_blocking().to_owned()
@@ -64,6 +74,7 @@ impl CoopSigner {
let public_key = new_signer.get_public_key().await.ok();
let mut signer = self.signer.write().await;
let mut signer_pkey = self.signer_pkey.write().await;
let mut encryption_signer = self.encryption_signer.write().await;
// Switch to the new signer
*signer = new_signer;
@@ -71,9 +82,21 @@ impl CoopSigner {
// Update the public key
*signer_pkey = public_key;
// Reset the encryption signer
*encryption_signer = None;
// Update the owned flag
self.owned.store(owned, Ordering::SeqCst);
}
/// Set the encryption signer.
pub async fn set_encryption_signer<T>(&self, new: T)
where
T: IntoNostrSigner,
{
let mut encryption_signer = self.encryption_signer.write().await;
*encryption_signer = Some(new.into_nostr_signer());
}
}
impl NostrSigner for CoopSigner {