feat: out-of-box experience #2

Merged
reya merged 64 commits from feat/ui into master 2026-08-25 13:23:08 +00:00
12 changed files with 226 additions and 20 deletions
Showing only changes of commit 322f6f60bc - Show all commits
Generated
+9
View File
@@ -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]]
+14 -1
View File
@@ -21,6 +21,8 @@ pub struct Announcement {
pub euc: Option<String>,
/// Other recognized maintainers.
pub maintainers: Vec<PublicKey>,
/// Hashtags labelling the repository (`t` tags).
pub hashtags: Vec<String>,
}
impl Announcement {
@@ -38,13 +40,20 @@ impl Announcement {
let mut relays: Vec<String> = Vec::new();
let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new();
let mut hashtags: Vec<String> = 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]
+1
View File
@@ -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
+3 -1
View File
@@ -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;
+1 -6
View File
@@ -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.
+71 -8
View File
@@ -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<Vec<Announcement>>,
/// Latest known activity timestamp per repository
/// (announcements, state updates, patches, PRs, issues, statuses).
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
author: Option<PublicKey>,
refreshing: bool,
refresh_dirty: bool,
@@ -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<Announcement> = 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<RepoAddr, Timestamp> = announcements
.iter()
.map(|a| (a.addr(), a.created_at))
.collect();
let state_filter = Filter::new().kind(Kind::RepoState);
for event in client.database().query(state_filter).await? {
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;
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "utils"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
nostr.workspace = true
+5
View File
@@ -0,0 +1,5 @@
mod pubkey;
mod time;
pub use pubkey::shorten_pubkey;
pub use time::relative_time;
+7
View File
@@ -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..])
}
+43
View File
@@ -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");
}
}
+1
View File
@@ -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
+63 -4
View File
@@ -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<Timestamp>,
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<AnyElement> = 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<Self>) -> 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