feat: out-of-box experience (#2)

Reviewed-on: https://git.reya.su/reya/signed/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-08-25 13:23:07 +00:00
parent 7249a323f8
commit dacfd49cdf
180 changed files with 16763 additions and 1042 deletions
+4
View File
@@ -6,12 +6,16 @@ publish.workspace = true
[dependencies]
signed_core = { path = "../signed_core" }
signed_git = { path = "../signed_git" }
signed_nostr = { path = "../signed_nostr" }
utils = { path = "../utils" }
nostr.workspace = true
nostr-sdk.workspace = true
nostr-connect.workspace = true
bitcoin_hashes = "1"
gpui.workspace = true
flume.workspace = true
anyhow.workspace = true
+553 -88
View File
@@ -1,16 +1,18 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Error, anyhow};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use nostr::event::IntoEventBuilder;
use nostr_connect::prelude::*;
use nostr_sdk::client::SyncSummary;
use nostr_sdk::prelude::*;
use signed_core::filters;
use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update};
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`).
pub const USER_KEYRING: &str = "su.reya.signed#user";
/// Keyring entry holding the locally generated key for NIP-46 sessions.
pub const MASTER_KEYRING: &str = "su.reya.signed#master";
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`
/// with an embedded `?master=<nsec>` NIP-46 session key).
pub const USER_KEYRING: &str = "Signed Safe Storage";
/// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
@@ -33,12 +35,27 @@ pub const INDEXER_RELAYS: [&str; 3] = [
pub enum BackendEvent {
/// User has no signer configured.
SignerRequired,
/// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a
/// passphrase is required to decrypt it before the session can resume.
PassphraseRequired,
/// The signer has changed (login/logout/account switch).
SignerChanged,
/// Relay bootstrap finished.
Connected,
/// A new event was received from a relay and stored in the database.
NostrUpdate(Update),
/// A negentropy sync completed; the database was updated directly,
/// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired
/// for synced events).
Synced,
/// A negentropy sync is in flight. Stores may re-query to render
/// incrementally; UI can show `current`/`total` progress.
SyncProgress {
/// Total events to process.
total: u64,
/// Events processed so far.
current: u64,
},
/// An event built locally was signed, broadcast and stored.
Published(Box<Event>),
/// An error occurred.
@@ -58,8 +75,14 @@ impl BackendEvent {
/// notification pump. Stores subscribe to [`BackendEvent`] and re-query the
/// local database when relevant updates arrive.
pub struct Backend {
inner: NostrBackend,
client: Client,
signer: UniversalSigner,
current_user: Option<PublicKey>,
connected: bool,
sync_progress: Option<(u64, u64)>,
/// Whether the stored credential is NIP-49 encrypted and a passphrase
/// is still needed to resume the session.
passphrase_required: bool,
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -79,11 +102,11 @@ impl Backend {
cx.set_global(GlobalBackend(entity));
}
pub(crate) fn new(inner: NostrBackend, cx: &mut Context<Self>) -> Self {
let client = inner.client();
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
let pump_client = client.clone();
let pump = cx.spawn(async move |this, cx| {
let mut notifications = client.notifications();
let mut notifications = pump_client.notifications();
while let Some(notification) = notifications.next().await {
let ClientNotification::Event { event, .. } = notification else {
@@ -104,8 +127,12 @@ impl Backend {
});
let mut this = Self {
inner,
client,
signer,
current_user: None,
connected: false,
sync_progress: None,
passphrase_required: false,
tasks: vec![pump],
};
@@ -116,23 +143,30 @@ impl Backend {
/// Bootstrap the client: connect to the default relays (indexers as
/// discovery-only) and restore the saved session, if any.
fn bootstrap(&mut self, cx: &mut Context<Self>) {
let backend = self.inner.clone();
let client = self.client.clone();
let task = cx.background_spawn(async move {
for url in BOOTSTRAP_RELAYS {
backend.add_relay(url).await?;
client.add_relay(url).await?;
}
for url in INDEXER_RELAYS {
backend.add_discovery_relay(url).await?;
client
.add_relay(url)
.capabilities(RelayCapabilities::DISCOVERY)
.await?;
}
backend.connect().await;
client.connect().await;
Ok::<(), Error>(())
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(()) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
this.update(cx, |this, cx| {
this.connected = true;
cx.emit(BackendEvent::Connected);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
@@ -145,7 +179,9 @@ impl Backend {
}
/// Restore the saved session from the keyring. Emits
/// [`BackendEvent::SignerRequired`] if no credential is stored.
/// [`BackendEvent::SignerRequired`] if no credential is stored, or
/// [`BackendEvent::PassphraseRequired`] if the stored identity is
/// NIP-49 encrypted.
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
cx.emit(BackendEvent::SignerRequired);
@@ -153,7 +189,6 @@ impl Backend {
}
let user = cx.read_credentials(USER_KEYRING);
let master = self.master_key(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let content = match user.await {
@@ -169,15 +204,24 @@ impl Backend {
let keys = Keys::new(SecretKey::parse(&content)?);
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
} else if content.starts_with("bunker://") {
let uri = NostrConnectUri::parse(&content)?;
let (base, keys) = extract_master_key(&content);
let uri = NostrConnectUri::parse(base)?;
let mut signer = NostrConnect::new(
uri,
master.await,
keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
signer.auth_url_handler(SignedAuthUrlHandler);
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
} else if content.starts_with("ncryptsec1") {
// Encrypted identity: a passphrase is required to
// decrypt it before the session can resume.
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
this.update(cx, |this, cx| {
this.passphrase_required = true;
cx.emit(BackendEvent::PassphraseRequired);
})?;
} else {
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
}
@@ -197,6 +241,166 @@ impl Backend {
}));
}
/// Decrypt the NIP-49 encrypted credential stored in the keyring with
/// the given passphrase and resume the session.
///
/// The scrypt decryption runs off the UI thread. The returned task
/// yields the public key on success, or the failure reason (e.g. wrong
/// passphrase), so callers can render inline errors.
pub fn restore_with_passphrase(
&mut self,
password: &str,
cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> {
let password = password.to_owned();
let user = cx.read_credentials(USER_KEYRING);
cx.spawn(async move |this, cx| {
let content = user
.await?
.map(|(_username, secret)| String::from_utf8(secret))
.transpose()?
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
if !content.starts_with("ncryptsec1") {
return Err(anyhow!("stored credential is not passphrase-encrypted"));
}
let decrypt_task = cx.background_spawn(async move {
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
let secret = encrypted.decrypt(&password)?;
Ok::<_, Error>(Keys::new(secret))
});
let keys = decrypt_task.await?;
let public_key = keys.public_key();
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
Ok(public_key)
})
}
/// Create a new identity: generate keys, encrypt the secret key with the
/// passphrase (NIP-49) and persist it in the keyring, then publish the
/// user's NIP-65 relay list, metadata and grasp list.
///
/// The heavy encryption runs off the UI thread. The returned task yields
/// the new public key on success, or the failure reason, so callers can
/// render progress and inline errors.
pub fn create_identity(
&mut self,
name: &str,
password: &str,
cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> {
let name = name.trim().to_owned();
let password = password.to_owned();
if name.is_empty() || name.len() > 255 {
return Task::ready(Err(anyhow!("Name must be 1-255 characters")));
}
if password.is_empty() {
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
}
cx.spawn(async move |this, cx| {
let job = cx.background_spawn(async move {
let keys = Keys::generate();
let encrypted =
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
let ncryptsec = encrypted.to_bech32()?;
Ok::<_, Error>((keys, ncryptsec))
});
let (keys, ncryptsec) = job.await?;
let public_key = keys.public_key();
// Persist the encrypted credential.
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
});
write.await?;
this.update(cx, |this, cx| {
// Become the new identity, so the publishes below are
// signed with the new keys.
this.signer.swap_inner(keys);
this.current_user = Some(public_key);
this.bootstrap_user(public_key, cx);
cx.emit(BackendEvent::SignerChanged);
cx.notify();
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
(
RelayUrl::parse("wss://relay.primal.net").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://relay.ditto.pub").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://relay.nostr.net").unwrap(),
Some(RelayMetadata::Write),
),
(
RelayUrl::parse("wss://nos.lol").unwrap(),
Some(RelayMetadata::Write),
),
]
.to_vec();
this.send_fire_and_forget(RelayList::new(relays).into_event_builder(), cx);
let metadata = Metadata::new()
.name(&name)
.display_name(&name)
.into_event_builder();
this.send_fire_and_forget(metadata, cx);
let grasp_servers: Vec<RelayUrl> = ["wss://gitnostr.com", "wss://relay.ngit.dev"]
.into_iter()
.map(|url| RelayUrl::parse(url).expect("valid relay URL"))
.collect();
this.send_fire_and_forget(
GitUserGraspList { grasp_servers }.into_event_builder(),
cx,
);
})?;
Ok(public_key)
})
}
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
/// the credential's prefix.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
let credential = credential.trim();
if credential.starts_with("nsec1") {
self.login_with_nsec(credential, cx);
} else if credential.starts_with("bunker://") {
self.login_with_bunker(credential, cx);
} else {
cx.emit(BackendEvent::error(
"Unsupported credential, expected nsec1... or bunker://...",
));
}
}
/// Create a fresh identity and login with it. The generated key is
/// persisted in the keyring like any other `nsec` credential.
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
let nsec = Keys::generate()
.secret_key()
.to_bech32()
.expect("infallible");
self.login_with_nsec(&nsec, cx);
}
/// Login with an `nsec1...` secret key. The credential is verified by
/// the signer flow and persisted in the keyring.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
@@ -224,9 +428,11 @@ impl Backend {
}));
}
/// Login with a `bunker://...` URI (NIP-46). The auth URL, if any, is
/// opened in the default browser. The credential is persisted in the
/// keyring after the signer proves reachable.
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is
/// generated and embedded into the stored URI as `?master=<nsec>`, so
/// no separate keyring entry is needed. The auth URL, if any, is opened
/// in the default browser. The credential is persisted in the keyring
/// after the signer proves reachable.
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
let uri_string = uri.trim().to_owned();
@@ -238,14 +444,15 @@ impl Backend {
}
};
let master = self.master_key(cx);
let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes());
let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
self.tasks.push(cx.spawn(async move |this, cx| {
let result = async {
let mut signer = NostrConnect::new(
connect_uri,
master.await,
keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
@@ -277,8 +484,9 @@ impl Backend {
delete.await.ok();
this.update(cx, |this, cx| {
this.inner.signer().swap_inner(Keys::generate());
this.signer.swap_inner(Keys::generate());
this.current_user = None;
this.passphrase_required = false;
cx.emit(BackendEvent::SignerChanged);
cx.emit(BackendEvent::SignerRequired);
cx.notify();
@@ -288,44 +496,14 @@ impl Backend {
}));
}
/// Get (or generate and persist) the key used for NIP-46 sessions.
fn master_key(&self, cx: &App) -> Task<Keys> {
let task = cx.read_credentials(MASTER_KEYRING);
cx.spawn(async move |cx| {
let (keys, new_key) = match task.await {
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
Ok(secret_key) => (Keys::new(secret_key), false),
_ => (Keys::generate(), true),
},
_ => (Keys::generate(), true),
};
if new_key {
let username = keys.public_key().to_hex();
let password = keys.secret_key().to_secret_bytes();
cx.update(|cx| {
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
cx.background_spawn(async move { task.await.ok() }).detach();
});
}
keys
})
}
/// Fetch the user's grasp list (kind `10317`) and add the listed grasp
/// servers as relays.
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let backend = self.inner.clone();
let client = self.client.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
let result = async {
let events = backend
.client()
.fetch_events(filters::grasp_list(public_key))
.await?;
let events = client.fetch_events(filters::grasp_list(public_key)).await?;
let urls: Vec<String> = events
.into_iter()
@@ -340,9 +518,9 @@ impl Backend {
.unwrap_or_default();
for url in urls {
backend.add_relay(&url).await.ok();
client.add_relay(&url).await.ok();
}
backend.connect().await;
client.connect().await;
Ok::<_, Error>(())
}
@@ -358,12 +536,12 @@ impl Backend {
/// Get the nostr client.
pub fn client(&self) -> Client {
self.inner.client()
self.client.clone()
}
/// Get the current signer.
pub fn signer(&self) -> UniversalSigner {
self.inner.signer()
self.signer.clone()
}
/// Get the current user's public key.
@@ -371,6 +549,27 @@ impl Backend {
self.current_user
}
/// Whether the stored credential is NIP-49 encrypted and a passphrase
/// is still needed to resume the session.
pub fn passphrase_required(&self) -> bool {
self.passphrase_required
}
/// Surface an error message through [`BackendEvent::Error`].
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
cx.emit(BackendEvent::error(message));
}
/// Whether the relay bootstrap has completed.
pub fn is_connected(&self) -> bool {
self.connected
}
/// Progress of the in-flight negentropy sync, if any: `(total, current)`.
pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress
}
/// Update the signer (any type implementing the async signer traits,
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy).
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
@@ -384,8 +583,9 @@ impl Backend {
match new_signer.get_public_key_async().await {
Ok(public_key) => {
this.update(cx, |this, cx| {
this.inner.signer().swap_inner(new_signer);
this.signer.swap_inner(new_signer);
this.current_user = Some(public_key);
this.passphrase_required = false;
this.bootstrap_user(public_key, cx);
cx.emit(BackendEvent::SignerChanged);
cx.notify();
@@ -405,20 +605,24 @@ impl Backend {
/// Add relays and connect to them.
pub fn add_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
let backend = self.inner.clone();
let client = self.client.clone();
let task = cx.background_spawn(async move {
for url in urls {
backend.add_relay(&url).await?;
client.add_relay(&url).await?;
}
backend.connect().await;
client.connect().await;
Ok::<(), Error>(())
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(()) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
this.update(cx, |this, cx| {
this.connected = true;
cx.emit(BackendEvent::Connected);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
@@ -431,13 +635,16 @@ impl Backend {
/// Add relays used only for discovery (e.g. NIP-65 indexers) and
/// connect to them. No subscriptions or writes are routed through them.
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
let backend = self.inner.clone();
let client = self.client.clone();
let task = cx.background_spawn(async move {
for url in urls {
backend.add_discovery_relay(&url).await?;
client
.add_relay(&url)
.capabilities(RelayCapabilities::DISCOVERY)
.await?;
}
backend.connect().await;
client.connect().await;
Ok::<(), Error>(())
});
@@ -452,9 +659,9 @@ impl Backend {
/// Start a persistent subscription. Matching events are stored in the
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
let backend = self.inner.clone();
let client = self.client.clone();
let task = cx.background_spawn(async move { backend.subscribe(filter).await.map(|_| ()) });
let task = cx.background_spawn(async move { client.subscribe(filter).await.map(|_| ()) });
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
@@ -464,41 +671,299 @@ impl Backend {
}));
}
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and
/// fetch its events from them: a one-shot auto-closing subscription for
/// `filters`, plus a negentropy sync so issues, patches and PRs stored
/// only on those relays are not missed.
///
/// Best-effort: failures are logged, not surfaced, because the bootstrap
/// relays already cover the repository. The relays stay in the pool, so
/// events the user publishes for this repository also reach them.
pub fn connect_repo_relays(
&mut self,
relays: Vec<RelayUrl>,
filters: Vec<Filter>,
cx: &mut Context<Self>,
) {
let client = self.client.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| {
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
log::warn!("repo relay fetch failed: {e}");
}
Ok(())
}));
}
/// Start a one-shot subscription targeted only at the bootstrap relays,
/// auto-closing after EOSE or a short timeout. Matching events are stored
/// in the database and surface as [`BackendEvent::NostrUpdate`] while the
/// subscription is open.
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
let task =
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
Ok(())
}));
}
/// Negentropy-sync the given filter against the bootstrap relays:
/// reconciles the local database with the relays in both directions.
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to
/// whole-percent changes) and [`BackendEvent::Synced`] on completion.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let client = self.client.clone();
self.sync_progress = Some((0, 0));
cx.notify();
let (tx, mut rx) = SyncProgress::channel();
self.tasks.push(cx.spawn(async move |this, cx| {
let mut last_percent: u64 = 0;
while rx.changed().await.is_ok() {
let progress = *rx.borrow_and_update();
let percent = (progress.percentage() * 100.0) as u64;
if progress.current > 0 && percent != last_percent {
last_percent = percent;
let alive = this.update(cx, |this, cx| {
this.sync_progress = Some((progress.total, progress.current));
cx.emit(BackendEvent::SyncProgress {
total: progress.total,
current: progress.current,
});
cx.notify();
});
if alive.is_err() {
break;
}
}
}
Ok(())
}));
let task = cx.background_spawn(async move {
let opts = SyncOptions::default().progress(tx);
sync_bootstrap_only(&client, filter, opts).await
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(summary) => {
log::debug!(
"sync done: {} received, {} sent",
summary.received.len(),
summary.sent.len()
);
this.update(cx, |this, cx| {
this.sync_progress = None;
cx.emit(BackendEvent::Synced);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.sync_progress = None;
cx.emit(BackendEvent::error(e.to_string()))
})?;
}
}
Ok(())
}));
}
/// Sign, broadcast and locally store an event. Emits
/// [`BackendEvent::Published`] on success so stores can refresh.
///
/// The returned receiver yields the outcome of this specific action,
/// so callers can show inline progress/errors instead of relying on
/// the global [`BackendEvent::Error`].
/// The returned task yields the outcome of this specific action, so
/// callers can show inline progress/errors instead of relying on
/// the global [`BackendEvent::Error`]. The task is owned by the caller;
/// dropping it cancels the publish.
pub fn send(
&mut self,
builder: EventBuilder,
cx: &mut Context<Self>,
) -> flume::Receiver<Result<Event, Error>> {
let (tx, rx) = flume::bounded(1);
) -> Task<Result<Event, Error>> {
let client = self.client.clone();
let signer = self.signer.clone();
let backend = self.inner.clone();
let task = cx.background_spawn(async move { backend.send(builder).await });
cx.spawn(async move |this, cx| {
// Sign with the current signer, broadcast, and save locally so
// the event is immediately visible to database queries.
let work = cx.background_spawn(async move {
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).await?;
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await;
if output.success.is_empty() && !output.failed.is_empty() {
let reasons = output
.failed
.values()
.cloned()
.collect::<Vec<String>>()
.join(", ");
return Err(anyhow!("event not accepted by any relay: {reasons}"));
}
Ok(event)
});
let result = work.await;
match &result {
Ok(event) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(event.clone())));
})?;
})
.ok();
}
Err(e) => {
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();
}
}
tx.send_async(result)
.await
.map_err(|_| anyhow!("action result receiver dropped"))
}));
result
})
}
rx
/// Publish a NIP-34 repository announcement (kind 30617) with the
/// current signer. The returned task yields the published event, so
/// callers can show inline progress/errors.
pub fn publish_announcement(
&mut self,
announcement: GitRepositoryAnnouncement,
cx: &mut Context<Self>,
) -> Task<Result<Event, Error>> {
self.send(announcement.into_event_builder(), cx)
}
/// Sign, broadcast and store an event without awaiting the result;
/// failures surface through [`BackendEvent::Error`]. The spawned task is
/// owned by the backend, so it is cancelled when the backend is dropped.
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
let task = self.send(builder, cx);
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
})
.ok();
}
Ok(())
}));
}
}
/// Add the given relays, connect to them, and fetch the filters: a one-shot
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter
/// as a second pass, so events that race with the subscription or relays
/// with flaky EOSE behavior can't be missed. Relays without NEG-XX support
/// just fail the sync step; the subscription already covered them.
async fn connect_repo_relays_only(
client: &Client,
relays: Vec<RelayUrl>,
filters: Vec<Filter>,
) -> Result<(), Error> {
if relays.is_empty() {
return Ok(());
}
for url in &relays {
client.add_relay(url).await?;
}
client.connect().await;
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(10)));
let target: HashMap<&str, Vec<Filter>> = relays
.iter()
.map(|url| (url.as_str(), filters.clone()))
.collect();
client.subscribe(target).close_on(opts).await?;
for filter in filters {
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
if let Err(e) = client
.sync(filter)
.with(relays.iter())
.opts(sync_opts)
.await
{
log::warn!("repo relay negentropy sync failed: {e}");
}
}
Ok(())
}
/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a
/// short timeout. Use for one-shot data fetches (repo events, profiles)
/// instead of persistent gossip-routed subscriptions.
pub(crate) async fn subscribe_bootstrap_only(
client: &Client,
filters: Vec<Filter>,
) -> Result<(), Error> {
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(10)));
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.iter()
.map(|relay| (*relay, filters.clone()))
.collect();
client.subscribe(target).close_on(opts).await?;
Ok(())
}
/// Negentropy-sync the filter against the bootstrap relays only.
pub(crate) async fn sync_bootstrap_only(
client: &Client,
filter: Filter,
opts: SyncOptions,
) -> Result<SyncSummary, Error> {
let output = client
.sync(filter)
.with(BOOTSTRAP_RELAYS)
.opts(opts)
.await?;
Ok(output.value)
}
/// Embed a NIP-46 session key into a bunker URI as `?master=<nsec>`.
fn with_master_key(uri: &str, keys: &Keys) -> String {
let separator = if uri.contains('?') { '&' } else { '?' };
let nsec = keys.secret_key().to_bech32().expect("infallible");
format!("{uri}{separator}master={nsec}")
}
/// Split a stored bunker credential into the plain URI and the session key.
/// Credentials without an embedded key (legacy) get a fresh one.
fn extract_master_key(credential: &str) -> (&str, Keys) {
match credential.split_once("master=") {
Some((base, nsec)) => {
let keys = SecretKey::parse(nsec)
.map(Keys::new)
.unwrap_or_else(|_| Keys::generate());
(base.trim_end_matches(['?', '&']), keys)
}
None => (credential, Keys::generate()),
}
}
+44
View File
@@ -0,0 +1,44 @@
use std::path::PathBuf;
use gpui::{App, Global};
use signed_git::GitCache;
struct GlobalGitStore(GitCache);
impl Global for GlobalGitStore {}
/// Global access to the on-disk git clone cache (grasp mirrors).
///
/// Installed at startup via [`GitStore::set_global`]; see also
/// [`signed_state::init`].
#[derive(Debug, Clone)]
pub struct GitStore(GitCache);
impl GitStore {
/// Register the clone cache rooted at `root` as an app-wide global.
/// Replaces any previously installed store (see [`signed_state::init`], which
/// installs an empty one).
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
let store = Self::new(root);
cx.set_global(GlobalGitStore(store.0.clone()));
store
}
/// The app-wide clone cache.
///
/// # Panics
///
/// Panics if [`GitStore::set_global`] was never called.
pub fn global(cx: &App) -> Self {
Self(cx.global::<GlobalGitStore>().0.clone())
}
fn new(root: impl Into<PathBuf>) -> Self {
Self(GitCache::new(root.into()))
}
/// Underlying clone cache.
pub fn cache(&self) -> &GitCache {
&self.0
}
}
+18 -8
View File
@@ -1,16 +1,20 @@
mod backend;
mod git_store;
mod profile;
mod repo;
mod repo_list;
use std::path::Path;
use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent};
pub use git_store::GitStore;
use gpui::{App, AppContext, Entity};
pub use profile::{Profile, ProfileStore, shorten_pubkey};
pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore};
pub use repo::RepoStore;
pub use repo_list::RepoListStore;
use signed_nostr::NostrBackend;
use signed_nostr::new_backend;
pub use utils::shorten_pubkey;
/// Initialize the backend and stores, and install them as globals. Call once
/// at startup, before opening any window that uses the stores.
@@ -22,29 +26,35 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.ok();
let path = db_path.as_ref().to_path_buf();
let inner = cx.foreground_executor().block_on(async move {
NostrBackend::new(path)
let (client, signer) = cx.foreground_executor().block_on(async move {
new_backend(path)
.await
.expect("failed to initialize nostr backend")
});
let entity = cx.new(|cx| Backend::new(inner, cx));
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// The clone cache is only meaningful on native platforms; the wasm
// build registers an empty store so `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx);
entity
}
/// Initialize the backend with an in-memory database on wasm.
#[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> {
let inner = NostrBackend::new().expect("failed to initialize nostr backend");
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
let entity = cx.new(|cx| Backend::new(inner, cx));
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
GitStore::set_global(PathBuf::new(), cx);
entity
}
+174 -70
View File
@@ -1,11 +1,14 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::Error;
use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task};
use flume::{Receiver, RecvTimeoutError, Sender};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
use nostr_sdk::prelude::*;
use utils::shorten_pubkey;
use crate::backend::{Backend, BackendEvent};
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
/// A user profile (kind `0` metadata), as plain data for the UI.
#[derive(Debug, Clone)]
@@ -57,21 +60,23 @@ impl Profile {
}
}
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let npub = public_key.to_bech32().unwrap();
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
/// Message from the fetch task to the main thread.
enum Dispatch {
/// A batched sync finished; re-read seen profiles from the database.
Synced,
}
/// How long to wait for more requests before firing a batched sync.
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
/// Global profile cache. Profiles are fetched in batches and kept as plain
/// data; the whole store notifies on change.
pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>,
/// Public keys we've already requested this session.
seen: HashSet<PublicKey>,
/// Public keys queued for the next batched fetch.
queued: HashSet<PublicKey>,
fetching: bool,
/// Public keys we've already requested this session (main thread only).
seen: RefCell<HashSet<PublicKey>>,
/// Sender for queuing fetch requests, batched by a background task.
sender: Sender<PublicKey>,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
@@ -106,12 +111,31 @@ impl ProfileStore {
_ => {}
});
// Fetch requests are queued on a channel and synced in batches by a
// background task.
let client = backend.read(cx).client();
let (sender, receiver) = flume::unbounded::<PublicKey>();
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
let mut tasks = Vec::new();
tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client, &dispatch_tx, &receiver).await
}));
// Re-read seen profiles from the database after each batch sync.
tasks.push(cx.spawn(async move |this, cx| {
while let Ok(Dispatch::Synced) = dispatch_rx.recv_async().await {
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
}
Ok(())
}));
let mut store = Self {
profiles: HashMap::new(),
seen: HashSet::new(),
queued: HashSet::new(),
fetching: false,
tasks: Vec::new(),
seen: RefCell::new(HashSet::new()),
sender,
tasks,
_subscription: subscription,
};
@@ -121,14 +145,17 @@ impl ProfileStore {
/// Get a profile. Returns a placeholder (default metadata) and queues a
/// fetch if the profile isn't cached yet.
pub fn get(&mut self, public_key: PublicKey, cx: &mut Context<Self>) -> Profile {
if let Some(profile) = self.profiles.get(&public_key) {
pub fn get(&self, public_key: &PublicKey) -> Profile {
if let Some(profile) = self.profiles.get(public_key) {
return profile.clone();
}
if self.seen.insert(public_key) {
self.queued.insert(public_key);
self.queue_fetch(cx);
let public_key = *public_key;
if self.seen.borrow_mut().insert(public_key)
&& let Err(e) = self.sender.send(public_key)
{
log::warn!("failed to queue profile fetch: {e}");
}
Profile::new(public_key, Metadata::default())
@@ -138,88 +165,165 @@ impl ProfileStore {
fn load(&mut self, cx: &mut Context<Self>) {
let client = Backend::global(cx).read(cx).client();
let task = cx.spawn(async move |this, cx| {
let work = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
this.update(cx, |this, cx| {
for event in events {
// Parse off the main thread; only plain profiles cross back.
let profiles: Vec<Profile> = events
.into_iter()
.map(|event| {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
this.profiles
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
Profile::new(event.pubkey, metadata)
})
.collect();
Ok::<_, Error>(profiles)
});
self.tasks.push(cx.spawn(async move |this, cx| {
let profiles = work.await?;
this.update(cx, |this, cx| {
for profile in profiles {
this.profiles.insert(profile.public_key(), profile);
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}));
}
/// Re-read the latest metadata of an author from the local database.
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = Backend::global(cx).read(cx).client();
let task = cx.spawn(async move |this, cx| {
let work = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
let events = client.database().query(filter).await?;
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
// 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)
});
this.update(cx, |this, cx| {
this.profiles
.insert(public_key, Profile::new(public_key, metadata));
cx.notify();
})?;
}
Ok(())
Ok::<_, Error>(profile)
});
self.tasks.push(task);
self.tasks.push(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(())
}));
}
/// Drain the queue in a batched fetch, debounced to collect requests.
fn queue_fetch(&mut self, cx: &mut Context<Self>) {
if self.fetching {
/// Re-read the latest metadata of every requested author from the local
/// database (used after a sync, which produces no NostrUpdate events).
fn apply_seen(&mut self, cx: &mut Context<Self>) {
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
if authors.is_empty() {
return;
}
self.fetching = true;
let client = Backend::global(cx).read(cx).client();
let task = cx.spawn(async move |this, cx| {
loop {
// Collect more requests before firing the batch.
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
let work = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
let events = client.database().query(filter).await?;
let batch = this.update(cx, |this, _cx| std::mem::take(&mut this.queued))?;
if batch.is_empty() {
this.update(cx, |this, _cx| {
this.fetching = false;
})?;
break;
}
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(batch.into_iter().collect::<Vec<PublicKey>>());
// Gossip routes the fetch to each author's relays. Fetched
// events land in the database and surface via NostrUpdate.
if let Err(e) = client.fetch_events(filter).await {
log::warn!("profile fetch failed: {e}");
// Pick the latest metadata per author off the main thread.
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
for event in events {
match latest.get(&event.pubkey) {
Some((ts, _)) if *ts >= event.created_at => {}
_ => {
latest.insert(
event.pubkey,
(
event.created_at,
Metadata::from_json(&event.content).unwrap_or_default(),
),
);
}
}
}
Ok(())
let profiles: Vec<Profile> = latest
.into_iter()
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
.collect();
Ok::<_, Error>(profiles)
});
self.tasks.push(task);
self.tasks.push(cx.spawn(async move |this, cx| {
let profiles = work.await?;
this.update(cx, |this, cx| {
for profile in profiles {
this.profiles.insert(profile.public_key(), profile);
}
cx.notify();
})?;
Ok(())
}));
}
/// Sync metadata for requested authors in batches, debounced to collect
/// requests. Runs on a background thread; results are dispatched to the
/// main thread, which re-reads the database.
async fn handle_requests(
client: &Client,
dispatch: &Sender<Dispatch>,
receiver: &Receiver<PublicKey>,
) -> Result<(), Error> {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
// Wait for the first request of a batch.
match receiver.recv_timeout(BATCH_TIMEOUT) {
Ok(public_key) => {
batch.insert(public_key);
}
Err(RecvTimeoutError::Disconnected) => return Ok(()),
Err(RecvTimeoutError::Timeout) => continue,
};
// Collect everything that arrives within the debounce window.
let deadline = Instant::now() + BATCH_TIMEOUT;
while let Ok(public_key) = receiver.recv_deadline(deadline) {
batch.insert(public_key);
}
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(batch.drain().collect::<Vec<PublicKey>>());
// Negentropy-sync with the bootstrap relays. Synced events are
// written to the database directly (no NostrUpdate), so re-apply
// from the database afterwards.
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
Ok(_) => {
if dispatch.send(Dispatch::Synced).is_err() {
log::warn!("profile dispatch channel closed, dropping sync result");
}
}
Err(e) => log::warn!("profile sync failed: {e}"),
}
}
}
}
File diff suppressed because it is too large Load Diff
+177 -62
View File
@@ -1,18 +1,33 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use gpui::{Context, Subscription, Task};
use gpui::{AppContext, Context, Subscription, Task};
use nostr_sdk::prelude::*;
use signed_core::{Announcement, filters};
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
use crate::backend::{Backend, BackendEvent};
/// Delay between a refresh request and the actual re-query, so bursts of
/// events (e.g. sync progress ticks) collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How far back activity events count toward a repository's last activity.
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
/// Store listing repository announcements (global discovery or per-author).
pub struct RepoListStore {
pub announcements: Vec<Announcement>,
/// Shared so views can clone the list per frame without a deep copy.
pub announcements: Arc<Vec<Announcement>>,
/// Latest known activity timestamp per repository
/// (announcements, state updates, patches, PRs, issues, statuses).
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
author: Option<PublicKey>,
refreshing: bool,
refresh_dirty: bool,
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
debouncing: bool,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
@@ -20,16 +35,30 @@ pub struct RepoListStore {
impl RepoListStore {
/// Create a store. If `author` is `None`, all announcements are listed.
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(update) => {
update.kind == Kind::GitRepoAnnouncement
&& this.author.is_none_or(|a| a == update.author)
// Deletions may target anything we list; always refresh.
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
true
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
// Activity (patches, issues, ...) is addressed to repos via
// `a` tags, so its author isn't the repo owner; always refresh.
true
} else {
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
let is_repo_state = update.kind == Kind::RepoState;
let tracked = is_announcement || is_repo_state;
tracked && this.author.is_none_or(|a| a == update.author)
}
}
BackendEvent::Published(event) => {
event.kind == Kind::GitRepoAnnouncement
&& this.author.is_none_or(|a| a == event.pubkey)
}
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
_ => false,
};
@@ -39,10 +68,12 @@ impl RepoListStore {
});
let mut store = Self {
announcements: Vec::new(),
announcements: Arc::new(Vec::new()),
last_activity: Arc::new(HashMap::new()),
author,
refreshing: false,
refresh_dirty: false,
debouncing: false,
_subscription: subscription,
tasks: Vec::new(),
};
@@ -59,89 +90,173 @@ impl RepoListStore {
self.refresh(cx);
}
/// Negentropy-sync announcements with the bootstrap relays.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let author = self.author;
Backend::global(cx).update(cx, |backend, cx| {
backend.update(cx, |backend, cx| {
let filter = match author {
Some(a) => filters::announcements_by(a),
None => filters::all_announcements(500),
None => filters::all_announcements(),
};
backend.subscribe(filter, cx);
backend.sync_bootstrap(filter, cx);
// Deletion requests (NIP-09/62) must be known before any
// announcement can be shown.
backend.sync_bootstrap(filters::deletions(), cx);
});
}
/// Re-query the local database. Latest announcement per repository wins.
///
/// Debounced: concurrent requests are coalesced into a single re-query
/// after the running one finishes.
/// Debounced: a short delay collapses bursts of requests (e.g. sync
/// progress ticks), and requests that arrive while a query is running
/// are folded into one follow-up query. The query and processing run on
/// a background thread; only the results are applied on the main thread.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing {
self.refresh_dirty = true;
return;
}
if self.debouncing {
return;
}
self.debouncing = true;
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| {
this.debouncing = false;
this.run_refresh(cx);
})
});
self.tasks.push(task);
}
/// One query + apply cycle (debounced entry point).
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refreshing = true;
let client = Backend::global(cx).read(cx).client();
let author = self.author;
let task = cx.spawn(async move |this, cx| {
loop {
let filter = match author {
Some(a) => filters::announcements_by(a),
None => filters::all_announcements(500),
let work = cx.background_spawn(async move {
let filter = match author {
Some(a) => filters::announcements_by(a),
None => filters::all_announcements(),
};
let events = client.database().query(filter).await?;
let deletion_events = client.database().query(filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events);
// Dedup and sort off the main thread; only the final list
// crosses back into the entity.
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
for event in events {
if deletions.is_deleted(&event) {
continue;
}
let Some(announcement) = Announcement::from_event(&event) else {
continue;
};
let events = match client.database().query(filter).await {
Ok(events) => events,
Err(_) => {
return this.update(cx, |this, _cx| {
this.refreshing = false;
});
let addr = announcement.addr();
match by_repo.get(&addr) {
Some(existing) if existing.created_at >= announcement.created_at => {}
_ => {
by_repo.insert(addr, announcement);
}
};
let again = this.update(cx, |this, cx| {
let mut by_repo: HashMap<(String, String), Announcement> = HashMap::new();
for event in events {
let Some(announcement) = Announcement::from_event(&event) else {
continue;
};
let key = (announcement.owner.to_hex(), announcement.id.clone());
match by_repo.get(&key) {
Some(existing) if existing.created_at >= announcement.created_at => {}
_ => {
by_repo.insert(key, announcement);
}
}
}
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
this.announcements = announcements;
cx.notify();
if this.refresh_dirty {
this.refresh_dirty = false;
true
} else {
this.refreshing = false;
false
}
})?;
if !again {
break;
}
}
Ok(())
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
// Last activity per repository: state updates plus all NIP-34
// activity events (patches, PRs, issues, statuses).
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
.iter()
.map(|a| (a.addr(), a.created_at))
.collect();
let state_filter = Filter::new().kind(Kind::RepoState);
for event in client.database().query(state_filter).await? {
if deletions.is_deleted(&event) {
continue;
}
let Some(id) = event.tags.identifier() else {
continue;
};
let addr = repo_addr(event.pubkey, id);
let Some(entry) = last_activity.get_mut(&addr) else {
continue;
};
*entry = (*entry).max(event.created_at);
}
// Bound the activity query to a recent window; older repos fall
// back to their announcement / state timestamps.
let activity_filter = Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.since(Timestamp::now() - ACTIVITY_WINDOW);
for event in client.database().query(activity_filter).await? {
if deletions.is_deleted(&event) {
continue;
}
for addr in event.tags.coordinates() {
if addr.kind != Kind::GitRepoAnnouncement {
continue;
}
// Skip events for repos we don't list, so the map can't
// grow beyond the number of announcements.
let Some(entry) = last_activity.get_mut(&addr) else {
continue;
};
*entry = (*entry).max(event.created_at);
}
}
Ok::<_, Error>((announcements, last_activity))
});
self.tasks.push(task);
self.tasks.push(cx.spawn(async move |this, cx| {
let (announcements, last_activity) = match work.await {
Ok(results) => results,
// Database errors are transient; keep the last list.
Err(_) => {
return this.update(cx, |this, _cx| {
this.refreshing = false;
});
}
};
let again = this.update(cx, |this, cx| {
this.announcements = Arc::new(announcements);
this.last_activity = Arc::new(last_activity);
cx.notify();
this.refreshing = false;
if this.refresh_dirty {
this.refresh_dirty = false;
true
} else {
false
}
})?;
// Requests that arrived while the refresh was running are
// coalesced into one follow-up refresh.
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
Ok(())
}));
}
}