diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b28e9..09537b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - Surface backend errors as error notifications in the workspace instead of silently dropping them - Break ties in repository activity lists by event id, so same-second events order deterministically - Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils` +- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays +- Connect to grasp relays and load the inbox only after the user's grasp list event arrives ### Fixed diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 46da810..7e7e9a0 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -71,6 +71,41 @@ impl Filters { .author(public_key) } + /// NIP-51: code (people who produce NIP-34 events) follow list + pub fn git_authors(public_key: PublicKey) -> Filter { + Filter::new().kind(Kind::Custom(10017)).author(public_key) + } + + /// NIP-51: NIP-34 followed repositories + pub fn git_repos(public_key: PublicKey) -> Filter { + Filter::new().kind(Kind::Custom(10018)).author(public_key) + } + + // Replaceable events, so the latest of each kind is all we need. + pub fn user_metadata(public_key: PublicKey) -> Vec { + vec![ + Self::grasp_list(public_key).limit(1), + Self::git_authors(public_key).limit(1), + Self::git_repos(public_key).limit(1), + Filter::new() + .kind(Kind::ContactList) + .author(public_key) + .limit(1), + Filter::new() + .kind(Kind::Metadata) + .author(public_key) + .limit(1), + Filter::new() + .kind(Kind::MuteList) + .author(public_key) + .limit(1), + Filter::new() + .kind(Kind::BlossomServerList) + .author(public_key) + .limit(1), + ] + } + // Two filters: combining `#E` and `#e` would AND the conditions. pub fn comments_for(roots: impl IntoIterator) -> Vec { let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 417cada..1b53323 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -14,7 +14,9 @@ use signed_core::{Announcement, Filters, RepoAddr, filters}; use signed_git::{GitCache, Repo}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; -use crate::bootstrap::{subscribe_bootstrap_only, sync_bootstrap_only, user_grasp_list_servers}; +use crate::bootstrap::{ + ensure_bootstrap_relays, subscribe_bootstrap_only, sync_bootstrap_only, user_grasp_list_servers, +}; use crate::git_store::Mirrors; use crate::inbox::Inbox; use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp_clone_url}; @@ -76,6 +78,7 @@ impl Backend { let pump: Task> = cx.spawn(async move |this, cx| { let mut notifications = pump_client.notifications(); let mut pending_profiles: HashSet = HashSet::new(); + let mut pending_grasps: HashSet = HashSet::new(); let mut pending_repos: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); @@ -84,6 +87,9 @@ impl Backend { Some(UpdateEvent::Profile(author)) => { pending_profiles.insert(author); } + Some(UpdateEvent::Grasp(author)) => { + pending_grasps.insert(author); + } Some(UpdateEvent::Repo(update)) => pending_repos.push(update), None => break, } @@ -107,6 +113,9 @@ impl Backend { futures::future::Either::Left((Some(UpdateEvent::Profile(author)), _)) => { pending_profiles.insert(author); } + futures::future::Either::Left((Some(UpdateEvent::Grasp(author)), _)) => { + pending_grasps.insert(author); + } futures::future::Either::Left((Some(UpdateEvent::Repo(update)), _)) => { pending_repos.push(update); } @@ -116,18 +125,25 @@ impl Backend { } let profiles: Vec = pending_profiles.drain().collect(); + let grasps: Vec = pending_grasps.drain().collect(); let repos = std::mem::take(&mut pending_repos); - if let Err(e) = this.update(cx, |_this, cx| { + this.update(cx, |this, cx| { if !profiles.is_empty() { cx.emit(BackendEvent::ProfileUpdates(profiles)); } + if !repos.is_empty() { cx.emit(BackendEvent::RepoUpdates(repos)); } - }) { - log::warn!("failed to emit backend update: {e}"); - } + + for author in grasps { + if this.current_user == Some(author) { + this.connect_grasp_relays(author, cx); + } + } + }) + .ok(); } Ok(()) @@ -402,8 +418,6 @@ impl Backend { this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); - this.sync_inbox(cx); - cx.notify(); let relays: Vec<(RelayUrl, Option)> = [ @@ -444,8 +458,10 @@ impl Backend { GitUserGraspList { grasp_servers }.into_event_builder(), ] { let pusher = pusher.clone(); - cx.spawn(async move |_this, _cx| pusher.publish_best_effort(builder).await) - .detach(); + cx.background_spawn(async move { + pusher.publish_best_effort(builder).await; + }) + .detach(); } })?; @@ -856,30 +872,56 @@ impl Backend { fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); - let task: Task> = cx.spawn(async move |this, cx| { - let result = async { - sync_bootstrap_only( - &client, - Filters::grasp_list(public_key), - SyncOptions::default(), - ) - .await?; + cx.spawn(async move |this, cx| { + let result: Result<(), anyhow::Error> = cx + .background_spawn(async move { + ensure_bootstrap_relays(&client).await?; - for url in user_grasp_list_servers(&client, public_key).await? { - client.add_relay(url).and_connect().await.ok(); - } + for filter in Filters::user_metadata(public_key) { + client.sync(filter).await?; + } - Ok::<_, Error>(()) - } - .await; + Ok(()) + }) + .await; if let Err(e) = result { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); + })?; } - Ok(()) - }); - task.detach(); + Ok::<(), Error>(()) + }) + .detach(); + } + + /// Runs when the pump sees the user's grasp list event arrive. + fn connect_grasp_relays(&mut self, public_key: PublicKey, cx: &mut Context) { + let client = self.client.clone(); + + cx.spawn(async move |this, cx| { + match user_grasp_list_servers(&client, public_key).await { + Ok(servers) => { + for url in servers { + if let Err(e) = client.add_relay(&url).and_connect().await { + log::warn!("failed to connect grasp relay {url}: {e}"); + } + } + this.update(cx, |this, cx| { + this.sync_inbox(cx); + })?; + } + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); + })?; + } + } + + Ok::<(), Error>(()) + }) + .detach(); } pub fn client(&self) -> Client { @@ -947,7 +989,6 @@ impl Backend { this.passphrase_required = false; this.bootstrap_user(public_key, cx); - this.sync_inbox(cx); cx.emit(BackendEvent::SignerChanged); cx.notify(); @@ -1193,6 +1234,7 @@ async fn announce_repository_and_push( enum UpdateEvent { Profile(PublicKey), + Grasp(PublicKey), Repo(Update), } @@ -1210,6 +1252,7 @@ impl UpdateEvent { let update = match event.kind { Kind::Metadata => UpdateEvent::Profile(event.pubkey), + Kind::GitUserGraspList => UpdateEvent::Grasp(event.pubkey), kind if filters::is_repo_kind(kind) => { UpdateEvent::Repo(Update::from_event(&event)) } diff --git a/crates/signed_state/src/bootstrap.rs b/crates/signed_state/src/bootstrap.rs index 2a1770c..dd0056f 100644 --- a/crates/signed_state/src/bootstrap.rs +++ b/crates/signed_state/src/bootstrap.rs @@ -10,7 +10,7 @@ use signed_core::Filters; pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"]; pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; -async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> { +pub(crate) async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> { for url in BOOTSTRAP_RELAYS { client.add_relay(url).and_connect().await?; } diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs index 6858e0a..bc18945 100644 --- a/crates/signed_state/src/push.rs +++ b/crates/signed_state/src/push.rs @@ -14,8 +14,7 @@ pub(crate) const GRASP_PUSH_ATTEMPTS: usize = 3; const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1); -// `ws://` grasp servers, like ngit, use `http://`; secure relays map to -// `https://`. +/// `ws://` grasp servers, like ngit, use `http(s)://`. pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { let parsed = Url::parse(relay.as_str()).ok()?; let host = parsed.host_str()?; @@ -34,12 +33,12 @@ pub(crate) fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> O Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() } -// GRASP-06 contributor namespace URL of a pull request tip. +/// GRASP-06 contributor namespace URL of a pull request tip. pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String { format!("{base_url}/prs/{npub}/{repo_id}.git") } -// The author's GRASP-06 `/prs/` URLs come first. +/// The author's GRASP-06 `/prs/` URLs come first. pub(crate) fn pr_clone_urls(prs_urls: Vec, base_clone_urls: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); let mut urls = Vec::new(); @@ -84,8 +83,7 @@ impl GraspServer { #[derive(Debug, Clone, Default)] pub struct PushOutcome { pub servers: Vec, - // The newest state event a grasp relay accepted for this push; broadcast - // to the other relays once a git server holds the data. + /// The newest state event a grasp relay accepted for this push. pub state_event: Option, } @@ -178,8 +176,6 @@ fn keep_newest(state_event: &mut Option, event: Event) { } } -// The grasp push pipeline: stage a signed state event on each server's -// relay, then push the git data, retrying transient denials. #[derive(Clone)] pub(crate) struct GraspPush { client: Client,