feat: implement a basic backend (#1)

Reviewed-on: https://git.reya.su/reya/signed/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-05 00:57:52 +00:00
parent 9809b986af
commit 7249a323f8
22 changed files with 4033 additions and 21 deletions
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "signed_state"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
signed_core = { path = "../signed_core" }
signed_nostr = { path = "../signed_nostr" }
nostr.workspace = true
nostr-sdk.workspace = true
nostr-connect.workspace = true
gpui.workspace = true
flume.workspace = true
anyhow.workspace = true
log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23"
+504
View File
@@ -0,0 +1,504 @@
use std::time::Duration;
use anyhow::{Error, anyhow};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use signed_core::filters;
use signed_nostr::{NostrBackend, 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";
/// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
/// Relays connected at startup, before any user-specific relay config is known.
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://index.ngit.dev",
"wss://profiles.nostr1.com",
];
/// Relays used for indexing user's relay list (NIP-65).
pub const INDEXER_RELAYS: [&str; 3] = [
"wss://indexer.coracle.social",
"wss://purplepag.es",
"wss://user.kindpag.es",
];
#[derive(Debug, Clone)]
pub enum BackendEvent {
/// User has no signer configured.
SignerRequired,
/// 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),
/// An event built locally was signed, broadcast and stored.
Published(Box<Event>),
/// An error occurred.
Error(String),
}
impl BackendEvent {
pub fn error<T>(error: T) -> Self
where
T: Into<String>,
{
Self::Error(error.into())
}
}
/// Global backend entity: owns the nostr client, the signer and the
/// notification pump. Stores subscribe to [`BackendEvent`] and re-query the
/// local database when relevant updates arrive.
pub struct Backend {
inner: NostrBackend,
current_user: Option<PublicKey>,
tasks: Vec<Task<Result<(), Error>>>,
}
struct GlobalBackend(Entity<Backend>);
impl Global for GlobalBackend {}
impl EventEmitter<BackendEvent> for Backend {}
impl Backend {
/// Retrieve the global backend.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalBackend>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalBackend(entity));
}
pub(crate) fn new(inner: NostrBackend, cx: &mut Context<Self>) -> Self {
let client = inner.client();
let pump = cx.spawn(async move |this, cx| {
let mut notifications = client.notifications();
while let Some(notification) = notifications.next().await {
let ClientNotification::Event { event, .. } = notification else {
continue;
};
let update = Update::from_event(&event);
if this
.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update)))
.is_err()
{
break;
}
}
Ok(())
});
let mut this = Self {
inner,
current_user: None,
tasks: vec![pump],
};
this.bootstrap(cx);
this
}
/// 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 task = cx.background_spawn(async move {
for url in BOOTSTRAP_RELAYS {
backend.add_relay(url).await?;
}
for url in INDEXER_RELAYS {
backend.add_discovery_relay(url).await?;
}
backend.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))?;
}
Err(e) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
}
Ok(())
}));
self.restore_session(cx);
}
/// Restore the saved session from the keyring. Emits
/// [`BackendEvent::SignerRequired`] if no credential is stored.
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
cx.emit(BackendEvent::SignerRequired);
return;
}
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 {
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
_ => {
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
return Ok(());
}
};
let result = async {
if content.starts_with("nsec1") {
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 mut signer = NostrConnect::new(
uri,
master.await,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
signer.auth_url_handler(SignedAuthUrlHandler);
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
} else {
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
}
Ok::<_, Error>(())
}
.await;
if let Err(e) = result {
this.update(cx, |_, cx| {
cx.emit(BackendEvent::error(e.to_string()));
cx.emit(BackendEvent::SignerRequired);
})?;
}
Ok(())
}));
}
/// 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>) {
let nsec = nsec.trim().to_owned();
let keys = match SecretKey::parse(&nsec) {
Ok(secret) => Keys::new(secret),
Err(e) => {
cx.emit(BackendEvent::error(e.to_string()));
return;
}
};
let write =
cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes());
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = write.await {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
return Ok(());
}
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
Ok(())
}));
}
/// 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.
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
let uri_string = uri.trim().to_owned();
let connect_uri = match NostrConnectUri::parse(&uri_string) {
Ok(uri) => uri,
Err(e) => {
cx.emit(BackendEvent::error(e.to_string()));
return;
}
};
let master = self.master_key(cx);
let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes());
self.tasks.push(cx.spawn(async move |this, cx| {
let result = async {
let mut signer = NostrConnect::new(
connect_uri,
master.await,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
signer.auth_url_handler(SignedAuthUrlHandler);
// Verify the signer before persisting the credential.
signer.get_public_key_async().await?;
write.await?;
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
Ok::<_, Error>(())
}
.await;
if let Err(e) = result {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
Ok(())
}));
}
/// Remove the saved credential and reset to an anonymous session.
pub fn logout(&mut self, cx: &mut Context<Self>) {
let delete = cx.delete_credentials(USER_KEYRING);
self.tasks.push(cx.spawn(async move |this, cx| {
delete.await.ok();
this.update(cx, |this, cx| {
this.inner.signer().swap_inner(Keys::generate());
this.current_user = None;
cx.emit(BackendEvent::SignerChanged);
cx.emit(BackendEvent::SignerRequired);
cx.notify();
})?;
Ok(())
}));
}
/// 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();
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 urls: Vec<String> = events
.into_iter()
.max_by_key(|e| e.created_at)
.map(|e| {
e.tags
.iter()
.filter(|t| t.kind() == "g")
.filter_map(|t| t.content().map(str::to_owned))
.collect()
})
.unwrap_or_default();
for url in urls {
backend.add_relay(&url).await.ok();
}
backend.connect().await;
Ok::<_, Error>(())
}
.await;
if let Err(e) = result {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
Ok(())
}));
}
/// Get the nostr client.
pub fn client(&self) -> Client {
self.inner.client()
}
/// Get the current signer.
pub fn signer(&self) -> UniversalSigner {
self.inner.signer()
}
/// Get the current user's public key.
pub fn current_user(&self) -> Option<PublicKey> {
self.current_user
}
/// 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>)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
{
let task = cx.spawn(async move |this, cx| {
match new_signer.get_public_key_async().await {
Ok(public_key) => {
this.update(cx, |this, cx| {
this.inner.signer().swap_inner(new_signer);
this.current_user = Some(public_key);
this.bootstrap_user(public_key, cx);
cx.emit(BackendEvent::SignerChanged);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
})?;
}
}
Ok(())
});
self.tasks.push(task);
}
/// 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 task = cx.background_spawn(async move {
for url in urls {
backend.add_relay(&url).await?;
}
backend.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))?;
}
Err(e) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
}
Ok(())
}));
}
/// 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 task = cx.background_spawn(async move {
for url in urls {
backend.add_discovery_relay(&url).await?;
}
backend.connect().await;
Ok::<(), Error>(())
});
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(())
}));
}
/// 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 task = cx.background_spawn(async move { backend.subscribe(filter).await.map(|_| ()) });
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(())
}));
}
/// 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`].
pub fn send(
&mut self,
builder: EventBuilder,
cx: &mut Context<Self>,
) -> flume::Receiver<Result<Event, Error>> {
let (tx, rx) = flume::bounded(1);
let backend = self.inner.clone();
let task = cx.background_spawn(async move { backend.send(builder).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await;
match &result {
Ok(event) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(event.clone())));
})?;
}
Err(e) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
}
tx.send_async(result)
.await
.map_err(|_| anyhow!("action result receiver dropped"))
}));
rx
}
}
+50
View File
@@ -0,0 +1,50 @@
mod backend;
mod profile;
mod repo;
mod repo_list;
use std::path::Path;
pub use backend::{Backend, BackendEvent};
use gpui::{App, AppContext, Entity};
pub use profile::{Profile, ProfileStore, shorten_pubkey};
pub use repo::RepoStore;
pub use repo_list::RepoListStore;
use signed_nostr::NostrBackend;
/// Initialize the backend and stores, and install them as globals. Call once
/// at startup, before opening any window that uses the stores.
#[cfg(not(target_arch = "wasm32"))]
pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok();
let path = db_path.as_ref().to_path_buf();
let inner = cx.foreground_executor().block_on(async move {
NostrBackend::new(path)
.await
.expect("failed to initialize nostr backend")
});
let entity = cx.new(|cx| Backend::new(inner, cx));
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::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 entity = cx.new(|cx| Backend::new(inner, cx));
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
entity
}
+225
View File
@@ -0,0 +1,225 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use anyhow::Error;
use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task};
use nostr_sdk::prelude::*;
use crate::backend::{Backend, BackendEvent};
/// A user profile (kind `0` metadata), as plain data for the UI.
#[derive(Debug, Clone)]
pub struct Profile {
public_key: PublicKey,
metadata: Metadata,
}
impl Profile {
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
Self {
public_key,
metadata,
}
}
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
/// Display name, falling back to `name`, then a shortened npub.
pub fn name(&self) -> SharedString {
if let Some(display_name) = self.metadata.display_name.as_ref()
&& !display_name.is_empty()
{
return SharedString::from(display_name.trim().to_owned());
}
if let Some(name) = self.metadata.name.as_ref()
&& !name.is_empty()
{
return SharedString::from(name.trim().to_owned());
}
SharedString::from(shorten_pubkey(self.public_key, 4))
}
/// Avatar URL, if set.
pub fn picture(&self) -> Option<SharedString> {
self.metadata
.picture
.as_ref()
.filter(|p| !p.is_empty())
.map(|p| SharedString::from(p.clone()))
}
}
/// 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..])
}
/// 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,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
struct GlobalProfileStore(Entity<ProfileStore>);
impl Global for GlobalProfileStore {}
impl ProfileStore {
/// Retrieve the global profile store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalProfileStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalProfileStore(entity));
}
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
BackendEvent::NostrUpdate(update) if update.kind == Kind::Metadata => {
this.apply_author(update.author, cx);
}
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
this.profiles
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
cx.notify();
}
_ => {}
});
let mut store = Self {
profiles: HashMap::new(),
seen: HashSet::new(),
queued: HashSet::new(),
fetching: false,
tasks: Vec::new(),
_subscription: subscription,
};
store.load(cx);
store
}
/// 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) {
return profile.clone();
}
if self.seen.insert(public_key) {
self.queued.insert(public_key);
self.queue_fetch(cx);
}
Profile::new(public_key, Metadata::default())
}
/// Load recently seen profiles from the local database.
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 filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
this.update(cx, |this, cx| {
for event in events {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
this.profiles
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
}
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 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();
this.update(cx, |this, cx| {
this.profiles
.insert(public_key, Profile::new(public_key, metadata));
cx.notify();
})?;
}
Ok(())
});
self.tasks.push(task);
}
/// Drain the queue in a batched fetch, debounced to collect requests.
fn queue_fetch(&mut self, cx: &mut Context<Self>) {
if self.fetching {
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 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}");
}
}
Ok(())
});
self.tasks.push(task);
}
}
+294
View File
@@ -0,0 +1,294 @@
use anyhow::Error;
use gpui::{Context, Subscription, Task};
use nostr_sdk::prelude::*;
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
use crate::backend::{Backend, BackendEvent};
/// Per-repository store: announcement, state, issues, patches, PRs and
/// their resolved statuses. Always derived from the local database.
pub struct RepoStore {
addr: RepoAddr,
addr_string: String,
pub announcement: Option<Announcement>,
/// `(refname, commit-id)` pairs from the latest state announcement.
pub refs: Vec<(String, String)>,
/// Branch pointed to by `HEAD` in the latest state announcement.
pub head: Option<String>,
pub issues: Vec<Event>,
pub patches: Vec<Event>,
pub pull_requests: Vec<Event>,
statuses: Vec<Event>,
/// Error of the last action initiated from this store, if any.
pub last_error: Option<String>,
refreshing: bool,
refresh_dirty: bool,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
impl RepoStore {
pub fn new(addr: RepoAddr, cx: &mut Context<Self>) -> Self {
let addr_string = addr.to_string();
let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(update) => {
update.coordinate.as_deref() == Some(this.addr_string.as_str())
|| (update.kind == Kind::GitRepoAnnouncement
&& update.author == this.addr.owner)
}
BackendEvent::Published(event) => {
event.kind == Kind::GitRepoAnnouncement && event.pubkey == this.addr.owner
|| event.tags.iter().any(|t| {
t.kind() == "a" && t.content() == Some(this.addr_string.as_str())
})
}
_ => false,
};
if relevant {
this.refresh(cx);
}
});
let mut store = Self {
addr,
addr_string,
announcement: None,
refs: Vec::new(),
head: None,
issues: Vec::new(),
patches: Vec::new(),
pull_requests: Vec::new(),
statuses: Vec::new(),
last_error: None,
refreshing: false,
refresh_dirty: false,
_subscription: subscription,
tasks: Vec::new(),
};
store.subscribe_remote(cx);
store.refresh(cx);
store
}
pub fn addr(&self) -> &RepoAddr {
&self.addr
}
/// Subscribe the relay pool to this repository's activity.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let addr = self.addr.clone();
Backend::global(cx).update(cx, |backend, cx| {
backend.subscribe(filters::announcement(&addr), cx);
backend.subscribe(filters::state(&addr), cx);
backend.subscribe(filters::activity(&addr), cx);
});
}
/// Re-query the local database and update all fields.
///
/// Debounced: concurrent requests are coalesced into a single re-query
/// after the running one finishes.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing {
self.refresh_dirty = true;
return;
}
self.refreshing = true;
let client = Backend::global(cx).read(cx).client();
let addr = self.addr.clone();
let task = cx.spawn(async move |this, cx| {
loop {
let queries = async {
let db = client.database();
let announcements = db.query(filters::announcement(&addr)).await?;
let states = db.query(filters::state(&addr)).await?;
let activity = db.query(filters::activity(&addr)).await?;
Ok::<_, Error>((announcements, states, activity))
}
.await;
let (announcements, states, activity) = match queries {
Ok(results) => results,
Err(e) => {
return this.update(cx, |this, cx| {
this.refreshing = false;
this.last_error = Some(e.to_string());
cx.notify();
});
}
};
let again = this.update(cx, |this, cx| {
this.announcement = latest(announcements)
.as_ref()
.and_then(Announcement::from_event);
if let Some(state) = latest(states) {
let (refs, head) = parse_state(&state);
this.refs = refs;
this.head = head;
}
this.issues.clear();
this.patches.clear();
this.pull_requests.clear();
this.statuses.clear();
for event in activity {
match event.kind {
Kind::GitIssue => this.issues.push(event),
Kind::GitPatch => this.patches.push(event),
Kind::GitPullRequest | Kind::GitPullRequestUpdate => {
this.pull_requests.push(event)
}
kind if RepoStatus::from_kind(kind).is_some() => {
this.statuses.push(event)
}
_ => {}
}
}
sort_newest_first(&mut this.issues);
sort_newest_first(&mut this.patches);
sort_newest_first(&mut this.pull_requests);
cx.notify();
if this.refresh_dirty {
this.refresh_dirty = false;
true
} else {
this.refreshing = false;
false
}
})?;
if !again {
break;
}
}
Ok(())
});
self.tasks.push(task);
}
/// Resolve the status of a root event (issue / patch / PR) per NIP-34.
pub fn status_of(&self, root: &Event) -> RepoStatus {
let maintainers = self
.announcement
.as_ref()
.map(|a| a.maintainers.as_slice())
.unwrap_or(&[]);
let events = self
.statuses
.iter()
.filter(|e| signed_core::references_root(e, &root.id));
signed_core::resolve_status(events, &root.pubkey, maintainers)
}
/// Open an issue on this repository.
pub fn open_issue(&mut self, subject: Option<String>, content: String, cx: &mut Context<Self>) {
let builder = GitIssue {
repository: self.addr.coordinate(),
content,
subject,
labels: Vec::new(),
}
.into_event_builder();
self.send(builder, cx);
}
/// Send a root patch (`git format-patch` output) to this repository.
pub fn send_root_patch(&mut self, patch: String, cx: &mut Context<Self>) {
let Ok(root_marker) = Tag::parse(["t", "root"]) else {
return;
};
let builder = EventBuilder::new(Kind::GitPatch, patch).tags([
Tag::coordinate(self.addr.coordinate(), None),
Tag::public_key(self.addr.owner),
root_marker,
]);
self.send(builder, cx);
}
/// Set the status of a root event (requires being the root author or a maintainer).
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
let Ok(root_ref) = Tag::parse(["e", &root.id.to_hex(), "", "root"]) else {
return;
};
let builder = EventBuilder::new(status.kind(), "").tags([
root_ref,
Tag::public_key(self.addr.owner),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.coordinate(), None),
]);
self.send(builder, cx);
}
fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
self.last_error = None;
let rx = Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx));
let task = cx.spawn(async move |this, cx| {
if let Ok(Err(e)) = rx.recv_async().await {
this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
})?;
}
Ok(())
});
self.tasks.push(task);
}
}
fn latest(events: Events) -> Option<Event> {
events.into_iter().max_by_key(|e| e.created_at)
}
fn sort_newest_first(events: &mut [Event]) {
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
}
/// Parse a kind `30618` state event into refs and HEAD.
fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
let mut refs = Vec::new();
let mut head = None;
for tag in event.tags.iter() {
let kind = tag.kind();
if kind == "HEAD" {
head = tag
.content()
.and_then(|v| v.strip_prefix("ref: refs/heads/"))
.map(str::to_owned);
} else if kind.starts_with("refs/")
&& let Some(commit) = tag.content()
{
refs.push((kind.to_owned(), commit.to_owned()));
}
}
(refs, head)
}
+147
View File
@@ -0,0 +1,147 @@
use std::collections::HashMap;
use anyhow::Error;
use gpui::{Context, Subscription, Task};
use nostr_sdk::prelude::*;
use signed_core::{Announcement, filters};
use crate::backend::{Backend, BackendEvent};
/// Store listing repository announcements (global discovery or per-author).
pub struct RepoListStore {
pub announcements: Vec<Announcement>,
author: Option<PublicKey>,
refreshing: bool,
refresh_dirty: bool,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
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 relevant = match event {
BackendEvent::NostrUpdate(update) => {
update.kind == Kind::GitRepoAnnouncement
&& 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)
}
_ => false,
};
if relevant {
this.refresh(cx);
}
});
let mut store = Self {
announcements: Vec::new(),
author,
refreshing: false,
refresh_dirty: false,
_subscription: subscription,
tasks: Vec::new(),
};
store.subscribe_remote(cx);
store.refresh(cx);
store
}
/// Scope the list to an author (or clear the scope with `None`).
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
self.author = author;
self.subscribe_remote(cx);
self.refresh(cx);
}
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let author = self.author;
Backend::global(cx).update(cx, |backend, cx| {
let filter = match author {
Some(a) => filters::announcements_by(a),
None => filters::all_announcements(500),
};
backend.subscribe(filter, 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.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing {
self.refresh_dirty = true;
return;
}
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 events = match client.database().query(filter).await {
Ok(events) => events,
Err(_) => {
return this.update(cx, |this, _cx| {
this.refreshing = false;
});
}
};
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(())
});
self.tasks.push(task);
}
}