chore: connect bootstrap relays on demand (#25)
Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
+3
-1
@@ -15,7 +15,9 @@
|
|||||||
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
|
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
|
||||||
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
|
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
|
||||||
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
|
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
|
||||||
- Connect to fewer relays at startup, keeping only ditto and the git indexer as bootstrap relays
|
- Connect to bootstrap relays on demand instead of at startup
|
||||||
|
- Fetch profile metadata in batches of 100 authors, applying every requested profile in a single database query
|
||||||
|
- Prefetch the 500 most recent profiles at startup instead of 200
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ use crate::repos::RepoListStore;
|
|||||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
/// Timeout for NIP-46 signer responses.
|
/// Timeout for NIP-46 signer responses.
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
/// Relays connected at startup, before any user-specific relay config is known.
|
/// Relays connected at startup, before any user-specific relay config is known.
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
||||||
|
|
||||||
/// Relays used to index the user's NIP-65 relay list.
|
/// Relays used to index the user's NIP-65 relay list.
|
||||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||||
|
|
||||||
@@ -151,9 +149,10 @@ impl Backend {
|
|||||||
pump.detach();
|
pump.detach();
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
weak.update(cx, |this, cx| {
|
||||||
log::warn!("backend dropped before bootstrap could run: {error}");
|
this.restore_session(cx);
|
||||||
}
|
})
|
||||||
|
.ok();
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -167,42 +166,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let client = self.client.clone();
|
|
||||||
|
|
||||||
let task = cx.background_spawn(async move {
|
|
||||||
for url in BOOTSTRAP_RELAYS {
|
|
||||||
client.add_relay(url).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for url in INDEXER_RELAYS {
|
|
||||||
client
|
|
||||||
.add_relay(url)
|
|
||||||
.capabilities(RelayCapabilities::DISCOVERY)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.connect().await;
|
|
||||||
|
|
||||||
Ok::<(), Error>(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(()) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.restore_session(cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok::<(), Error>(())
|
|
||||||
});
|
|
||||||
notify_task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore the saved session from the Keyring.
|
/// Restore the saved session from the Keyring.
|
||||||
///
|
///
|
||||||
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||||
@@ -227,7 +190,9 @@ impl Backend {
|
|||||||
let result = async {
|
let result = async {
|
||||||
if content.starts_with("nsec1") {
|
if content.starts_with("nsec1") {
|
||||||
let keys = Keys::new(SecretKey::parse(&content)?);
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
} else if content.starts_with("bunker://") {
|
} else if content.starts_with("bunker://") {
|
||||||
let (base, keys) = extract_master_key(&content);
|
let (base, keys) = extract_master_key(&content);
|
||||||
let uri = NostrConnectUri::parse(base)?;
|
let uri = NostrConnectUri::parse(base)?;
|
||||||
@@ -238,14 +203,19 @@ impl Backend {
|
|||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||||
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(signer, cx);
|
||||||
|
})?;
|
||||||
} else if content.starts_with("ncryptsec1") {
|
} else if content.starts_with("ncryptsec1") {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.passphrase_required = true;
|
this.passphrase_required = true;
|
||||||
cx.emit(BackendEvent::PassphraseRequired);
|
cx.emit(BackendEvent::PassphraseRequired);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok::<_, Error>(())
|
||||||
@@ -1424,10 +1394,29 @@ async fn connect_repo_relays(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add and connect the startup relays.
|
||||||
|
async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> {
|
||||||
|
for url in BOOTSTRAP_RELAYS {
|
||||||
|
client.add_relay(url).and_connect().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for url in INDEXER_RELAYS {
|
||||||
|
client
|
||||||
|
.add_relay(url)
|
||||||
|
.capabilities(RelayCapabilities::DISCOVERY)
|
||||||
|
.and_connect()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn subscribe_bootstrap_only(
|
pub(crate) async fn subscribe_bootstrap_only(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
filters: Vec<Filter>,
|
filters: Vec<Filter>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
ensure_bootstrap_relays(client).await?;
|
||||||
|
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
.timeout(Some(Duration::from_secs(10)));
|
.timeout(Some(Duration::from_secs(10)));
|
||||||
@@ -1447,6 +1436,8 @@ pub(crate) async fn sync_bootstrap_only(
|
|||||||
filter: Filter,
|
filter: Filter,
|
||||||
opts: SyncOptions,
|
opts: SyncOptions,
|
||||||
) -> Result<SyncSummary, Error> {
|
) -> Result<SyncSummary, Error> {
|
||||||
|
ensure_bootstrap_relays(client).await?;
|
||||||
|
|
||||||
let output = client
|
let output = client
|
||||||
.sync(filter)
|
.sync(filter)
|
||||||
.with(BOOTSTRAP_RELAYS)
|
.with(BOOTSTRAP_RELAYS)
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ use std::time::{Duration, Instant};
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use flume::{Receiver, Sender};
|
use flume::{Receiver, Sender};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity,
|
||||||
WeakEntity,
|
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use utils::shorten_pubkey;
|
use utils::shorten_pubkey;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||||
|
|
||||||
|
/// How long to wait for more requests before firing a batched fetch.
|
||||||
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
/// Max authors per profile request, keeping each filter within relay limits.
|
||||||
|
const REQUEST_CHUNK: usize = 100;
|
||||||
|
/// Recent profiles prefetched at startup and read back from the cache.
|
||||||
|
const WARM_LIMIT: usize = 500;
|
||||||
|
|
||||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
@@ -62,9 +68,6 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long to wait for more requests before firing a batched sync.
|
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
|
||||||
|
|
||||||
/// Global profile cache.
|
/// Global profile cache.
|
||||||
///
|
///
|
||||||
/// Profiles are fetched in batches and kept as plain data.
|
/// Profiles are fetched in batches and kept as plain data.
|
||||||
@@ -92,30 +95,26 @@ impl ProfileStore {
|
|||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let client = backend.read(cx).client();
|
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
|
||||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
|
||||||
for author in authors {
|
|
||||||
this.apply_author(*author, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let entity = cx.entity().downgrade();
|
let entity = cx.entity().downgrade();
|
||||||
let entity_clone = entity.clone();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
|
|
||||||
cx.spawn(async move |_this, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||||
|
this.apply_authors(authors.clone(), cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
if let Err(e) = Self::handle_requests(this, &client, &receiver, cx).await {
|
||||||
|
log::error!("Failed to handle requests: {e}");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = entity_clone.update(cx, |this, cx| this.load(cx)) {
|
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||||
log::warn!("profile store dropped before initial load could run: {error}");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -150,11 +149,9 @@ impl ProfileStore {
|
|||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
let filter = Filter::new().kind(Kind::Metadata).limit(WARM_LIMIT);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// Parse off the main thread.
|
|
||||||
// Only plain profiles cross back.
|
|
||||||
let profiles: Vec<Profile> = events
|
let profiles: Vec<Profile> = events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
@@ -166,7 +163,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -176,51 +173,13 @@ impl ProfileStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<_, Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
/// Re-read the latest metadata of `authors` from the local database in one query.
|
||||||
let backend = Backend::global(cx);
|
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||||
let client = backend.read(cx).client();
|
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
|
||||||
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
// Parse off the main thread.
|
|
||||||
// Only the profile crosses back.
|
|
||||||
let profile = events
|
|
||||||
.into_iter()
|
|
||||||
.max_by_key(|e| e.created_at)
|
|
||||||
.map(|event| {
|
|
||||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
|
||||||
Profile::new(event.pubkey, metadata)
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok::<_, Error>(profile)
|
|
||||||
});
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
|
||||||
let profile = work.await?;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
if let Some(profile) = profile {
|
|
||||||
this.profiles.insert(profile.public_key(), profile);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-read the latest metadata of every requested author from the local database.
|
|
||||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
|
||||||
|
|
||||||
if authors.is_empty() {
|
if authors.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -231,9 +190,9 @@ impl ProfileStore {
|
|||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// Pick the latest metadata per author off the main thread.
|
// Pick the latest metadata per author off the main thread.
|
||||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
match latest.get(&event.pubkey) {
|
match latest.get(&event.pubkey) {
|
||||||
Some((ts, _)) if *ts >= event.created_at => {}
|
Some((ts, _)) if *ts >= event.created_at => {}
|
||||||
@@ -257,7 +216,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -267,14 +226,18 @@ impl ProfileStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<_, Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
/// Re-read the latest metadata of every requested author from the local database.
|
||||||
///
|
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||||
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||||
|
self.apply_authors(authors, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch metadata for requested authors in batches, debounced to collect requests.
|
||||||
async fn handle_requests(
|
async fn handle_requests(
|
||||||
this: WeakEntity<ProfileStore>,
|
this: WeakEntity<ProfileStore>,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -316,15 +279,21 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let authors: Vec<PublicKey> = batch.drain().collect();
|
||||||
|
|
||||||
|
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||||
|
let opts = SyncOptions::default();
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
.authors(chunk.iter().copied());
|
||||||
|
|
||||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
|
||||||
Ok(_) => {
|
log::warn!("profile fetch failed: {e}");
|
||||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
}
|
||||||
|
|
||||||
|
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user