3 Commits
Author SHA1 Message Date
reya e1a6f0a669 remove cover note
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-27 08:37:39 +07:00
reya c170c4a800 update per event kind 2026-09-27 08:34:13 +07:00
reya 4d2d058396 refactor 2026-09-27 08:03:56 +07:00
10 changed files with 129 additions and 228 deletions
-7
View File
@@ -1,7 +0,0 @@
use nostr::prelude::*;
/// GitWorkshop and `ngit` cover-note extension, kind 1624.
///
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
/// not part of the NIP-34 draft, read support for interop.
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
+15 -25
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use nostr::prelude::*;
use crate::{COVER_NOTE_KIND, RepoAddr};
use crate::RepoAddr;
/// Kinds that make up the activity of a repository.
pub const ACTIVITY_KINDS: [Kind; 9] = [
@@ -18,19 +18,18 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
];
/// Kinds that notify a user when they tag them via their `p` tag.
pub const NOTIFICATION_KINDS: [Kind; 9] = [
pub const NOTIFICATION_KINDS: [Kind; 8] = [
Kind::GitIssue,
Kind::GitPullRequest,
Kind::GitPatch,
Kind::GitPullRequestUpdate,
COVER_NOTE_KIND,
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
];
/// Git root kinds that make a comment or cover note count as git activity.
/// Git root kinds that make a comment count as git activity.
const GIT_ROOT_KINDS: [Kind; 4] = [
Kind::GitIssue,
Kind::GitPatch,
@@ -38,6 +37,15 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
Kind::GitRepoAnnouncement,
];
/// Kinds that carry repository data: announcements, states, activity and deletions.
pub fn is_repo_kind(kind: Kind) -> bool {
kind == Kind::GitRepoAnnouncement
|| kind == Kind::RepoState
|| kind == Kind::EventDeletion
|| kind == Kind::RequestToVanish
|| ACTIVITY_KINDS.contains(&kind)
}
/// Value of the first tag named `name` on `event`.
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
event
@@ -140,13 +148,7 @@ pub fn notifications(me: PublicKey) -> Vec<Filter> {
/// A comment on an unrelated kind is matched too, so results must be filtered
/// through [`is_git_activity`] before display.
pub fn authored_activity(me: PublicKey) -> Filter {
Filter::new()
.kinds(
ACTIVITY_KINDS
.into_iter()
.chain(std::iter::once(COVER_NOTE_KIND)),
)
.author(me)
Filter::new().kinds(ACTIVITY_KINDS).author(me)
}
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
@@ -155,12 +157,6 @@ fn is_git_comment(event: &Event) -> bool {
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
}
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
fn is_git_cover_note(event: &Event) -> bool {
event.kind == COVER_NOTE_KIND
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
}
/// Whether a status event references a git root, checked via its `k` tag.
fn is_git_status(event: &Event) -> bool {
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
@@ -175,7 +171,7 @@ pub fn is_git_activity(event: &Event) -> bool {
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => is_git_status(event),
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
_ => false,
}
}
@@ -267,17 +263,12 @@ mod tests {
}
#[test]
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
fn status_activity_depends_on_the_lowercase_k_tag() {
let status = signed(
&keys(1),
Kind::GitStatusClosed,
vec![kind_tag("k", Kind::GitPullRequest)],
);
let cover = signed(
&keys(1),
COVER_NOTE_KIND,
vec![kind_tag("k", Kind::GitPatch)],
);
let unrelated = signed(
&keys(1),
Kind::GitStatusClosed,
@@ -285,7 +276,6 @@ mod tests {
);
assert!(is_git_activity(&status));
assert!(is_git_activity(&cover));
assert!(!is_git_activity(&unrelated));
assert!(!is_git_activity(&signed(
&keys(1),
+2 -5
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
use crate::{RepoAddr, activity_subject};
/// Window before `now` that an advanced cutoff retreats to.
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
@@ -121,14 +121,11 @@ impl InboxItem {
/// - patch (1617): its `e` parent patch, else itself
/// - NIP-22 comment (1111): uppercase `E` root pointer
/// - PR update (1619): uppercase `E`
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
/// - statuses (1630-1633): NIP-10 root `e`
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
where
L: Fn(EventId) -> Option<Event>,
{
if event.kind == COVER_NOTE_KIND {
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
}
match event.kind {
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
Kind::GitPatch => Some(match first_e_id(event) {
-2
View File
@@ -1,5 +1,4 @@
pub mod addr;
pub mod annotations;
pub mod deletions;
pub mod filters;
pub mod inbox;
@@ -8,7 +7,6 @@ pub mod state;
pub mod status;
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
pub use annotations::COVER_NOTE_KIND;
pub use deletions::Deletions;
pub use filters::{
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
+77 -58
View File
@@ -37,15 +37,15 @@ pub enum BackendEvent {
PassphraseRequired,
/// The signer changed on login, logout or account switch.
SignerChanged,
/// New events were received from a relay and stored in the database.
NostrUpdate(Vec<Update>),
/// Kind-0 metadata arrived for these authors; re-read them from the store.
ProfileUpdates(Vec<PublicKey>),
/// Repository events arrived: announcements, states, activity and deletions.
RepoUpdates(Vec<Update>),
Synced,
SyncProgress {
total: u64,
current: u64,
},
/// An event built locally was signed, broadcast and stored.
Published(Box<Event>),
Error(String),
}
@@ -90,14 +90,16 @@ impl Backend {
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let mut notifications = pump_client.notifications();
let mut pending: Vec<Update> = Vec::new();
let mut pending_profiles: HashSet<PublicKey> = HashSet::new();
let mut pending_repos: Vec<Update> = Vec::new();
let mut seen: HashSet<EventId> = HashSet::new();
'outer: loop {
match notifications.next().await {
Some(ClientNotification::Event { event, .. }) => {
pending.push(Update::from_event(&event));
match next_update(&mut notifications, &mut seen).await {
Some(UpdateEvent::Profile(author)) => {
pending_profiles.insert(author);
}
Some(_) => continue,
Some(UpdateEvent::Repo(update)) => pending_repos.push(update),
None => break,
}
@@ -113,28 +115,33 @@ impl Backend {
let timer = cx.background_executor().timer(deadline - now);
futures::pin_mut!(timer);
let next = notifications.next();
let next = next_update(&mut notifications, &mut seen);
futures::pin_mut!(next);
match futures::future::select(next, timer).await {
futures::future::Either::Left((
Some(ClientNotification::Event { event, .. }),
_,
)) => {
pending.push(Update::from_event(&event));
futures::future::Either::Left((Some(UpdateEvent::Profile(author)), _)) => {
pending_profiles.insert(author);
}
futures::future::Either::Left((Some(UpdateEvent::Repo(update)), _)) => {
pending_repos.push(update);
}
futures::future::Either::Left((Some(_), _)) => continue,
futures::future::Either::Left((None, _)) => break 'outer,
futures::future::Either::Right(_) => break,
}
}
let batch = std::mem::take(&mut pending);
let profiles: Vec<PublicKey> = pending_profiles.drain().collect();
let repos = std::mem::take(&mut pending_repos);
if let Err(e) =
this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
{
log::warn!("failed to emit nostr update: {e}");
if let Err(e) = this.update(cx, |_this, cx| {
if !profiles.is_empty() {
cx.emit(BackendEvent::ProfileUpdates(profiles));
}
if !repos.is_empty() {
cx.emit(BackendEvent::RepoUpdates(repos));
}
}) {
log::warn!("failed to emit backend update: {e}");
}
}
@@ -500,9 +507,7 @@ impl Backend {
let builder = announcement.into_event_builder();
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
let event = require_relay_accepted(output, event)?;
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
event
require_relay_accepted(output, event)?
};
// The state event is the push authorization. Stage it on each
@@ -557,15 +562,11 @@ impl Backend {
// Fan the state out to the relays once a git server holds the objects.
// Staging already stored the event locally, publishing makes it
// visible to the other relays and clients.
if let Some(state_event) = &outcome.state_event {
if let Err(e) = client.send_event(state_event).broadcast().await {
if let Some(state_event) = &outcome.state_event
&& let Err(e) = client.send_event(state_event).broadcast().await
{
log::warn!("failed to broadcast repository state: {e}");
}
this.update(cx, |this, cx| {
this.announce_published(state_event.clone(), cx)
})
.ok();
}
let announcement = Announcement::from_event(&event)
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
@@ -653,9 +654,7 @@ impl Backend {
let builder = announcement.into_event_builder();
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
let event = require_relay_accepted(output, event)?;
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
event
require_relay_accepted(output, event)?
};
let refs = state.refs.clone();
@@ -712,13 +711,11 @@ impl Backend {
// Fan the state out to the relays once a git server holds the objects.
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
if let Some(state_event) = &outcome.state_event {
if let Err(e) = client.send_event(state_event).broadcast().await {
if let Some(state_event) = &outcome.state_event
&& let Err(e) = client.send_event(state_event).broadcast().await
{
log::warn!("failed to broadcast repository state: {e}");
}
this.update(cx, |this, cx| this.announce_published(state_event.clone(), cx))
.ok();
}
}
// Point `origin` at the first grasp server so later pushes have a target.
@@ -894,15 +891,11 @@ impl Backend {
// Fan the state out to the relays once a git server holds the objects.
// Staging already stored the event locally, publishing notifies
// the repository views and other relays and clients.
if let Some(state_event) = &outcome.state_event {
if let Err(e) = client.send_event(state_event).broadcast().await {
if let Some(state_event) = &outcome.state_event
&& let Err(e) = client.send_event(state_event).broadcast().await
{
log::warn!("failed to broadcast repository state: {e}");
}
this.update(cx, |this, cx| {
this.announce_published(state_event.clone(), cx)
})
.ok();
}
Ok(outcome)
})
@@ -1299,14 +1292,6 @@ impl Backend {
.detach();
}
/// Emit [`BackendEvent::Published`] for cross-store invalidation.
///
/// Callers publish with `client.send_event(...)` directly, then call this
/// so stores like `RepoListStore` refresh without re-querying the relays.
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
cx.emit(BackendEvent::Published(Box::new(event)));
}
/// Publish a NIP-09 deletion for each of `events`, best-effort.
///
/// Each target gets its own deletion event: a relay rejecting or
@@ -1329,6 +1314,42 @@ impl Backend {
}
}
/// A relay event the backend routes to a store group.
enum UpdateEvent {
Profile(PublicKey),
Repo(Update),
}
/// Await the next relay event from the notification stream.
async fn next_update(
notifications: &mut (impl futures::Stream<Item = ClientNotification> + Unpin),
seen: &mut HashSet<EventId>,
) -> Option<UpdateEvent> {
loop {
match notifications.next().await {
Some(ClientNotification::Message { message, .. }) => {
let RelayMessage::Event { event, .. } = *message else {
continue;
};
let update = match event.kind {
Kind::Metadata => UpdateEvent::Profile(event.pubkey),
kind if filters::is_repo_kind(kind) => {
UpdateEvent::Repo(Update::from_event(&event))
}
_ => continue,
};
if seen.insert(event.id) {
return Some(update);
}
}
Some(_) => continue,
None => return None,
}
}
}
/// Sign and send a single NIP-09 deletion request for `event`.
async fn retract_event(
client: &Client,
@@ -1338,16 +1359,14 @@ async fn retract_event(
let builder = EventDeletionRequest::new()
.id(event.id)
.into_event_builder();
let deletion = builder.finalize_async(signer).await?;
client.send_event(&deletion).broadcast().await?;
Ok(())
}
/// The event was accepted by at least one relay, or a descriptive error otherwise.
///
/// The SDK does not treat "accepted by zero relays" as an error on its own:
/// [`SendEventOutput::success`] may be empty while the call still returns `Ok`.
/// This turns that case into an error the caller can surface.
pub(crate) fn require_relay_accepted(
output: SendEventOutput,
event: Event,
+14 -24
View File
@@ -92,38 +92,28 @@ impl ProfileStore {
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let client = backend.read(cx).client();
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
BackendEvent::NostrUpdate(updates) => {
for update in updates
.iter()
.filter(|update| update.kind == Kind::Metadata)
{
this.apply_author(update.author, cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
if let BackendEvent::ProfileUpdates(authors) = event {
for author in authors {
this.apply_author(*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();
}
_ => {}
});
// Fetch requests are queued on a channel, batched into one sync per debounce window.
let client = backend.read(cx).client();
let (sender, receiver) = flume::unbounded::<PublicKey>();
let entity = cx.entity().downgrade();
let entity_clone = entity.clone();
let (sender, receiver) = flume::unbounded::<PublicKey>();
cx.spawn(async move |_this, cx| {
Self::handle_requests(entity, &client, &receiver, cx).await
})
.detach();
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
if let Err(error) = entity_clone.update(cx, |this, cx| this.load(cx)) {
log::warn!("profile store dropped before initial load could run: {error}");
}
});
@@ -228,8 +218,6 @@ impl ProfileStore {
}
/// Re-read the latest metadata of every requested author from the local database.
///
/// Used after a sync, which produces no NostrUpdate events.
fn apply_seen(&mut self, cx: &mut Context<Self>) {
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
@@ -305,15 +293,20 @@ impl ProfileStore {
// The channel has no async timeout, race the receive against a timer.
let deadline = Instant::now() + BATCH_TIMEOUT;
loop {
let now = Instant::now();
if now >= deadline {
break;
}
let timer = cx.background_executor().timer(deadline - now);
futures::pin_mut!(timer);
let recv = receiver.recv_async();
futures::pin_mut!(recv);
match futures::future::select(recv, timer).await {
futures::future::Either::Left((Ok(public_key), _)) => {
batch.insert(public_key);
@@ -327,9 +320,6 @@ impl ProfileStore {
.kind(Kind::Metadata)
.authors(batch.drain().collect::<Vec<PublicKey>>());
// Negentropy-sync with the bootstrap relays.
// Synced events are written to the database directly, no NostrUpdate.
// Re-apply from the database afterwards.
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
Ok(_) => {
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
+3 -45
View File
@@ -208,8 +208,7 @@ impl RepoStore {
};
let relevant = match event {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
BackendEvent::RepoUpdates(updates) => updates.iter().any(|update| {
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
@@ -225,20 +224,6 @@ impl RepoStore {
deletion || coordinate || authored || comment || status
}),
BackendEvent::Published(event) => {
let announcement = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
let state = event.kind == Kind::RepoState
&& author
&& event.tags.identifier().as_deref() == Some(addr.identifier.as_str());
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
coordinate || (announcement && author) || state || deletion
}
_ => false,
};
@@ -977,11 +962,6 @@ impl RepoStore {
}
};
this.update(cx, |_this, cx| {
Backend::global(cx)
.update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx))
})?;
// A draft PR carries a kind-1633 status event, NIP-34.
// Publish it right after the PR event so viewers never show it open.
if draft {
@@ -1171,21 +1151,12 @@ impl RepoStore {
}
.await;
match publish_result {
Ok(event) => {
this.update(cx, |_this, cx| {
Backend::global(cx).update(cx, |backend, cx| {
backend.announce_published(event.clone(), cx)
})
})?;
}
Err(e) => {
if let Err(e) = publish_result {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
}
}
Ok(())
})
@@ -1622,20 +1593,12 @@ impl RepoStore {
}
.await;
match publish_result {
Ok(event) => {
this.update(cx, |_this, cx| {
Backend::global(cx)
.update(cx, |backend, cx| backend.announce_published(event, cx))
})?;
}
Err(e) => {
if let Err(e) = publish_result {
this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
})?;
}
}
Ok(())
});
@@ -1767,11 +1730,6 @@ async fn publish_patch_series(
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
let event = require_relay_accepted(output, event)?;
this.update(cx, |_this, cx| {
Backend::global(cx).update(cx, |backend, cx| {
backend.announce_published(event.clone(), cx)
})
})?;
if root.is_none() {
root = Some(event.clone());
+1 -22
View File
@@ -68,32 +68,11 @@ impl RepoListStore {
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target anything we list, always refresh.
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
true
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
// Activity events are addressed to repos via `a` tags.
// Their author is not the repo owner, always refresh.
true
} else {
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
let is_repo_state = update.kind == Kind::RepoState;
is_announcement || is_repo_state
}
}),
BackendEvent::Published(event) => {
let announcement = event.kind == Kind::GitRepoAnnouncement;
let state = event.kind == Kind::RepoState;
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
announcement || state || deletion
}
BackendEvent::SignerChanged => {
this.state_synced_repos.clear();
true
}
BackendEvent::RepoUpdates(_) => true,
BackendEvent::Synced => true,
_ => false,
};
+3 -20
View File
@@ -11,7 +11,7 @@ use gpui::{
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters};
use signed_core::{InboxItem, InboxReadState, RepoAddr};
use signed_state::{
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
};
@@ -157,21 +157,8 @@ impl InboxView {
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
match event {
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);
}
}
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
BackendEvent::RepoUpdates(_) => self.refresh(cx),
BackendEvent::Synced => self.refresh(cx),
_ => {}
}
}
@@ -570,10 +557,6 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
}
fn activity_phrase(kind: Kind) -> &'static str {
if kind == COVER_NOTE_KIND {
return "added a note";
}
match kind {
Kind::GitIssue => "opened an issue",
Kind::GitPullRequest => "opened a PR",
+3 -9
View File
@@ -79,16 +79,12 @@ impl SidebarPanel {
let signer_changed = matches!(event, BackendEvent::SignerChanged);
let signer_required = matches!(event, BackendEvent::SignerRequired);
if !signer_changed && !signer_required {
return;
}
if signer_required {
this.banner = pick_banner();
cx.notify();
}
if this.refresh(cx) || signer_required {
if this.refresh(cx) || signer_changed {
cx.notify();
}
}));
@@ -131,16 +127,14 @@ impl SidebarPanel {
let user = backend.read(cx).current_user();
let (announcements, local_repos, scanning) = {
let repo_list = RepoListStore::global(cx);
let repo_list = repo_list.read(cx);
let repo_list = RepoListStore::global(cx).read(cx);
let announcements = user
.as_ref()
.map(|user| repo_list.announcements_of(user))
.unwrap_or_default();
let local = LocalReposStore::global(cx);
let local = local.read(cx);
let local = LocalReposStore::global(cx).read(cx);
let local_repos =
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);