chore: load user data via gossip and defer grasp relay connection
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s

This commit is contained in:
2026-10-06 13:48:35 +07:00
parent cd72b25626
commit f2a0ed002d
5 changed files with 113 additions and 37 deletions
+2
View File
@@ -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
+35
View File
@@ -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<Filter> {
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<Item = EventId>) -> Vec<Filter> {
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
+71 -28
View File
@@ -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<Result<(), Error>> = cx.spawn(async move |this, cx| {
let mut notifications = pump_client.notifications();
let mut pending_profiles: HashSet<PublicKey> = HashSet::new();
let mut pending_grasps: HashSet<PublicKey> = HashSet::new();
let mut pending_repos: Vec<Update> = Vec::new();
let mut seen: HashSet<EventId> = 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,19 +125,26 @@ impl Backend {
}
let profiles: Vec<PublicKey> = pending_profiles.drain().collect();
let grasps: Vec<PublicKey> = 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<RelayMetadata>)> = [
@@ -444,7 +458,9 @@ impl Backend {
GitUserGraspList { grasp_servers }.into_event_builder(),
] {
let pusher = pusher.clone();
cx.spawn(async move |_this, _cx| pusher.publish_best_effort(builder).await)
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<Self>) {
let client = self.client.clone();
let task: Task<Result<(), Error>> = 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();
}
Ok::<_, Error>(())
}
.await;
if let Err(e) = result {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
for filter in Filters::user_metadata(public_key) {
client.sync(filter).await?;
}
Ok(())
});
task.detach();
})
.await;
if let Err(e) = result {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
})?;
}
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<Self>) {
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))
}
+1 -1
View File
@@ -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?;
}
+4 -8
View File
@@ -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://<host>`; secure relays map to
// `https://<host>`.
/// `ws://` grasp servers, like ngit, use `http(s)://<host>`.
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
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<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
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<GraspServer>,
// 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<Event>,
}
@@ -178,8 +176,6 @@ fn keep_newest(state_event: &mut Option<Event>, 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,