diff --git a/Cargo.lock b/Cargo.lock index 9e56d81..8015da2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7826,6 +7826,7 @@ dependencies = [ "rustls", "signed_core", "signed_nostr", + "utils", ] [[package]] @@ -9091,6 +9092,13 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "utils" +version = "1.0.0" +dependencies = [ + "nostr", +] + [[package]] name = "uuid" version = "1.24.0" @@ -10348,6 +10356,7 @@ dependencies = [ "gpui-component", "signed_core", "signed_state", + "utils", ] [[package]] diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 796282c..6d104f3 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -21,6 +21,8 @@ pub struct Announcement { pub euc: Option, /// Other recognized maintainers. pub maintainers: Vec, + /// Hashtags labelling the repository (`t` tags). + pub hashtags: Vec, } impl Announcement { @@ -38,13 +40,20 @@ impl Announcement { let mut relays: Vec = Vec::new(); let mut euc: Option = None; let mut maintainers: Vec = Vec::new(); + let mut hashtags: Vec = Vec::new(); for tag in event.tags.iter() { - // The `d` tag isn't part of the NIP-34 tag codec; parse it directly. + // The `d` and `t` tags aren't part of the NIP-34 tag codec; parse them directly. if tag.kind() == "d" { id = tag.content().map(str::to_owned); continue; } + if tag.kind() == "t" { + if let Some(value) = tag.content() { + hashtags.push(value.to_owned()); + } + continue; + } match Nip34Tag::parse(tag.as_slice()) { Ok(Nip34Tag::Name(value)) => name = Some(value), @@ -73,6 +82,7 @@ impl Announcement { relays, euc, maintainers, + hashtags, }) } @@ -119,6 +129,8 @@ mod tests { &["relays", "wss://relay.example.com"], &["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"], &["maintainers", MAINTAINER_HEX], + &["t", "rust"], + &["t", "nostr"], ]); let announcement = Announcement::from_event(&event).expect("parses"); @@ -141,6 +153,7 @@ mod tests { announcement.maintainers, vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")] ); + assert_eq!(announcement.hashtags, vec!["rust", "nostr"]); } #[test] diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index cea9b9a..21ddabf 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] signed_core = { path = "../signed_core" } signed_nostr = { path = "../signed_nostr" } +utils = { path = "../utils" } nostr.workspace = true nostr-sdk.workspace = true diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 8ccf4dc..7536559 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -7,7 +7,9 @@ use std::path::Path; pub use backend::{Backend, BackendEvent}; use gpui::{App, AppContext, Entity}; -pub use profile::{Profile, ProfileStore, shorten_pubkey}; +pub use nostr_sdk::prelude::Timestamp; +pub use profile::{Profile, ProfileStore}; +pub use utils::shorten_pubkey; pub use repo::RepoStore; pub use repo_list::RepoListStore; use signed_nostr::new_backend; diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 52dc737..cc2e714 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -6,6 +6,7 @@ use anyhow::Error; use flume::{Receiver, RecvTimeoutError, Sender}; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task}; use nostr_sdk::prelude::*; +use utils::shorten_pubkey; use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; @@ -59,12 +60,6 @@ impl Profile { } } -/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form. -pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String { - let npub = public_key.to_bech32().unwrap(); - format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..]) -} - /// Message from the fetch task to the main thread. enum Dispatch { /// A batched sync finished; re-read seen profiles from the database. diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index b09cdd4..bb37d6e 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, filters}; +use signed_core::{Announcement, RepoAddr, filters, repo_addr}; use crate::backend::{Backend, BackendEvent}; @@ -13,10 +13,16 @@ use crate::backend::{Backend, BackendEvent}; /// events (e.g. sync progress ticks) collapse into one query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); +/// How far back activity events count toward a repository's last activity. +const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400); + /// Store listing repository announcements (global discovery or per-author). pub struct RepoListStore { /// Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, + /// Latest known activity timestamp per repository + /// (announcements, state updates, patches, PRs, issues, statuses). + pub last_activity: Arc>, author: Option, refreshing: bool, refresh_dirty: bool, @@ -32,14 +38,22 @@ impl RepoListStore { let backend = Backend::global(cx); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { - let git_kind = Kind::GitRepoAnnouncement; - let relevant = match event { BackendEvent::NostrUpdate(update) => { - update.kind == git_kind && this.author.is_none_or(|a| a == update.author) + // Activity (patches, issues, ...) is addressed to repos via + // `a` tags, so its author isn't the repo owner; always refresh. + if filters::ACTIVITY_KINDS.contains(&update.kind) { + true + } else { + let is_announcement = update.kind == Kind::GitRepoAnnouncement; + let is_repo_state = update.kind == Kind::RepoState; + let tracked = is_announcement || is_repo_state; + tracked && this.author.is_none_or(|a| a == update.author) + } } BackendEvent::Published(event) => { - event.kind == git_kind && this.author.is_none_or(|a| a == event.pubkey) + event.kind == Kind::GitRepoAnnouncement + && this.author.is_none_or(|a| a == event.pubkey) } BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, _ => false, @@ -52,6 +66,7 @@ impl RepoListStore { let mut store = Self { announcements: Arc::new(Vec::new()), + last_activity: Arc::new(HashMap::new()), author, refreshing: false, refresh_dirty: false, @@ -151,12 +166,59 @@ impl RepoListStore { let mut announcements: Vec = by_repo.into_values().collect(); announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); - Ok::<_, Error>(announcements) + // Last activity per repository: state updates plus all NIP-34 + // activity events (patches, PRs, issues, statuses). + let mut last_activity: HashMap = announcements + .iter() + .map(|a| (a.addr(), a.created_at)) + .collect(); + + let state_filter = Filter::new().kind(Kind::RepoState); + for event in client.database().query(state_filter).await? { + let Some(id) = event.tags.identifier() else { + continue; + }; + let addr = repo_addr(event.pubkey, id); + let Some(entry) = last_activity.get_mut(&addr) else { + continue; + }; + *entry = (*entry).max(event.created_at); + } + + // Bound the activity query to a recent window; older repos fall + // back to their announcement / state timestamps. + let activity_filter = Filter::new() + .kinds(filters::ACTIVITY_KINDS) + .since(Timestamp::now() - ACTIVITY_WINDOW); + for event in client.database().query(activity_filter).await? { + for tag in event.tags.iter() { + if tag.kind() != "a" { + continue; + } + let Some(content) = tag.content() else { + continue; + }; + let Ok(addr) = Coordinate::parse(content) else { + continue; + }; + if addr.kind != Kind::GitRepoAnnouncement { + continue; + } + // Skip events for repos we don't list, so the map can't + // grow beyond the number of announcements. + let Some(entry) = last_activity.get_mut(&addr) else { + continue; + }; + *entry = (*entry).max(event.created_at); + } + } + + Ok::<_, Error>((announcements, last_activity)) }); self.tasks.push(cx.spawn(async move |this, cx| { - let announcements = match work.await { - Ok(announcements) => announcements, + let (announcements, last_activity) = match work.await { + Ok(results) => results, // Database errors are transient; keep the last list. Err(_) => { return this.update(cx, |this, _cx| { @@ -167,6 +229,7 @@ impl RepoListStore { let again = this.update(cx, |this, cx| { this.announcements = Arc::new(announcements); + this.last_activity = Arc::new(last_activity); cx.notify(); this.refreshing = false; diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml new file mode 100644 index 0000000..da7dbad --- /dev/null +++ b/crates/utils/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "utils" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +nostr.workspace = true diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs new file mode 100644 index 0000000..5ce1f4c --- /dev/null +++ b/crates/utils/src/lib.rs @@ -0,0 +1,5 @@ +mod pubkey; +mod time; + +pub use pubkey::shorten_pubkey; +pub use time::relative_time; diff --git a/crates/utils/src/pubkey.rs b/crates/utils/src/pubkey.rs new file mode 100644 index 0000000..3b2d588 --- /dev/null +++ b/crates/utils/src/pubkey.rs @@ -0,0 +1,7 @@ +use nostr::prelude::*; + +/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form. +pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String { + let npub = public_key.to_bech32().unwrap(); + format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..]) +} diff --git a/crates/utils/src/time.rs b/crates/utils/src/time.rs new file mode 100644 index 0000000..3c350d9 --- /dev/null +++ b/crates/utils/src/time.rs @@ -0,0 +1,43 @@ +use nostr::prelude::*; + +/// Format a timestamp as a short relative time (e.g. "3h ago"). +pub fn relative_time(timestamp: Timestamp) -> String { + let now = Timestamp::now().as_secs(); + let secs = now.saturating_sub(timestamp.as_secs()); + + if secs < 60 { + "just now".to_string() + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86_400 { + format!("{}h ago", secs / 3600) + } else if secs < 30 * 86_400 { + format!("{}d ago", secs / 86_400) + } else if secs < 365 * 86_400 { + format!("{}mo ago", secs / (30 * 86_400)) + } else { + format!("{}y ago", secs / (365 * 86_400)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_relative_time() { + let now = Timestamp::now(); + + assert_eq!(relative_time(now), "just now"); + assert_eq!(relative_time(now - 300), "5m ago"); + assert_eq!(relative_time(now - 7_200), "2h ago"); + assert_eq!(relative_time(now - 3 * 86_400), "3d ago"); + assert_eq!(relative_time(now - 60 * 86_400), "2mo ago"); + assert_eq!(relative_time(now - 800 * 86_400), "2y ago"); + } + + #[test] + fn clamps_future_timestamps() { + assert_eq!(relative_time(Timestamp::now() + 600), "just now"); + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 17f1d16..a817502 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true assets = { path = "../assets" } signed_core = { path = "../signed_core" } signed_state = { path = "../signed_state" } +utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 224258a..b15ee8d 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -6,7 +6,8 @@ use gpui::{ use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::{ActiveTheme, StyledExt}; use signed_core::Announcement; -use signed_state::{ProfileStore, RepoListStore}; +use signed_state::{ProfileStore, RepoListStore, Timestamp}; +use utils::relative_time; /// Browse all announced repositories (works anonymously). pub struct RepoListView { @@ -27,7 +28,12 @@ impl RepoListView { } } - fn render_card(&self, announcement: &Announcement, cx: &mut App) -> AnyElement { + fn render_card( + &self, + announcement: &Announcement, + last_activity: Option, + cx: &mut App, + ) -> AnyElement { let name = announcement .name .clone() @@ -40,9 +46,41 @@ impl RepoListView { let description = announcement.description.clone().unwrap_or_default(); + let mut hashtags: Vec = announcement + .hashtags + .iter() + .take(3) + .map(|tag| { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(SharedString::from(format!("#{tag}"))) + .into_any_element() + }) + .collect(); + + let remaining = announcement.hashtags.len().saturating_sub(3); + + if remaining > 0 { + hashtags.push( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(SharedString::from(format!("+{remaining}"))) + .into_any_element(), + ); + } + + let activity = last_activity + .map(relative_time) + .map(|label| SharedString::from(format!("Updated {label}"))) + .unwrap_or_default(); + div() .v_flex() - .h(px(60.)) + .h(px(78.)) .w_full() .justify_center() .gap_1() @@ -78,6 +116,24 @@ impl RepoListView { .text_ellipsis() .child(description), ) + .child( + div() + .h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .children(hashtags) + .child( + div() + .flex_1() + .h_flex() + .justify_end() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(activity), + ), + ) .into_any_element() } } @@ -103,6 +159,7 @@ impl Focusable for RepoListView { impl Render for RepoListView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let announcements = self.store.read(cx).announcements.clone(); + let last_activity = self.store.read(cx).last_activity.clone(); let has_announcements = !announcements.is_empty(); let count = announcements.len(); @@ -147,7 +204,9 @@ impl Render for RepoListView { let mut items = vec![]; for ix in range { - items.push(this.render_card(&announcements[ix], cx)); + let announcement: &Announcement = &announcements[ix]; + let activity = last_activity.get(&announcement.addr()).copied(); + items.push(this.render_card(announcement, activity, cx)); } items