feat: refactor to use gpui event instead of local state (#18)

Reviewed-on: #18
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
This commit was merged in pull request #18.
This commit is contained in:
2026-03-10 08:19:02 +00:00
committed by reya
parent fe4eb7df74
commit 40d726c986
64 changed files with 8341 additions and 1434 deletions

View File

@@ -10,6 +10,8 @@ common = { path = "../common" }
nostr.workspace = true
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-memory.workspace = true
nostr-gossip-sqlite.workspace = true
nostr-connect.workspace = true
nostr-blossom.workspace = true

View File

@@ -40,7 +40,8 @@ pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://indexer.coracle.social",
"wss://user.kindpag.es",

View File

@@ -1,83 +0,0 @@
use std::collections::{HashMap, HashSet};
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// Gossip
#[derive(Debug, Clone, Default)]
pub struct Gossip {
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
}
impl Gossip {
pub fn read_only_relays(&self, public_key: &PublicKey) -> Vec<SharedString> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.map(|(url, _)| url.to_string().into())
.collect()
})
.unwrap_or_default()
}
/// Get read relays for a given public key
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
Some(url.to_owned())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Get write relays for a given public key
pub fn write_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url.to_owned())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Insert gossip relays for a public key
pub fn insert_relays(&mut self, event: &Event) {
self.relays.entry(event.pubkey).or_default().extend(
event
.tags
.iter()
.filter_map(|tag| {
if let Some(TagStandard::RelayMetadata {
relay_url,
metadata,
}) = tag.clone().to_standardized()
{
Some((relay_url, metadata))
} else {
None
}
})
.take(3),
);
}
}

View File

@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
@@ -6,20 +6,20 @@ use anyhow::{Context as AnyhowContext, Error, anyhow};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window};
use nostr_connect::prelude::*;
use nostr_gossip_sqlite::prelude::*;
use nostr_lmdb::prelude::*;
use nostr_memory::prelude::*;
use nostr_sdk::prelude::*;
mod blossom;
mod constants;
mod device;
mod gossip;
mod nip05;
mod signer;
pub use blossom::*;
pub use constants::*;
pub use device::*;
pub use gossip::*;
pub use nip05::*;
pub use signer::*;
@@ -41,6 +41,23 @@ struct GlobalNostrRegistry(Entity<NostrRegistry>);
impl Global for GlobalNostrRegistry {}
/// Signer event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum StateEvent {
/// Connecting to the bootstrapping relay
Connecting,
/// Connected to the bootstrapping relay
Connected,
/// User has not set up NIP-65 relays
RelayNotConfigured,
/// Connected to NIP-65 relays
RelayConnected,
/// A new signer has been set
SignerSet,
/// An error occurred
Error(SharedString),
}
/// Nostr Registry
#[derive(Debug)]
pub struct NostrRegistry {
@@ -53,21 +70,17 @@ pub struct NostrRegistry {
/// Local public keys
npubs: Entity<Vec<PublicKey>>,
/// Custom gossip implementation
gossip: Entity<Gossip>,
/// App keys
///
/// Used for Nostr Connect and NIP-4e operations
pub app_keys: Keys,
/// Relay list state
pub relay_list_state: RelayState,
app_keys: Keys,
/// Tasks for asynchronous operations
tasks: Vec<Task<Result<(), Error>>>,
tasks: Vec<Task<()>>,
}
impl EventEmitter<StateEvent> for NostrRegistry {}
impl NostrRegistry {
/// Retrieve the global nostr state
pub fn global(cx: &App) -> Entity<Self> {
@@ -88,32 +101,43 @@ impl NostrRegistry {
// Construct the nostr npubs entity
let npubs = cx.new(|_| vec![]);
// Construct the gossip entity
let gossip = cx.new(|_| Gossip::default());
// Construct the nostr lmdb instance
let lmdb = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
// Construct the nostr gossip instance
let gossip = cx.foreground_executor().block_on(async move {
NostrGossipSqlite::open(config_dir().join("gossip"))
.await
.expect("Failed to initialize database")
.expect("Failed to initialize gossip instance")
});
// Construct the nostr client
let client = ClientBuilder::default()
// Construct the nostr client builder
let mut builder = ClientBuilder::default()
.signer(signer.clone())
.database(lmdb)
.gossip(gossip)
.automatic_authentication(false)
.verify_subscriptions(false)
.connect_timeout(Duration::from_secs(TIMEOUT))
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(600),
})
.build();
});
// Add database if not in debug mode
if !cfg!(debug_assertions) {
// Construct the nostr lmdb instance
let lmdb = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
.await
.expect("Failed to initialize database")
});
builder = builder.database(lmdb);
} else {
builder = builder.database(MemoryDatabase::unbounded())
}
// Build the nostr client
let client = builder.build();
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.connect(cx);
this.handle_notifications(cx);
});
Self {
@@ -121,8 +145,6 @@ impl NostrRegistry {
signer,
npubs,
app_keys,
gossip,
relay_list_state: RelayState::Idle,
tasks: vec![],
}
}
@@ -142,94 +164,57 @@ impl NostrRegistry {
self.npubs.clone()
}
/// Get the app keys
pub fn keys(&self) -> Keys {
self.app_keys.clone()
}
/// Connect to the bootstrapping relays
fn connect(&mut self, cx: &mut Context<Self>) {
let client = self.client();
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor()
.await_on_background(async move {
// 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();
}
// Connect to all added relays
client.connect().and_wait(Duration::from_secs(2)).await;
})
.await;
// Update the state
this.update(cx, |this, cx| {
this.get_npubs(cx);
})?;
Ok(())
}));
}
/// Handle nostr notifications
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let gossip = self.gossip.downgrade();
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Event>(2048);
self.tasks.push(cx.background_spawn(async move {
// Handle nostr notifications
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Some(notification) = notifications.next().await {
if let ClientNotification::Message {
message:
RelayMessage::Event {
event,
subscription_id,
},
..
} = notification
{
if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue;
}
if let Kind::RelayList = event.kind {
if subscription_id.as_str().contains("room-") {
get_events_for_room(&client, &event).await.ok();
}
tx.send_async(event.into_owned()).await?;
}
}
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).await?;
}
Ok(())
}));
self.tasks.push(cx.spawn(async move |_this, cx| {
while let Ok(event) = rx.recv_async().await {
if let Kind::RelayList = event.kind {
gossip.update(cx, |this, cx| {
this.insert_relays(&event);
cx.notify();
})?;
}
// Add bootstrap relay to the relay pool
for url in BOOTSTRAP_RELAYS.into_iter() {
client.add_relay(url).await?;
}
// Connect to all added relays
client.connect().await;
Ok(())
}));
});
// Emit connecting event
cx.emit(StateEvent::Connecting);
self.tasks
.push(cx.spawn(async move |this, cx| match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
cx.emit(StateEvent::Connected);
this.get_npubs(cx);
})
.ok();
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
})
.ok();
}
}));
}
/// Get all used npubs
fn get_npubs(&mut self, cx: &mut Context<Self>) {
let npubs = self.npubs.downgrade();
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let dir = config_dir().join("keys");
// Ensure keys directory exists
@@ -269,25 +254,26 @@ impl NostrRegistry {
true => {
this.update(cx, |this, cx| {
this.create_identity(cx);
})?;
})
.ok();
}
false => {
// TODO: auto login
npubs.update(cx, |this, cx| {
this.extend(public_keys);
cx.notify();
})?;
npubs
.update(cx, |this, cx| {
this.extend(public_keys);
cx.notify();
})
.ok();
}
},
Err(e) => {
log::error!("Failed to get npubs: {e}");
this.update(cx, |this, cx| {
this.create_identity(cx);
})?;
this.update(cx, |_this, cx| {
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
})
.ok();
}
}
Ok(())
}));
}
@@ -307,74 +293,49 @@ impl NostrRegistry {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = async_keys.into_nostr_signer();
// Get default relay list
// Construct relay list event
let relay_list = default_relay_list();
// Extract write relays
let write_urls: Vec<RelayUrl> = relay_list
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url)
} else {
None
}
})
.cloned()
.collect();
// Ensure connected to all relays
for (url, _metadata) in relay_list.iter() {
client.add_relay(url).and_connect().await?;
}
// Publish relay list event
let event = EventBuilder::relay_list(relay_list).sign(&signer).await?;
let output = client
// Publish relay list
client
.send_event(&event)
.to(BOOTSTRAP_RELAYS)
.ok_timeout(Duration::from_secs(TIMEOUT))
.await?;
log::info!("Sent gossip relay list: {output:?}");
// Construct the default metadata
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap();
let metadata = Metadata::new().display_name(&name).picture(avatar);
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
// Publish metadata event
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
client
.send_event(&event)
.to(&write_urls)
.to_nip65()
.ack_policy(AckPolicy::none())
.await?;
// Construct the default contact list
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
// Publish contact list event
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
client
.send_event(&event)
.to(&write_urls)
.to_nip65()
.ack_policy(AckPolicy::none())
.await?;
// Construct the default messaging relay list
let relays = default_messaging_relays();
// Ensure connected to all relays
for url in relays.iter() {
client.add_relay(url).and_connect().await?;
}
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
// Publish messaging relay list event
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
client
.send_event(&event)
.to(&write_urls)
.to_nip65()
.ack_policy(AckPolicy::none())
.await?;
@@ -385,15 +346,20 @@ impl NostrRegistry {
});
self.tasks.push(cx.spawn(async move |this, cx| {
// Wait for the task to complete
task.await?;
// Set signer
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
Ok(())
match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})
.ok();
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
})
.ok();
}
};
}));
}
@@ -472,6 +438,7 @@ impl NostrRegistry {
Ok(public_key) => {
// Update states
this.update(cx, |this, cx| {
this.ensure_relay_list(&public_key, cx);
// Add public key to npubs if not already present
this.npubs.update(cx, |this, cx| {
if !this.contains(&public_key) {
@@ -479,22 +446,18 @@ impl NostrRegistry {
cx.notify();
}
});
// Ensure relay list for the user
this.ensure_relay_list(cx);
// Emit signer changed event
cx.emit(SignerEvent::Set);
})?;
cx.emit(StateEvent::SignerSet);
})
.ok();
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
})
.ok();
}
}
Ok(())
};
}));
}
@@ -506,16 +469,15 @@ impl NostrRegistry {
self.tasks.push(cx.spawn(async move |this, cx| {
let key_path = keys_dir.join(format!("{}.npub", npub));
smol::fs::remove_file(key_path).await?;
smol::fs::remove_file(key_path).await.ok();
this.update(cx, |this, cx| {
this.npubs().update(cx, |this, cx| {
this.retain(|k| k != &public_key);
cx.notify();
});
})?;
Ok(())
})
.ok();
}));
}
@@ -533,16 +495,16 @@ impl NostrRegistry {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
})
.ok();
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
})
.ok();
}
}
Ok(())
};
}));
}
@@ -564,192 +526,90 @@ impl NostrRegistry {
match task.await {
Ok((public_key, uri)) => {
let username = public_key.to_bech32().unwrap();
let write_credential = this.read_with(cx, |_this, cx| {
cx.write_credentials(&username, "nostrconnect", uri.to_string().as_bytes())
})?;
let write_credential = this
.read_with(cx, |_this, cx| {
cx.write_credentials(
&username,
"nostrconnect",
uri.to_string().as_bytes(),
)
})
.unwrap();
match write_credential.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_signer(nip46, cx);
})?;
})
.ok();
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
})
.ok();
}
}
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
})
.ok();
}
}
Ok(())
};
}));
}
/// Set the state of the relay list
fn set_relay_state(&mut self, state: RelayState, cx: &mut Context<Self>) {
self.relay_list_state = state;
cx.notify();
}
pub fn ensure_relay_list(&mut self, cx: &mut Context<Self>) {
let task = self.verify_relay_list(cx);
// Set the state to idle before starting the task
self.set_relay_state(RelayState::default(), cx);
pub fn ensure_relay_list(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
let task = self.get_event(public_key, Kind::RelayList, cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await?;
// Update state
this.update(cx, |this, cx| {
this.relay_list_state = result;
cx.notify();
})?;
Ok(())
match task.await {
Ok(_) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::RelayConnected);
})
.ok();
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::RelayNotConfigured);
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
})
.ok();
}
};
}));
}
// Verify relay list for current user
fn verify_relay_list(&mut self, cx: &mut Context<Self>) -> Task<Result<RelayState, Error>> {
/// Get an event with the given author and kind.
pub fn get_event(
&self,
author: &PublicKey,
kind: Kind,
cx: &App,
) -> Task<Result<Event, Error>> {
let client = self.client();
let public_key = *author;
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.limit(1);
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
// Stream events from the bootstrap relays
let filter = Filter::new().kind(kind).author(public_key).limit(1);
let mut stream = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.stream_events(filter)
.timeout(Duration::from_millis(800))
.await?;
while let Some((_url, res)) = stream.next().await {
match res {
Ok(event) => {
log::info!("Received relay list event: {event:?}");
return Ok(RelayState::Configured);
}
Err(e) => {
log::error!("Failed to receive relay list event: {e}");
}
if let Ok(event) = res {
return Ok(event);
}
}
Ok(RelayState::NotConfigured)
Err(anyhow!("No event found"))
})
}
/// Ensure write relays for a given public key
pub fn ensure_write_relays(&self, public_key: &PublicKey, cx: &App) -> Task<Vec<RelayUrl>> {
let client = self.client();
let public_key = *public_key;
cx.background_spawn(async move {
let mut relays = vec![];
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.limit(1);
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
if let Ok(mut stream) = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await
{
while let Some((_url, res)) = stream.next().await {
match res {
Ok(event) => {
// Extract relay urls
relays.extend(nip65::extract_owned_relay_list(event).filter_map(
|(url, metadata)| {
if metadata.is_none() || metadata == Some(RelayMetadata::Write)
{
Some(url)
} else {
None
}
},
));
// Ensure connections
for url in relays.iter() {
client.add_relay(url).and_connect().await.ok();
}
return relays;
}
Err(e) => {
log::error!("Failed to receive relay list event: {e}");
}
}
}
}
relays
})
}
/// Get a list of write relays for a given public key
pub fn write_relays(&self, public_key: &PublicKey, cx: &App) -> Task<Vec<RelayUrl>> {
let client = self.client();
let relays = self.gossip.read(cx).write_relays(public_key);
cx.background_spawn(async move {
// Ensure relay connections
for url in relays.iter() {
client.add_relay(url).and_connect().await.ok();
}
relays
})
}
/// Get a list of read relays for a given public key
pub fn read_relays(&self, public_key: &PublicKey, cx: &App) -> Task<Vec<RelayUrl>> {
let client = self.client();
let relays = self.gossip.read(cx).read_relays(public_key);
cx.background_spawn(async move {
// Ensure relay connections
for url in relays.iter() {
client.add_relay(url).and_connect().await.ok();
}
relays
})
}
/// Get all relays for a given public key without ensuring connections
pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec<SharedString> {
self.gossip.read(cx).read_only_relays(public_key)
}
/// Get the public key of a NIP-05 address
pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
@@ -905,8 +765,6 @@ impl NostrRegistry {
}
}
impl EventEmitter<SignerEvent> for NostrRegistry {}
/// Get or create a new app keys
fn get_or_init_app_keys() -> Result<Keys, Error> {
let dir = config_dir().join(".app_keys");
@@ -932,52 +790,6 @@ fn get_or_init_app_keys() -> Result<Keys, Error> {
Ok(keys)
}
async fn get_events_for_room(client: &Client, nip65: &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(nip65)
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url)
} else {
None
}
})
.collect();
// Ensure relay connections
for url in write_relays.iter() {
client.add_relay(*url).and_connect().await.ok();
}
// Construct filter for inbox relays
let inbox = Filter::new()
.kind(Kind::InboxRelays)
.author(nip65.pubkey)
.limit(1);
// Construct filter for encryption announcement
let announcement = Filter::new()
.kind(Kind::Custom(10044))
.author(nip65.pubkey)
.limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Vec<Filter>> = write_relays
.into_iter()
.map(|relay| (relay, vec![inbox.clone(), announcement.clone()]))
.collect();
// Subscribe to inbox relays and encryption announcements
client.subscribe(target).close_on(opts).await?;
Ok(())
}
fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
vec![
(
@@ -1011,43 +823,6 @@ fn default_messaging_relays() -> Vec<RelayUrl> {
]
}
/// Signer event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerEvent {
/// A new signer has been set
Set,
/// An error occurred
Error(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RelayState {
#[default]
Idle,
Checking,
NotConfigured,
Configured,
}
impl RelayState {
pub fn idle(&self) -> bool {
matches!(self, RelayState::Idle)
}
pub fn checking(&self) -> bool {
matches!(self, RelayState::Checking)
}
pub fn not_configured(&self) -> bool {
matches!(self, RelayState::NotConfigured)
}
pub fn configured(&self) -> bool {
matches!(self, RelayState::Configured)
}
}
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;