refacotr
This commit is contained in:
Generated
+1
@@ -8048,6 +8048,7 @@ dependencies = [
|
|||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"serde_json",
|
||||||
"settings",
|
"settings",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
"signed_git",
|
"signed_git",
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ impl InboxItem {
|
|||||||
pub fn is_unread(&self) -> bool {
|
pub fn is_unread(&self) -> bool {
|
||||||
!self.archived && !self.unread_ids.is_empty()
|
!self.archived && !self.unread_ids.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recompute the unread and archived flags from `state`.
|
||||||
|
pub fn apply_state(&mut self, state: &InboxReadState) {
|
||||||
|
self.unread_ids = self
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter(|event| !state.is_read(event))
|
||||||
|
.map(|event| event.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.archived = self.events.iter().all(|event| state.is_archived(event));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Root issue, patch or pull request of a notification event.
|
/// Root issue, patch or pull request of a notification event.
|
||||||
@@ -112,25 +125,18 @@ where
|
|||||||
|
|
||||||
let root_event = lookup(root);
|
let root_event = lookup(root);
|
||||||
|
|
||||||
let unread_ids = events
|
let mut item = InboxItem {
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.filter(|event| !state.is_read(event))
|
|
||||||
.map(|event| event.id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let archived = events.iter().all(|event| state.is_archived(event));
|
|
||||||
|
|
||||||
InboxItem {
|
|
||||||
root,
|
root,
|
||||||
root_kind: root_event.as_ref().map(|event| event.kind),
|
root_kind: root_event.as_ref().map(|event| event.kind),
|
||||||
address: root_event
|
address: root_event
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|event| event.tags.coordinates().next()),
|
.and_then(|event| event.tags.coordinates().next()),
|
||||||
events,
|
events,
|
||||||
unread_ids,
|
unread_ids: Vec::new(),
|
||||||
archived,
|
archived: false,
|
||||||
}
|
};
|
||||||
|
item.apply_state(state);
|
||||||
|
item
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -174,6 +180,13 @@ impl InboxReadState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark one event archived. Events at or before the cutoff are already archived.
|
||||||
|
pub fn mark_archived(&mut self, event: &Event) {
|
||||||
|
if event.created_at > self.archived_before {
|
||||||
|
self.archived_ids.insert(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark every non-self event read, anchoring the cutoff ten days back.
|
/// Mark every non-self event read, anchoring the cutoff ten days back.
|
||||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||||
let cutoff = now - MARK_ALL_WINDOW;
|
let cutoff = now - MARK_ALL_WINDOW;
|
||||||
@@ -626,6 +639,47 @@ mod tests {
|
|||||||
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
|
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mark_archived_skips_events_at_or_before_the_cutoff() {
|
||||||
|
let now = Timestamp::from_secs(1_000_000_000);
|
||||||
|
let event = issue(&keys(2), now.as_secs() - 1000);
|
||||||
|
|
||||||
|
let mut state = InboxReadState {
|
||||||
|
archived_before: now,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.mark_archived(&event);
|
||||||
|
assert!(state.archived_ids.is_empty());
|
||||||
|
|
||||||
|
let mut state = InboxReadState::default();
|
||||||
|
state.mark_archived(&event);
|
||||||
|
assert_eq!(state.archived_ids, HashSet::from([event.id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_state_recomputes_unread_and_archived() {
|
||||||
|
let now = Timestamp::from_secs(1_000_000_000);
|
||||||
|
let first = issue(&keys(2), now.as_secs() - 2000);
|
||||||
|
let second = issue(&keys(2), now.as_secs() - 1000);
|
||||||
|
let mut item = InboxItem {
|
||||||
|
root: first.id,
|
||||||
|
root_kind: None,
|
||||||
|
address: None,
|
||||||
|
events: vec![second.clone(), first.clone()],
|
||||||
|
unread_ids: Vec::new(),
|
||||||
|
archived: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = InboxReadState {
|
||||||
|
read_before: first.created_at,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
item.apply_state(&state);
|
||||||
|
|
||||||
|
assert_eq!(item.unread_ids, vec![second.id]);
|
||||||
|
assert!(!item.archived);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serde_round_trip_preserves_state() {
|
fn serde_round_trip_preserves_state() {
|
||||||
let first = issue(&keys(1), 100);
|
let first = issue(&keys(1), 100);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ flume.workspace = true
|
|||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_
|
|||||||
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||||
|
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::GitStore;
|
||||||
|
use crate::inbox::Inbox;
|
||||||
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
/// Keyring entry for the user credential.
|
/// Keyring entry for the user credential.
|
||||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
@@ -79,19 +81,18 @@ impl BackendEvent {
|
|||||||
|
|
||||||
/// The global backend entity.
|
/// The global backend entity.
|
||||||
///
|
///
|
||||||
/// Owns the nostr client, the signer and the notification pump.
|
/// Owns the nostr client, the signer, the notification pump and the inbox.
|
||||||
pub struct Backend {
|
pub struct Backend {
|
||||||
client: Client,
|
client: Client,
|
||||||
signer: UniversalSigner,
|
signer: UniversalSigner,
|
||||||
current_user: Option<PublicKey>,
|
current_user: Option<PublicKey>,
|
||||||
|
/// User's inbox, including notifications and recent activity.
|
||||||
|
inbox: Entity<Inbox>,
|
||||||
|
/// The progress of the current sync operation, if any.
|
||||||
sync_progress: Option<(u64, u64)>,
|
sync_progress: Option<(u64, u64)>,
|
||||||
/// True when the stored credential is NIP-49 encrypted.
|
/// True when the stored credential is NIP-49 encrypted.
|
||||||
passphrase_required: bool,
|
passphrase_required: bool,
|
||||||
/// Repositories with a push in flight, mirror or checkout based.
|
/// Repositories with a push in flight, mirror or checkout based.
|
||||||
///
|
|
||||||
/// A child entity: views that only care whether one repository is
|
|
||||||
/// pushing can `cx.observe` it without being invoked on unrelated
|
|
||||||
/// `Backend` changes (a `sync_progress` tick, a new relay connecting).
|
|
||||||
pushing_repos: Entity<HashSet<RepoAddr>>,
|
pushing_repos: Entity<HashSet<RepoAddr>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
let pump_client = client.clone();
|
let pump_client = client.clone();
|
||||||
|
|
||||||
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
@@ -133,13 +135,17 @@ impl Backend {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
if now >= deadline {
|
if now >= deadline {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let next = notifications.next();
|
let next = notifications.next();
|
||||||
futures::pin_mut!(next);
|
futures::pin_mut!(next);
|
||||||
|
|
||||||
match futures::future::select(next, timer).await {
|
match futures::future::select(next, timer).await {
|
||||||
futures::future::Either::Left((
|
futures::future::Either::Left((
|
||||||
Some(ClientNotification::Event { event, .. }),
|
Some(ClientNotification::Event { event, .. }),
|
||||||
@@ -156,7 +162,9 @@ impl Backend {
|
|||||||
// Collect and emit the collected events.
|
// Collect and emit the collected events.
|
||||||
let batch = std::mem::take(&mut pending);
|
let batch = std::mem::take(&mut pending);
|
||||||
|
|
||||||
if let Err(e) = this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch))) {
|
if let Err(e) = this.update(cx, |this, cx| {
|
||||||
|
this.emit(BackendEvent::NostrUpdate(batch), cx)
|
||||||
|
}) {
|
||||||
log::warn!("failed to emit nostr update: {e}");
|
log::warn!("failed to emit nostr update: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +175,6 @@ impl Backend {
|
|||||||
pump.detach();
|
pump.detach();
|
||||||
|
|
||||||
// Bootstrap the client.
|
// Bootstrap the client.
|
||||||
let weak = cx.entity().downgrade();
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
||||||
log::warn!("backend dropped before bootstrap could run: {error}");
|
log::warn!("backend dropped before bootstrap could run: {error}");
|
||||||
@@ -178,46 +185,50 @@ impl Backend {
|
|||||||
client,
|
client,
|
||||||
signer,
|
signer,
|
||||||
current_user: None,
|
current_user: None,
|
||||||
|
inbox: cx.new(|_| Inbox::default()),
|
||||||
sync_progress: None,
|
sync_progress: None,
|
||||||
passphrase_required: false,
|
passphrase_required: false,
|
||||||
pushing_repos: cx.new(|_| HashSet::new()),
|
pushing_repos: cx.new(|_| HashSet::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bootstrap the client.
|
/// Bootstrap the client and restore the saved session, if any.
|
||||||
///
|
|
||||||
/// Restore the saved session, if any.
|
|
||||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
let task = cx.background_spawn(async move {
|
let task = cx.background_spawn(async move {
|
||||||
for url in BOOTSTRAP_RELAYS {
|
for url in BOOTSTRAP_RELAYS {
|
||||||
client.add_relay(url).and_connect().await?;
|
client.add_relay(url).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for url in INDEXER_RELAYS {
|
for url in INDEXER_RELAYS {
|
||||||
client
|
client
|
||||||
.add_relay(url)
|
.add_relay(url)
|
||||||
.capabilities(RelayCapabilities::DISCOVERY)
|
.capabilities(RelayCapabilities::DISCOVERY)
|
||||||
.and_connect()
|
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.connect().await;
|
||||||
|
|
||||||
Ok::<(), Error>(())
|
Ok::<(), Error>(())
|
||||||
});
|
});
|
||||||
|
|
||||||
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
this.update(cx, |_this, cx| cx.notify())?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.restore_session(cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.emit(BackendEvent::error(e.to_string()), cx)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok::<(), Error>(())
|
||||||
});
|
});
|
||||||
notify_task.detach();
|
notify_task.detach();
|
||||||
|
|
||||||
self.restore_session(cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the saved session from the keyring.
|
/// Restore the saved session from the keyring.
|
||||||
@@ -227,7 +238,7 @@ impl Backend {
|
|||||||
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
||||||
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
cx.emit(BackendEvent::SignerRequired);
|
self.emit(BackendEvent::SignerRequired, cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +248,7 @@ impl Backend {
|
|||||||
let content = match user.await {
|
let content = match user.await {
|
||||||
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
||||||
_ => {
|
_ => {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -258,15 +269,13 @@ impl Backend {
|
|||||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||||
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||||
} else if content.starts_with("ncryptsec1") {
|
} else if content.starts_with("ncryptsec1") {
|
||||||
// Encrypted identity.
|
|
||||||
// A passphrase is required to decrypt it before the session can resume.
|
// 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.update(cx, |this, cx| {
|
||||||
this.passphrase_required = true;
|
this.passphrase_required = true;
|
||||||
cx.emit(BackendEvent::PassphraseRequired);
|
this.emit(BackendEvent::PassphraseRequired, cx);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok::<_, Error>(())
|
||||||
@@ -274,9 +283,9 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |_, cx| {
|
this.update(cx, |this, cx| {
|
||||||
cx.emit(BackendEvent::error(e.to_string()));
|
this.emit(BackendEvent::error(e.to_string()), cx);
|
||||||
cx.emit(BackendEvent::SignerRequired);
|
this.emit(BackendEvent::SignerRequired, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +369,8 @@ impl Backend {
|
|||||||
this.signer.swap_inner(keys);
|
this.signer.swap_inner(keys);
|
||||||
this.current_user = Some(public_key);
|
this.current_user = Some(public_key);
|
||||||
this.bootstrap_user(public_key, cx);
|
this.bootstrap_user(public_key, cx);
|
||||||
cx.emit(BackendEvent::SignerChanged);
|
this.emit(BackendEvent::SignerChanged, cx);
|
||||||
|
this.sync_inbox(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
||||||
@@ -968,9 +978,7 @@ impl Backend {
|
|||||||
} else if credential.starts_with("bunker://") {
|
} else if credential.starts_with("bunker://") {
|
||||||
self.login_with_bunker(credential, cx);
|
self.login_with_bunker(credential, cx);
|
||||||
} else {
|
} else {
|
||||||
cx.emit(BackendEvent::error(
|
self.emit(BackendEvent::error("Unsupported credential."), cx);
|
||||||
"Unsupported credential, expected nsec1... or bunker://...",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,7 +996,7 @@ impl Backend {
|
|||||||
let keys = match SecretKey::parse(nsec) {
|
let keys = match SecretKey::parse(nsec) {
|
||||||
Ok(secret) => Keys::new(secret),
|
Ok(secret) => Keys::new(secret),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.emit(BackendEvent::error(e.to_string()));
|
self.emit(BackendEvent::error(e.to_string()), cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -999,7 +1007,9 @@ impl Backend {
|
|||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = write.await {
|
if let Err(e) = write.await {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.emit(BackendEvent::error(e.to_string()), cx)
|
||||||
|
})?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
||||||
@@ -1015,7 +1025,7 @@ impl Backend {
|
|||||||
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
||||||
Ok(uri) => uri,
|
Ok(uri) => uri,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.emit(BackendEvent::error(e.to_string()));
|
self.emit(BackendEvent::error(e.to_string()), cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1045,7 +1055,9 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.emit(BackendEvent::error(e.to_string()), cx)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1064,8 +1076,9 @@ impl Backend {
|
|||||||
this.signer.swap_inner(Keys::generate());
|
this.signer.swap_inner(Keys::generate());
|
||||||
this.current_user = None;
|
this.current_user = None;
|
||||||
this.passphrase_required = false;
|
this.passphrase_required = false;
|
||||||
cx.emit(BackendEvent::SignerChanged);
|
this.emit(BackendEvent::SignerChanged, cx);
|
||||||
cx.emit(BackendEvent::SignerRequired);
|
this.emit(BackendEvent::SignerRequired, cx);
|
||||||
|
this.sync_inbox(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -1096,7 +1109,9 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.emit(BackendEvent::error(e.to_string()), cx)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1121,6 +1136,13 @@ impl Backend {
|
|||||||
self.pushing_repos.clone()
|
self.pushing_repos.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inbox child entity backing the home screen.
|
||||||
|
///
|
||||||
|
/// A child entity: `cx.observe` it to react only to inbox changes.
|
||||||
|
pub fn inbox(&self) -> Entity<Inbox> {
|
||||||
|
self.inbox.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the current user's public key.
|
/// Get the current user's public key.
|
||||||
pub fn current_user(&self) -> Option<PublicKey> {
|
pub fn current_user(&self) -> Option<PublicKey> {
|
||||||
self.current_user
|
self.current_user
|
||||||
@@ -1133,7 +1155,63 @@ impl Backend {
|
|||||||
|
|
||||||
/// Surface an error message through [`BackendEvent::Error`].
|
/// Surface an error message through [`BackendEvent::Error`].
|
||||||
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
||||||
cx.emit(BackendEvent::error(message));
|
self.emit(BackendEvent::error(message), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the inbox, then emit `event` to the other stores.
|
||||||
|
fn emit(&self, event: BackendEvent, cx: &mut Context<Self>) {
|
||||||
|
let inbox = self.inbox.downgrade();
|
||||||
|
let inbox_event = event.clone();
|
||||||
|
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
if let Err(error) = inbox.update(cx, |inbox, cx| {
|
||||||
|
inbox.handle_backend_event(&inbox_event, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("inbox dropped before handling backend event: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach the inbox to the current signer and activate or clear it.
|
||||||
|
///
|
||||||
|
/// The inbox's own update is deferred because activating reads `Backend`,
|
||||||
|
/// which every call site is in the middle of updating.
|
||||||
|
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if let Some(me) = self.current_user {
|
||||||
|
self.subscribe_bootstrap(filters::notifications(me), cx);
|
||||||
|
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||||
|
|
||||||
|
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.announcements_of(&me)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|announcement| announcement.relays)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !relays.is_empty() {
|
||||||
|
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
||||||
|
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
||||||
|
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let inbox = self.inbox.downgrade();
|
||||||
|
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
let updated = inbox.update(cx, |inbox, cx| {
|
||||||
|
if Backend::global(cx).read(cx).current_user().is_some() {
|
||||||
|
inbox.activate(cx);
|
||||||
|
} else {
|
||||||
|
inbox.reset(cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(error) = updated {
|
||||||
|
log::warn!("inbox dropped before syncing with the signer: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Progress of the in-flight negentropy sync, if any.
|
/// Progress of the in-flight negentropy sync, if any.
|
||||||
@@ -1149,7 +1227,7 @@ impl Backend {
|
|||||||
<T as AsyncSignEvent>::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,
|
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
match new_signer.get_public_key_async().await {
|
match new_signer.get_public_key_async().await {
|
||||||
Ok(public_key) => {
|
Ok(public_key) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -1157,26 +1235,24 @@ impl Backend {
|
|||||||
this.current_user = Some(public_key);
|
this.current_user = Some(public_key);
|
||||||
this.passphrase_required = false;
|
this.passphrase_required = false;
|
||||||
this.bootstrap_user(public_key, cx);
|
this.bootstrap_user(public_key, cx);
|
||||||
cx.emit(BackendEvent::SignerChanged);
|
this.emit(BackendEvent::SignerChanged, cx);
|
||||||
|
this.sync_inbox(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |_this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
cx.emit(BackendEvent::error(e.to_string()));
|
this.emit(BackendEvent::error(e.to_string()), cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok::<(), Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
||||||
///
|
|
||||||
/// Callers are responsible for not repeating this for relays they already
|
|
||||||
/// connected, e.g. `RepoStore::repo_relays`.
|
|
||||||
pub fn connect_repo_relays(
|
pub fn connect_repo_relays(
|
||||||
&mut self,
|
&mut self,
|
||||||
relays: Vec<RelayUrl>,
|
relays: Vec<RelayUrl>,
|
||||||
@@ -1185,13 +1261,13 @@ impl Backend {
|
|||||||
) {
|
) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |_this, _cx| {
|
cx.spawn(async move |_this, _cx| {
|
||||||
if let Err(e) = connect_repo_relays(&client, relays, filters).await {
|
if let Err(e) = connect_repo_relays(&client, relays, filters).await {
|
||||||
log::warn!("repo relay fetch failed: {e}");
|
log::warn!("repo relay fetch failed: {e}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok::<(), Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot subscription on the bootstrap relays only.
|
/// One-shot subscription on the bootstrap relays only.
|
||||||
@@ -1201,24 +1277,25 @@ impl Backend {
|
|||||||
let fetch =
|
let fetch =
|
||||||
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
|
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = fetch.await {
|
if let Err(e) = fetch.await {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.emit(BackendEvent::error(e.to_string()), cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok::<(), Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Negentropy-sync the given filter against the bootstrap relays.
|
/// Negentropy-sync the given filter against the bootstrap relays.
|
||||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
let (tx, mut rx) = SyncProgress::channel();
|
||||||
|
|
||||||
self.sync_progress = Some((0, 0));
|
self.sync_progress = Some((0, 0));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let (tx, mut rx) = SyncProgress::channel();
|
|
||||||
|
|
||||||
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let mut last_percent: u64 = 0;
|
let mut last_percent: u64 = 0;
|
||||||
|
|
||||||
@@ -1231,10 +1308,13 @@ impl Backend {
|
|||||||
|
|
||||||
let alive = this.update(cx, |this, cx| {
|
let alive = this.update(cx, |this, cx| {
|
||||||
this.sync_progress = Some((progress.total, progress.current));
|
this.sync_progress = Some((progress.total, progress.current));
|
||||||
cx.emit(BackendEvent::SyncProgress {
|
this.emit(
|
||||||
|
BackendEvent::SyncProgress {
|
||||||
total: progress.total,
|
total: progress.total,
|
||||||
current: progress.current,
|
current: progress.current,
|
||||||
});
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1263,14 +1343,14 @@ impl Backend {
|
|||||||
);
|
);
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.sync_progress = None;
|
this.sync_progress = None;
|
||||||
cx.emit(BackendEvent::Synced);
|
this.emit(BackendEvent::Synced, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.sync_progress = None;
|
this.sync_progress = None;
|
||||||
cx.emit(BackendEvent::error(e.to_string()))
|
this.emit(BackendEvent::error(e.to_string()), cx)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1284,7 +1364,7 @@ impl Backend {
|
|||||||
/// Callers publish with `client.send_event(...)` directly, then call this
|
/// Callers publish with `client.send_event(...)` directly, then call this
|
||||||
/// so stores like `RepoListStore` refresh without re-querying the relays.
|
/// so stores like `RepoListStore` refresh without re-querying the relays.
|
||||||
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
|
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
|
||||||
cx.emit(BackendEvent::Published(Box::new(event)));
|
self.emit(BackendEvent::Published(Box::new(event)), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use gpui::{AppContext, Context, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
|
||||||
|
|
||||||
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
|
|
||||||
|
/// Delay between a refresh request and the actual re-query.
|
||||||
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
/// Maximum number of "continue where you left off" activity events kept.
|
||||||
|
const ACTIVITY_LIMIT: usize = 50;
|
||||||
|
|
||||||
|
/// State backing the inbox home screen.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Inbox {
|
||||||
|
/// Notifications grouped by thread root, newest activity first.
|
||||||
|
pub notifications: Arc<Vec<InboxItem>>,
|
||||||
|
/// The user's own recent git activity, newest first.
|
||||||
|
pub activity: Arc<Vec<Event>>,
|
||||||
|
/// Number of non-archived groups with an unread event.
|
||||||
|
pub unread_count: usize,
|
||||||
|
state: InboxReadState,
|
||||||
|
/// Set once the stored state has been read for the current user.
|
||||||
|
state_loaded: bool,
|
||||||
|
refresh: RefreshGate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inbox {
|
||||||
|
/// Mark every event in the group rooted at `root` as read.
|
||||||
|
pub fn mark_read(&mut self, root: EventId, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(events) = self.group_events(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for event in &events {
|
||||||
|
self.state.mark_read(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = self.all_notification_events();
|
||||||
|
self.state.advance_read(&all, me, Timestamp::now());
|
||||||
|
self.after_state_change(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Archive the group rooted at `root`. Archived events are always read too.
|
||||||
|
pub fn mark_archived(&mut self, root: EventId, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(events) = self.group_events(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for event in &events {
|
||||||
|
self.state.mark_archived(event);
|
||||||
|
self.state.mark_read(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = self.all_notification_events();
|
||||||
|
let now = Timestamp::now();
|
||||||
|
|
||||||
|
self.state.advance_archived(&all, me, now);
|
||||||
|
self.state.advance_read(&all, me, now);
|
||||||
|
self.after_state_change(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark every known notification read.
|
||||||
|
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let all = self.all_notification_events();
|
||||||
|
self.state.mark_all_read(&all, me, Timestamp::now());
|
||||||
|
self.after_state_change(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a backend event that can change the inbox contents.
|
||||||
|
pub(crate) fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||||
|
match event {
|
||||||
|
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
|
||||||
|
BackendEvent::NostrUpdate(updates) => {
|
||||||
|
let relevant = updates.iter().any(|update| {
|
||||||
|
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
|
||||||
|
let is_comment = update.kind == Kind::Comment;
|
||||||
|
let is_event_deletion = update.kind == Kind::EventDeletion;
|
||||||
|
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
|
is_notification || is_comment || is_event_deletion || is_request_to_vanish
|
||||||
|
});
|
||||||
|
if relevant {
|
||||||
|
self.refresh(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate the inbox for the backend's current user.
|
||||||
|
pub(crate) fn activate(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let Some(me) = backend.read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.notifications = Arc::new(Vec::new());
|
||||||
|
self.activity = Arc::new(Vec::new());
|
||||||
|
self.unread_count = 0;
|
||||||
|
self.state = InboxReadState::default();
|
||||||
|
self.state_loaded = false;
|
||||||
|
// Drop any in-flight or pending run belonging to the previous user.
|
||||||
|
self.refresh = RefreshGate::default();
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
self.load_state(me, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget everything for the current user.
|
||||||
|
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.notifications = Arc::new(Vec::new());
|
||||||
|
self.activity = Arc::new(Vec::new());
|
||||||
|
self.unread_count = 0;
|
||||||
|
self.state = InboxReadState::default();
|
||||||
|
self.state_loaded = false;
|
||||||
|
self.refresh = RefreshGate::default();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored state, then run the first refresh.
|
||||||
|
fn load_state(&mut self, me: PublicKey, cx: &mut Context<Self>) {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
|
let work = cx.background_spawn(async move { load_state(&client, me).await });
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let loaded = work.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if backend.read(cx).current_user() != Some(me) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match loaded {
|
||||||
|
Ok(Some(state)) => this.state = state,
|
||||||
|
Ok(None) => this.state = InboxReadState::default(),
|
||||||
|
Err(error) => log::warn!("failed to load inbox state: {error}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state_loaded = true;
|
||||||
|
this.refresh_initial(cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot initial load, no debounce.
|
||||||
|
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||||
|
debug_assert!(!self.refresh.debouncing());
|
||||||
|
if self.refresh.running() {
|
||||||
|
self.refresh.request();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.run_refresh(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-query the local database.
|
||||||
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.state_loaded {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.refresh.request() != RefreshRequest::Schedule {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||||
|
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One query and apply cycle, the debounced entry point.
|
||||||
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.refresh.begin();
|
||||||
|
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
self.refresh.abort();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
let work = cx.background_spawn(async move {
|
||||||
|
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||||
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
|
let (notification_events, by_id) = fetch_notifications(&client, me, &deletions).await?;
|
||||||
|
let notifications = inbox::group(notification_events, me, &state, &|id| {
|
||||||
|
by_id.get(&id).cloned()
|
||||||
|
});
|
||||||
|
let unread_count = notifications.iter().filter(|item| item.is_unread()).count();
|
||||||
|
|
||||||
|
let mut activity = Vec::new();
|
||||||
|
for event in client
|
||||||
|
.database()
|
||||||
|
.query(filters::authored_activity(me))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
activity.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity.sort_by(|a, b| {
|
||||||
|
b.created_at
|
||||||
|
.cmp(&a.created_at)
|
||||||
|
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||||
|
});
|
||||||
|
activity.truncate(ACTIVITY_LIMIT);
|
||||||
|
|
||||||
|
Ok::<_, Error>((notifications, activity, unread_count))
|
||||||
|
});
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
|
let (notifications, activity, unread_count) = match work.await {
|
||||||
|
Ok(results) => results,
|
||||||
|
// Database errors are transient, keep the last lists.
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("inbox refresh failed: {error}");
|
||||||
|
return this.update(cx, |this, _cx| this.refresh.abort());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let again = this.update(cx, |this, cx| {
|
||||||
|
// The signer may have changed while the query ran, making
|
||||||
|
// these results belong to the previous user.
|
||||||
|
if Backend::global(cx).read(cx).current_user() != Some(me) {
|
||||||
|
this.refresh.abort();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifications = Arc::new(notifications);
|
||||||
|
this.activity = Arc::new(activity);
|
||||||
|
this.unread_count = unread_count;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
this.refresh.finish()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if again {
|
||||||
|
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the cutoffs, re-derive the groups and persist the state.
|
||||||
|
fn after_state_change(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.regroup();
|
||||||
|
self.persist(cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute the unread and archived flags from the current state.
|
||||||
|
fn regroup(&mut self) {
|
||||||
|
let mut items = (*self.notifications).clone();
|
||||||
|
|
||||||
|
for item in items.iter_mut() {
|
||||||
|
item.apply_state(&self.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||||
|
self.notifications = Arc::new(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign the state with a random key and store it locally.
|
||||||
|
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.spawn(async move |_this, _cx| {
|
||||||
|
if let Err(error) = save_state(&client, me, &state).await {
|
||||||
|
log::warn!("failed to save inbox state: {error}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events of the group rooted at `root`.
|
||||||
|
fn group_events(&self, root: EventId) -> Option<Vec<Event>> {
|
||||||
|
self.notifications
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.root == root)
|
||||||
|
.map(|item| item.events.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every event in every group, archived groups included.
|
||||||
|
fn all_notification_events(&self) -> Vec<Event> {
|
||||||
|
self.notifications
|
||||||
|
.iter()
|
||||||
|
.flat_map(|item| item.events.iter().cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `d` tag identifying the inbox state event of `me`.
|
||||||
|
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||||
|
format!("signed-inbox-state:{}", me.to_hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest stored state for `me`.
|
||||||
|
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(inbox_state_d_tag(me));
|
||||||
|
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
match serde_json::from_str(&event.content) {
|
||||||
|
Ok(state) => Ok(Some(state)),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign with a random key and store locally.
|
||||||
|
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||||
|
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||||
|
.finalize(&Keys::generate())?;
|
||||||
|
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notification events and a lookup of every ancestor they reference.
|
||||||
|
async fn fetch_notifications(
|
||||||
|
client: &Client,
|
||||||
|
me: PublicKey,
|
||||||
|
deletions: &Deletions,
|
||||||
|
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
||||||
|
let mut notifications: Vec<Event> = Vec::new();
|
||||||
|
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
||||||
|
|
||||||
|
for filter in filters::notifications(me) {
|
||||||
|
for event in client.database().query(filter).await? {
|
||||||
|
if deletions.is_deleted(&event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if by_id.insert(event.id, event.clone()).is_none() {
|
||||||
|
notifications.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pending: Vec<EventId> = notifications.iter().flat_map(event_references).collect();
|
||||||
|
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Keep only ids not walked yet, and remember them.
|
||||||
|
pending.retain(|id| seen.insert(*id));
|
||||||
|
|
||||||
|
if pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ancestors = client
|
||||||
|
.database()
|
||||||
|
.query(Filter::new().ids(pending.iter().copied()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut next = Vec::new();
|
||||||
|
|
||||||
|
for event in ancestors {
|
||||||
|
if deletions.is_deleted(&event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.extend(event_references(&event).filter(|id| !seen.contains(id)));
|
||||||
|
by_id.entry(event.id).or_insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((notifications, by_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event ids referenced by `event` through its `e` and `E` tags.
|
||||||
|
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
||||||
|
event.tags.iter().filter_map(|tag| {
|
||||||
|
if tag.kind() != "e" && tag.kind() != "E" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
mod checkouts;
|
mod checkouts;
|
||||||
mod git_store;
|
mod git_store;
|
||||||
|
mod inbox;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod refresh;
|
mod refresh;
|
||||||
mod repo;
|
mod repo;
|
||||||
@@ -11,7 +12,8 @@ use std::path::{Path, PathBuf};
|
|||||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||||
pub use git_store::GitStore;
|
pub use git_store::GitStore;
|
||||||
use gpui::{App, AppContext, Entity};
|
use gpui::{App, AppContext};
|
||||||
|
pub use inbox::Inbox;
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
pub use profile::{Profile, ProfileStore};
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
@@ -19,14 +21,13 @@ pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
|
|||||||
use signed_nostr::new_backend;
|
use signed_nostr::new_backend;
|
||||||
|
|
||||||
/// Initialize the backend and stores, and install them as globals.
|
/// 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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn init(
|
pub fn init(
|
||||||
db_path: impl AsRef<Path>,
|
db_path: impl AsRef<Path>,
|
||||||
repos_root: impl Into<PathBuf>,
|
repos_root: impl Into<PathBuf>,
|
||||||
scan_paths: Vec<PathBuf>,
|
scan_paths: Vec<PathBuf>,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Entity<Backend> {
|
) {
|
||||||
// rustls uses the `aws_lc_rs` provider by default.
|
// rustls uses the `aws_lc_rs` provider by default.
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
@@ -37,27 +38,22 @@ pub fn init(
|
|||||||
.expect("failed to initialize nostr backend")
|
.expect("failed to initialize nostr backend")
|
||||||
});
|
});
|
||||||
|
|
||||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||||
Backend::set_global(entity.clone(), cx);
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
GitStore::set_global(repos_root, cx);
|
GitStore::set_global(repos_root, cx);
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||||
|
|
||||||
entity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the backend with an in-memory database on wasm.
|
/// Initialize the backend with an in-memory database on wasm.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
pub fn init(cx: &mut App) {
|
||||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||||
Backend::set_global(entity.clone(), cx);
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
GitStore::set_global(PathBuf::new(), cx);
|
GitStore::set_global(PathBuf::new(), cx);
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||||
entity
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
/// Refresh coalescing shared by the event stores.
|
/// Refresh coalescing shared by the event stores.
|
||||||
///
|
|
||||||
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
|
|
||||||
/// re-query their inputs on a debounce timer with the same policy:
|
|
||||||
/// a request arriving while a run is in flight is folded into a follow-up run,
|
|
||||||
/// a request arriving while the debounce timer is pending is dropped by it.
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct RefreshGate {
|
pub struct RefreshGate {
|
||||||
/// A run is in flight.
|
/// A run is in flight.
|
||||||
|
|||||||
+242
-95
@@ -6,6 +6,12 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for
|
|||||||
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
|
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
|
||||||
> an account is active, and that home screen is the inbox.
|
> an account is active, and that home screen is the inbox.
|
||||||
|
|
||||||
|
> **Status.** Phases 0 and 1 are implemented and green on `feat/inbox`:
|
||||||
|
> `cargo test -p signed_core` (68), `cargo test -p signed_state` (24),
|
||||||
|
> `cargo clippy -p signed_state --all-targets` clean, `cargo check --workspace` succeeds.
|
||||||
|
> Phases 2-5 are not started. This document reflects the implementation as it stands, including
|
||||||
|
> the Phase 1 refactors (§4.3).
|
||||||
|
|
||||||
## 1. What the GitWorkshop home screen is
|
## 1. What the GitWorkshop home screen is
|
||||||
|
|
||||||
`Index.tsx`:
|
`Index.tsx`:
|
||||||
@@ -217,54 +223,66 @@ when the signed-in key changes.
|
|||||||
```rust
|
```rust
|
||||||
/// d tag identifying the inbox read/archive state event of `me`.
|
/// d tag identifying the inbox read/archive state event of `me`.
|
||||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||||
format!("signed-inbox-state:{me}")
|
format!("signed-inbox-state:{}", me.to_hex())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Newest stored read state for `me`, with the id of the event it came from.
|
/// Newest stored read state for `me`.
|
||||||
async fn load_state(
|
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||||
client: &Client,
|
// No author filter: the signing key is random per save.
|
||||||
me: PublicKey,
|
|
||||||
) -> Result<Option<(InboxReadState, EventId)>> {
|
|
||||||
// No author filter: the signing key is random per session.
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.identifier(inbox_state_d_tag(me));
|
.identifier(inbox_state_d_tag(me));
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
let Some(event) = events.into_iter().max_by_key(|e| e.created_at) else {
|
|
||||||
|
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
Ok(serde_json::from_str(&event.content)
|
|
||||||
.ok()
|
match serde_json::from_str(&event.content) {
|
||||||
.map(|state| (state, event.id)))
|
Ok(state) => Ok(Some(state)),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign with the random `keys` and store locally.
|
/// Sign with a fresh random key and store locally.
|
||||||
async fn save_state(
|
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||||
client: &Client,
|
|
||||||
keys: &Keys,
|
|
||||||
me: PublicKey,
|
|
||||||
state: &InboxReadState,
|
|
||||||
) -> Result<EventId> {
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||||
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||||
.finalize(keys)?; // synchronous: random keys, no user signer
|
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
|
||||||
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
|
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
|
||||||
client.database().save_event(&event).await?;
|
client.database().save_event(&event).await?;
|
||||||
Ok(event.id)
|
Ok(())
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Kind 30078 is addressable, so saves signed by the same `keys` replace the previous event. Because
|
A fresh random key is generated on every save, so each save writes a new event rather than
|
||||||
`keys` is random per session, the first save of a session creates a new coordinate; the store then
|
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
|
||||||
deletes the event it loaded (its id is kept from `load_state`) so exactly one state event remains.
|
has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the
|
||||||
|
newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a
|
||||||
|
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
|
||||||
|
id across saves; that was removed as more derived state than it was worth.
|
||||||
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
|
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
|
||||||
|
|
||||||
### 4.3 `signed_state`: one `InboxStore`
|
### 4.3 `signed_state`: `Inbox`, a child entity of `Backend`
|
||||||
|
|
||||||
One store backs the whole screen, modelled on `RepoListStore` (`repos.rs`):
|
The inbox is not an app-wide global. It is a child `Entity<Inbox>` owned by `Backend`
|
||||||
|
(`inbox: Entity<Inbox>`), following the project's child-entity pattern
|
||||||
|
(`docs/backend-rearchitecture.md` §11): its observer set (the inbox screen, the sidebar badge) is a
|
||||||
|
strict subset of the backend's, so it is observed independently.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct InboxStore {
|
// backend.rs
|
||||||
|
pub struct Backend {
|
||||||
|
...
|
||||||
|
inbox: Entity<Inbox>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbox.rs
|
||||||
|
pub struct Inbox {
|
||||||
/// Activity directed at the user, grouped by thread root, newest first.
|
/// Activity directed at the user, grouped by thread root, newest first.
|
||||||
pub notifications: Arc<Vec<InboxItem>>,
|
pub notifications: Arc<Vec<InboxItem>>,
|
||||||
/// The user's own recent git activity, newest first.
|
/// The user's own recent git activity, newest first.
|
||||||
@@ -272,69 +290,132 @@ pub struct InboxStore {
|
|||||||
/// Unread notification count (non-archived).
|
/// Unread notification count (non-archived).
|
||||||
pub unread_count: usize,
|
pub unread_count: usize,
|
||||||
state: InboxReadState,
|
state: InboxReadState,
|
||||||
user: Option<PublicKey>,
|
/// Set once the stored state has been read for the current user.
|
||||||
/// Random keypair signing the local NIP-78 storage event.
|
state_loaded: bool,
|
||||||
keys: Keys,
|
|
||||||
/// Event id of the loaded state event, pruned on the next save.
|
|
||||||
loaded_state: Option<EventId>,
|
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
_subscription: Subscription,
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Lifespan: idle until a signer exists.** The store is created in `signed_state::init` like the
|
`Backend::new` builds it with `cx.new(|_| Inbox::default())`, and callers reach it through
|
||||||
other stores, but it does nothing until the user has a signer. It is never wired from the `desktop`
|
`Backend::global(cx).read(cx).inbox()` or `Backend::inbox()`. All inbox operations (`mark_read`,
|
||||||
crate, and `signed_state::init` gains no parameters.
|
`mark_archived`, `mark_all_read`, `refresh`) live on `Inbox`.
|
||||||
|
|
||||||
- `new` schedules `cx.defer`, like the other stores. The deferred bootstrap checks
|
**The store holds no derived state.** The current user is read from `Backend::current_user()` at
|
||||||
`Backend::current_user()`:
|
each use site, repo relays are queried from `RepoListStore` in `Backend::sync_inbox`, and the
|
||||||
- signer already present (session restored before the store was created): activate now;
|
signing key is random per save rather than cached. This follows the project rule against caching
|
||||||
- no signer: do nothing, wait for the event.
|
derived state.
|
||||||
- `BackendEvent::SignerChanged`: activate.
|
|
||||||
- `BackendEvent::SignerRequired` (logout / no credential): clear `user`, `notifications`,
|
|
||||||
`activity`, `unread_count`.
|
|
||||||
- `BackendEvent::NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`,
|
|
||||||
is `Kind::Comment`, or is a deletion.
|
|
||||||
- `BackendEvent::Synced`: refresh.
|
|
||||||
- `BackendEvent::Published`: refresh.
|
|
||||||
|
|
||||||
**Activation** (only on signer):
|
**Lifespan: idle until a signer exists.** `Inbox` is created with the backend but does nothing
|
||||||
|
until the user has a signer. It is never wired from the `desktop` crate, and `signed_state::init`
|
||||||
|
gains no parameters (the `InboxStore::set_global` idea was dropped).
|
||||||
|
|
||||||
|
The dependency is strictly one-way: **`Backend` → `Inbox`**. `Inbox` holds no `Backend` handle, so
|
||||||
|
there is no reference cycle and no `cx.subscribe`. Two mechanisms connect them.
|
||||||
|
|
||||||
|
**`Backend::emit`** is the single funnel for every `BackendEvent`. It updates the inbox on a
|
||||||
|
deferred effect and then emits to the other subscribers:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
fn activate(&mut self, me: PublicKey, cx: &mut Context<Self>) {
|
/// Update the inbox, then emit `event` to the other stores.
|
||||||
self.user = Some(me);
|
fn emit(&self, event: BackendEvent, cx: &mut Context<Self>) {
|
||||||
// Load the NIP-78 state from LMDB, then subscribe and refresh.
|
let inbox = self.inbox.downgrade();
|
||||||
// All run on background tasks; only plain data crosses back.
|
let inbox_event = event.clone();
|
||||||
|
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
if let Err(error) = inbox.update(cx, |inbox, cx| {
|
||||||
|
inbox.handle_backend_event(&inbox_event, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("inbox dropped before handling backend event: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.emit(event);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cx.defer` is load-bearing: every emit site runs inside `Backend::update`, and the inbox
|
||||||
|
handlers read `Backend`, so a synchronous call would re-enter the borrowed entity and panic. All
|
||||||
|
`cx.emit(...)` sites route through `self.emit(...)`.
|
||||||
|
|
||||||
|
`Inbox::handle_backend_event` reacts to only three shapes:
|
||||||
|
|
||||||
|
- `NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, is
|
||||||
|
`Kind::Comment`, or is a deletion (`EventDeletion` / `RequestToVanish`).
|
||||||
|
- `Synced` / `Published`: refresh.
|
||||||
|
- everything else: ignored.
|
||||||
|
|
||||||
|
`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore`
|
||||||
|
and `SidebarPanel` consume them. They no longer drive the inbox.
|
||||||
|
|
||||||
|
**Signer lifecycle: `Backend::sync_inbox`.** The inbox does not match `SignerChanged` /
|
||||||
|
`SignerRequired`. `Backend` owns the wiring and calls `sync_inbox` from the three real signer
|
||||||
|
transitions: `create_identity`, `set_signer` (covers nsec, bunker and passphrase restore) and
|
||||||
|
`logout`. The fetch work that used to live in `Inbox::activate` moved here, because the filters and
|
||||||
|
repo relays need `Backend`'s state:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if let Some(me) = self.current_user {
|
||||||
|
self.subscribe_bootstrap(filters::notifications(me), cx);
|
||||||
|
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||||
|
|
||||||
|
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.announcements_of(&me)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|announcement| announcement.relays)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !relays.is_empty() {
|
||||||
|
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
||||||
|
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
||||||
|
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let inbox = self.inbox.downgrade();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
let updated = inbox.update(cx, |inbox, cx| {
|
||||||
|
if Backend::global(cx).read(cx).current_user().is_some() {
|
||||||
|
inbox.activate(cx);
|
||||||
|
} else {
|
||||||
|
inbox.reset(cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(error) = updated {
|
||||||
|
log::warn!("inbox dropped before syncing with the signer: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call
|
||||||
|
time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind
|
||||||
|
10002 yet.) The deferred `update` is required because `activate` reads `Backend`, which every
|
||||||
|
caller is mid-update on. `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` no longer
|
||||||
|
has `subscribe_remote` / `connect_own_repo_relays`.
|
||||||
|
|
||||||
|
**Activation** (`activate`) clears the user's data, drops any in-flight or pending run belonging to
|
||||||
|
the previous user (`self.refresh = RefreshGate::default()`), then loads the NIP-78 state from LMDB
|
||||||
|
and chains the first refresh once it is loaded:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) fn activate(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else { return };
|
||||||
|
// clear notifications, activity, unread_count; state = default; state_loaded = false;
|
||||||
|
// self.refresh = RefreshGate::default();
|
||||||
self.load_state(me, cx);
|
self.load_state(me, cx);
|
||||||
self.subscribe_remote(cx);
|
|
||||||
self.refresh(cx);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Loading uses the random `keys` only for signing on save; reading the state event needs no signer at
|
`reset` performs the same clearing without a state load, and is used on logout.
|
||||||
all. Activation itself is still gated on the signer because the fetch filters need the user's pubkey.
|
|
||||||
|
|
||||||
**Fetch** (reuses `Backend::subscribe_bootstrap` / `connect_repo_relays`):
|
Reading the state event needs no signer at all (the `d` tag carries the identity); activation is
|
||||||
|
still gated on the signer because the fetch filters need the user's pubkey.
|
||||||
|
|
||||||
```rust
|
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays`, as shown in
|
||||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
`sync_inbox` above. The query that follows is intentionally the offline-first cache read, not a
|
||||||
let Some(me) = self.user else { return };
|
wait on the network; see the note below.
|
||||||
let backend = Backend::global(cx);
|
|
||||||
backend.update(cx, |backend, cx| {
|
|
||||||
backend.subscribe_bootstrap(filters::notifications(me), cx);
|
|
||||||
backend.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
|
||||||
});
|
|
||||||
// Relays of the user's own repos, so their activity there is found too.
|
|
||||||
let relays = own_repo_relays(me, cx);
|
|
||||||
backend.update(cx, |backend, cx| {
|
|
||||||
backend.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
|
||||||
backend.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`own_repo_relays` reads `RepoListStore::global(cx).read(cx).announcements_of(&me)` and unions their
|
|
||||||
`relays`. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind 10002 yet.)
|
|
||||||
|
|
||||||
**Refresh** (mirrors `RepoListStore::run_refresh`):
|
**Refresh** (mirrors `RepoListStore::run_refresh`):
|
||||||
|
|
||||||
@@ -345,12 +426,23 @@ fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
|||||||
`inbox::group`.
|
`inbox::group`.
|
||||||
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
|
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
|
||||||
their `K` tag is a git kind; sort newest first; take the top N.
|
their `K` tag is a git kind; sort newest first; take the top N.
|
||||||
- Cross back to the main thread: set `notifications`, `activity`, `unread_count`, `cx.notify()`,
|
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
|
||||||
`refresh.finish()`.
|
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
|
||||||
|
previous user's results never land. Then set `notifications`, `activity`, `unread_count`,
|
||||||
|
`cx.notify()`, `refresh.finish()`.
|
||||||
|
|
||||||
**Actions**: `mark_read(root)`, `mark_all_read()`, plus `advance_read` after marking to bound the
|
**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately,
|
||||||
`read_ids` set. After each change, sign with the random `keys` and save the NIP-78 event to LMDB,
|
so the query that follows them reads the local cache rather than waiting for the relays. That is
|
||||||
then delete the previously loaded copy (see 4.2).
|
deliberate offline-first behavior: cached content appears at once on a warm start and with no
|
||||||
|
network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by
|
||||||
|
timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so
|
||||||
|
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the inbox refreshes. This was
|
||||||
|
reviewed and left as-is.
|
||||||
|
|
||||||
|
**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each group's events are
|
||||||
|
marked, then the cutoffs are advanced against *all* notification events to bound the id sets. After
|
||||||
|
each change the groups are re-derived (`InboxItem::apply_state`) and the state is saved to LMDB,
|
||||||
|
signed with a fresh random key (see 4.2).
|
||||||
|
|
||||||
**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes
|
**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes
|
||||||
`announcements_of(user)`.
|
`announcements_of(user)`.
|
||||||
@@ -371,7 +463,7 @@ One scrollable two-column flex row.
|
|||||||
**Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle
|
**Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle
|
||||||
expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a
|
expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a
|
||||||
relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`.
|
relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`.
|
||||||
- **Continue where you left off**: `InboxStore::activity`, top 15, each row a kind icon, subject,
|
- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject,
|
||||||
repo name, and relative time.
|
repo name, and relative time.
|
||||||
- **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same
|
- **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same
|
||||||
pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows
|
pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows
|
||||||
@@ -398,7 +490,7 @@ pub fn add_bottom_panel(
|
|||||||
The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then:
|
The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then:
|
||||||
|
|
||||||
- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the
|
- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the
|
||||||
`InboxStore`. It renders the matching subset of `InboxStore::notifications` as a list.
|
`Entity<Inbox>`. It renders the matching subset of `Inbox::notifications` as a list.
|
||||||
- The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the
|
- The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the
|
||||||
requested mode. `InboxView` keeps `filter_view: Option<WeakEntity<InboxFilterView>>`; when it
|
requested mode. `InboxView` keeps `filter_view: Option<WeakEntity<InboxFilterView>>`; when it
|
||||||
already exists, update its mode and focus instead of adding a duplicate.
|
already exists, update its mode and focus instead of adding a duplicate.
|
||||||
@@ -417,7 +509,7 @@ In `views/sidebar/mod.rs`:
|
|||||||
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
|
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
|
||||||
```
|
```
|
||||||
|
|
||||||
- `cx.observe` the `InboxStore` so the badge updates.
|
- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge updates.
|
||||||
|
|
||||||
### 5.4 Click-through (P1)
|
### 5.4 Click-through (P1)
|
||||||
|
|
||||||
@@ -455,20 +547,22 @@ patch-root click opens the repo panel. Note as a known limitation.
|
|||||||
| File | Change |
|
| File | Change |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `crates/signed_core/Cargo.toml` | add `serde` |
|
| `crates/signed_core/Cargo.toml` | add `serde` |
|
||||||
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity` |
|
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` |
|
||||||
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests |
|
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests |
|
||||||
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
|
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
|
||||||
| `crates/signed_state/Cargo.toml` | add `serde_json` |
|
| `crates/signed_state/Cargo.toml` | add `serde_json` |
|
||||||
| `crates/signed_state/src/inbox.rs` | **new**: `InboxStore`, global, signer-gated activation, NIP-78 load/save, actions |
|
| `crates/signed_state/src/inbox.rs` | **new**: `Inbox` child entity, NIP-78 load/save, refresh, actions; no `Backend` handle, no `cx.subscribe` |
|
||||||
| `crates/signed_state/src/lib.rs` | `mod inbox;`, set global in `init` |
|
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, private `emit` funnel, `sync_inbox`, `RepoListStore` import |
|
||||||
|
| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users |
|
||||||
|
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) |
|
||||||
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper |
|
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper |
|
||||||
| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` |
|
| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` |
|
||||||
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` |
|
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` |
|
||||||
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge |
|
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge |
|
||||||
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) |
|
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) |
|
||||||
|
|
||||||
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; the store
|
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox`
|
||||||
bootstraps itself via `cx.defer` once a signer is present.
|
activates the `Inbox` child entity at each signer transition.
|
||||||
|
|
||||||
## 7. Phasing
|
## 7. Phasing
|
||||||
|
|
||||||
@@ -477,9 +571,11 @@ bootstraps itself via `cx.defer` once a signer is present.
|
|||||||
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
|
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
|
||||||
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
|
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
|
||||||
and `authored_activity` results must pass through `is_git_activity` before display (comments on
|
and `authored_activity` results must pass through `is_git_activity` before display (comments on
|
||||||
non-git roots are matched by the filter). `cargo test -p signed_core` passes (62 tests).
|
non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the
|
||||||
2. **Phase 1 - store**: `InboxStore` with signer-gated activation, both queries, unread count, and
|
end of Phase 0; 68 after the two Phase 1 additions).
|
||||||
NIP-78 load/save to LMDB, global install.
|
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
|
||||||
|
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
|
||||||
|
implementation notes below.
|
||||||
3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge.
|
3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge.
|
||||||
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
||||||
5. **Phase 4 - click-through**: `open_item` and announcement lookup.
|
5. **Phase 4 - click-through**: `open_item` and announcement lookup.
|
||||||
@@ -488,12 +584,59 @@ bootstraps itself via `cx.defer` once a signer is present.
|
|||||||
|
|
||||||
Each phase compiles and is usable on its own.
|
Each phase compiles and is usable on its own.
|
||||||
|
|
||||||
|
### Phase 1 implementation notes
|
||||||
|
|
||||||
|
Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}`
|
||||||
|
and two additions to `crates/signed_core/src/inbox.rs`.
|
||||||
|
|
||||||
|
- `Inbox` is a child entity of `Backend` (`inbox: Entity<Inbox>`), created in `Backend::new` and
|
||||||
|
reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no
|
||||||
|
parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle.
|
||||||
|
- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through
|
||||||
|
`cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs
|
||||||
|
inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on
|
||||||
|
a re-entrant entity access.
|
||||||
|
- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer`
|
||||||
|
and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate`
|
||||||
|
/ `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and
|
||||||
|
`SidebarPanel`, but no longer drive the inbox.
|
||||||
|
- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the
|
||||||
|
database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`.
|
||||||
|
- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh
|
||||||
|
before the stored state is read would briefly mark everything unread. The first refresh is chained
|
||||||
|
after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set.
|
||||||
|
- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh
|
||||||
|
`RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of
|
||||||
|
`run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the
|
||||||
|
user the query was started for.
|
||||||
|
- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors
|
||||||
|
`mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it).
|
||||||
|
Both are covered by tests.
|
||||||
|
- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`)
|
||||||
|
rather than a single hop, because a patch series chains through parent patches. Only the notification
|
||||||
|
events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is
|
||||||
|
not mistaken for a notification.
|
||||||
|
- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh
|
||||||
|
`Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the
|
||||||
|
random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`.
|
||||||
|
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
|
||||||
|
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
|
||||||
|
re-derives the groups locally so the UI updates immediately, then persists in the background.
|
||||||
|
- The store keeps no derived state. The signing key is generated per save, the current user is read
|
||||||
|
from `Backend::current_user()` where needed, and the relays of the user's own repositories are
|
||||||
|
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
|
||||||
|
either: the newest state event is selected by `created_at`.
|
||||||
|
- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and
|
||||||
|
`connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`.
|
||||||
|
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
|
||||||
|
`cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds.
|
||||||
|
|
||||||
## 8. Validation
|
## 8. Validation
|
||||||
|
|
||||||
- `cargo test -p signed_core`: root resolution, grouping, read-state cutoff, serde round-trip.
|
- `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip.
|
||||||
- `cargo test -p signed_state`: NIP-78 content round-trip (`serde_json`), if a non-GPUI path is
|
- `cargo test -p signed_state` (24 tests): the `Inbox` store paths that do not need GPUI (state
|
||||||
factored out.
|
round-trip, grouping helpers).
|
||||||
- `cargo check --workspace` after each phase.
|
- `cargo clippy -p signed_state --all-targets` and `cargo check --workspace` after each phase.
|
||||||
- Manual: log in with a repo-owning identity; confirm the inbox panel populates from another
|
- Manual: log in with a repo-owning identity; confirm the inbox panel populates from another
|
||||||
identity's issue/comment, the activity list shows your own items, the repositories panel matches
|
identity's issue/comment, the activity list shows your own items, the repositories panel matches
|
||||||
the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events).
|
the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events).
|
||||||
@@ -513,3 +656,7 @@ Each phase compiles and is usable on its own.
|
|||||||
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
|
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
|
||||||
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
|
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
|
||||||
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
|
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
|
||||||
|
- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy
|
||||||
|
`client.sync(...)` both persist received events to LMDB and surface them as
|
||||||
|
`ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`.
|
||||||
|
This is why the query right after a fetch is a cache read, not a race.
|
||||||
|
|||||||
Reference in New Issue
Block a user