From 1d44338037c5b44a0e019a5fcdbe4dedb2f1cd43 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 25 Sep 2026 17:34:39 +0700 Subject: [PATCH 1/4] prepare --- CHANGELOG.md | 1 + docs/repo-updated-timestamp.md | 146 +++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 docs/repo-updated-timestamp.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe5879..9409808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Added - Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise +- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default ### Changed diff --git a/docs/repo-updated-timestamp.md b/docs/repo-updated-timestamp.md new file mode 100644 index 0000000..84ad85e --- /dev/null +++ b/docs/repo-updated-timestamp.md @@ -0,0 +1,146 @@ +# Repository card "Updated" timestamps are stale until first open + +Feedback under investigation: + +> My repositories that I updated yesterday show up as being updated like 6 months ago, +> but then when I open them and they get cloned that timestamp is fixed, which means +> you're not reading the repository state event, only the date of the repository +> announcement event and the git history? + +## Summary + +The reviewer is essentially right. The backend *has* logic to include kind `30618` +repository state events in the "last activity" timestamp, but that logic only reads +events that are **already present in the local nostr database**. The app never +globally fetches state events for repositories the user has not opened, so for those +the timestamp falls back to the announcement's `created_at` — the repository's +creation date. Opening (and cloning) the repository fetches the state event, which is +why the timestamp corrects itself afterwards. + +The "git history" part of the hypothesis is not accurate for this label: no +git-derived timestamp feeds the repository card. The only git-derived times in the UI +are commit timestamps in commit lists and diffs, which are unrelated. + +This is a data-coverage bug, not a missing computation. The code that aggregates state +events is correct; it is simply starved of input on the explore list. + +## Where the timestamp comes from + +The label is rendered in `crates/workspace/src/views/repo_list.rs` (L209-212): + +```rust +let activity = last_activity + .map(relative_time) + .map(|label| SharedString::from(format!("Updated {label}"))) +``` + +`last_activity` is computed in `crates/signed_state/src/repos.rs`, +`RepoListStore::run_refresh` (L192-235), as a max over three sources: + +| Source | Code | Availability | +|---|---|---| +| `announcement.created_at` (kind 30617) | L195-198 | always; seeded for every listed repo | +| kind 30618 repository state events | L200-213 | only events already in the local database | +| NIP-34 activity (issues, PRs, patches, statuses, comments) | L215-235 | only local database, windowed to 90 days (`ACTIVITY_WINDOW`, L13-14) | + +The critical gap is the global sync scope. `RepoListStore::subscribe_remote` +(`crates/signed_state/src/repos.rs` L131-139) only syncs: + +```rust +backend.sync_bootstrap(filters::all_announcements(), cx); // kind 30617 only +backend.sync_bootstrap(filters::deletions(), cx); +``` + +State events are fetched **per repository, on open**, via `RepoStore`: + +- `repo_filters` (`crates/signed_state/src/repo.rs` L254-270) queries + `[GitRepoAnnouncement, RepoState]` plus activity and deletions. +- `subscribe_remote` (L296-306) runs those filters against the bootstrap relays. +- `connect_announced_relays` (L272-294) runs a negentropy sync of the same filters + against the relays in the announcement's NIP-34 `relays` tag. +- Both fire from `RepoStore::new`'s deferred initialization (L93-112). + +Publishes only ever create a state event, never re-publish the announcement: +`sign_state_event` in `crates/signed_state/src/backend.rs` (L1655-1682) and the +fan-out at L907-918. A repository created 6 months ago and pushed yesterday therefore +has an announcement dated 6 months ago and a state event dated yesterday. + +```mermaid +flowchart TD + A[App start / login] --> B[RepoListStore sync: 30617 announcements + deletions] + B --> C[Explore card: Updated = announcement created_at] + C --> D{Repo opened?} + D -- no --> C + D -- yes --> E[RepoStore fetches 30617 + 30618 from bootstrap + announced relays] + E --> F[State event stored in local DB] + F --> G[BackendEvent NostrUpdate RepoState] + G --> H[RepoListStore refreshes, last_activity recomputed] + H --> I[Card shows push date] +``` + +## Why the report reproduces exactly + +1. The announcement (kind 30617) is addressable and effectively static; pushes replace + only the state event (kind 30618). +2. On a database that has never fetched that repository (fresh install, another device, + or wasm where the database is in-memory per session), only the announcement is + known, so the card shows the creation date. +3. Opening the repository triggers the per-repo fetch; the state event lands in the + database, and `RepoListStore` does react to `RepoState` updates + (`crates/signed_state/src/repos.rs` L69-82), so the card corrects itself. + +## Secondary issues found in the same area + +1. **`RepoStore` ignores live state events.** + `subscribe_backend` (`crates/signed_state/src/repo.rs` L195-239) matches relay + updates via `Update.coordinate`, which is built from `a` tags only + (`crates/signed_nostr/src/update.rs` L13-21). A kind `30618` event carries `d`, not + `a`, and the kind check only accepts `GitRepoAnnouncement`. A state update arriving + from a relay therefore does not refresh an open repository panel's `head`. + `RepoListStore` is unaffected because its check is kind-based. + +2. **Locally published state events refresh nothing.** + `BackendEvent::Published` is ignored for `RepoState` by both stores + (`crates/signed_state/src/repos.rs` L83-92 and `crates/signed_state/src/repo.rs` + L221-231). The comment in `crates/signed_state/src/backend.rs` L907-909 + ("publishing notifies the repository views") is inaccurate: after an in-app push, + the explore card stays stale until the next unrelated refresh, sync or restart, + even though the event is in the local database. + +3. **Usage counts are similarly under-scoped.** + The Popular ranking (`crates/signed_state/src/repos.rs` L239-261) counts only + issues, PRs and patches present locally, i.e. mostly repositories the user has + opened. + +4. **The Recent tab is not "recently updated".** + `RepoFilter::Recent` (`crates/workspace/src/views/repo_list.rs` L73-84) truncates the + announcement-sorted list (`crates/signed_state/src/repos.rs` L189-190), so it orders + by creation, not by `last_activity`. + +5. **The 90-day activity window is deliberate.** + Activity-driven bumps decay after `ACTIVITY_WINDOW`; only state events would bump + indefinitely once fetched (L13-14). + +## Remediation options + +- **A. Global state sync (smallest change).** + Add `sync_bootstrap(Filter::new().kind(Kind::RepoState))` alongside the announcement + sync in `RepoListStore::subscribe_remote`. Kind `30618` is addressable, so relays + keep one event per repository — volume comparable to announcements. Caveat: coverage + depends on whether state events reach the bootstrap relays; if a grasp server is the + only holder, those repositories remain unseen. + +- **B. Targeted fetch for the user's own repositories.** + In `sync_inbox` (`crates/signed_state/src/backend.rs` L1126-1152), where the user's + own repositories' relays are already connected, also sync `filters::state(addr)` per + own repository. This directly addresses "my repositories updated elsewhere" at low + cost. Note the existing race: `sync_inbox` reads the announcement list right after + login, which may not be loaded yet. + +- **C. Reactivity fixes.** + Include `Kind::RepoState` in the `Published` refresh conditions, and in + `RepoStore::subscribe_backend` either match kind plus author, or extend `Update` to + carry the `d`-tag identifier. + +Suggested combination: A + C, plus B if own-repository freshness on fresh profiles +matters. -- 2.54.0 From 42c1229a90778294980ae73ec5bf7c1126a3530b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 25 Sep 2026 17:36:35 +0700 Subject: [PATCH 2/4] fix: date repository cards from state events, not announcements The explore list fell back to the announcement created_at because kind 30618 state events were only fetched per repository, on open. Sync them globally and refresh the list on published state events, so a card shows the last push without the user cloning first. Open repository panels now also react to state events from relays and to locally published state events. --- crates/signed_core/src/filters.rs | 5 +++++ crates/signed_state/src/repo.rs | 16 ++++++++++++---- crates/signed_state/src/repos.rs | 9 ++++++++- docs/repo-updated-timestamp.md | 26 ++++++++++++++------------ 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 829a0ab..678cf50 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -184,6 +184,11 @@ pub fn all_announcements() -> Filter { Filter::new().kind(Kind::GitRepoAnnouncement) } +/// All repository state events, carrying each repository's refs and last push time. +pub fn all_states() -> Filter { + Filter::new().kind(Kind::RepoState) +} + /// How far back deletion requests are fetched and stored. const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 05c9a86..82b03da 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -207,7 +207,11 @@ impl RepoStore { update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; let coordinate = update.coordinate.as_ref() == Some(addr); let author = update.author == addr.public_key; - let kind = update.kind == Kind::GitRepoAnnouncement; + // Announcements and state share the owner and identifier. + // State events carry `d`, not `a`, so `coordinate` misses them. + let authored = (update.kind == Kind::GitRepoAnnouncement + || update.kind == Kind::RepoState) + && author; // NIP-22 comments carry no `a` tag. // Coordinate matching fails for them. // Any comment may reference this repository's roots. @@ -216,18 +220,22 @@ impl RepoStore { // Any status event may reference a root of this repository. let status = RepoStatus::from_kind(update.kind).is_some(); - deletion || coordinate || (author && kind) || comment || status + deletion || coordinate || authored || comment || status }), BackendEvent::Published(event) => { - let kind = event.kind == Kind::GitRepoAnnouncement; + let announcement = event.kind == Kind::GitRepoAnnouncement; let author = event.pubkey == addr.public_key; let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr); + // State events carry their repository id in the `d` tag. + let state = event.kind == Kind::RepoState + && author + && event.tags.identifier().as_deref() == Some(addr.identifier.as_str()); // Locally published deletions may target any event of this repository. // Refresh so they take effect immediately, like relay deletions. let deletion = event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; - coordinate || (kind && author) || deletion + coordinate || (announcement && author) || state || deletion } _ => false, }; diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index 3c4758f..df35679 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -83,12 +83,16 @@ impl RepoListStore { BackendEvent::Published(event) => { let announcement = event.kind == Kind::GitRepoAnnouncement; + // Locally published state events advance last activity. + // Refresh so a push made in this session dates the card immediately. + let state = event.kind == Kind::RepoState; + // Locally published deletions are already in the local database. // Refresh so they take effect immediately, like relay deletions. let deletion = event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; - announcement || deletion + announcement || state || deletion } // Only a completed sync refreshes the list. // Progress ticks would re-scan the whole database several times @@ -133,6 +137,9 @@ impl RepoListStore { backend.update(cx, |backend, cx| { backend.sync_bootstrap(filters::all_announcements(), cx); + // State events carry the last push time, so syncing them globally + // lets the list date every repository without the user opening it. + backend.sync_bootstrap(filters::all_states(), cx); // Deletion requests, NIP-09/62, must be known before any announcement is shown. backend.sync_bootstrap(filters::deletions(), cx); }); diff --git a/docs/repo-updated-timestamp.md b/docs/repo-updated-timestamp.md index 84ad85e..9d1799e 100644 --- a/docs/repo-updated-timestamp.md +++ b/docs/repo-updated-timestamp.md @@ -123,24 +123,26 @@ flowchart TD ## Remediation options -- **A. Global state sync (smallest change).** - Add `sync_bootstrap(Filter::new().kind(Kind::RepoState))` alongside the announcement - sync in `RepoListStore::subscribe_remote`. Kind `30618` is addressable, so relays - keep one event per repository — volume comparable to announcements. Caveat: coverage - depends on whether state events reach the bootstrap relays; if a grasp server is the - only holder, those repositories remain unseen. +- **A. Global state sync (smallest change). — implemented.** + `RepoListStore::subscribe_remote` now also syncs `filters::all_states()` + (`Filter::new().kind(Kind::RepoState)`) alongside the announcement sync. Kind `30618` + is addressable, so relays keep one event per repository — volume comparable to + announcements. Caveat: coverage depends on whether state events reach the bootstrap + relays; if a grasp server is the only holder, those repositories remain unseen. -- **B. Targeted fetch for the user's own repositories.** +- **B. Targeted fetch for the user's own repositories. — not implemented.** In `sync_inbox` (`crates/signed_state/src/backend.rs` L1126-1152), where the user's own repositories' relays are already connected, also sync `filters::state(addr)` per own repository. This directly addresses "my repositories updated elsewhere" at low cost. Note the existing race: `sync_inbox` reads the announcement list right after login, which may not be loaded yet. -- **C. Reactivity fixes.** - Include `Kind::RepoState` in the `Published` refresh conditions, and in - `RepoStore::subscribe_backend` either match kind plus author, or extend `Update` to - carry the `d`-tag identifier. +- **C. Reactivity fixes. — implemented.** + `Kind::RepoState` is now included in the `BackendEvent::Published` refresh condition of + both `RepoListStore` and `RepoStore`. `RepoStore::subscribe_backend` also matches relay + state updates by owner plus kind — state events carry `d`, not `a`, so coordinate + matching misses them — and locally published state updates by owner plus the `d` + identifier. -Suggested combination: A + C, plus B if own-repository freshness on fresh profiles +A + C are implemented; B remains available if own-repository freshness on fresh profiles matters. -- 2.54.0 From 333989813eeca69dfbe1bac81b308fd0e52ff688 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 25 Sep 2026 20:01:28 +0700 Subject: [PATCH 3/4] sync user own repos --- crates/signed_state/src/repo.rs | 66 ++++++-------------------------- crates/signed_state/src/repos.rs | 52 +++++++++++++++++-------- docs/repo-updated-timestamp.md | 15 ++++---- 3 files changed, 53 insertions(+), 80 deletions(-) diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 82b03da..25cdf78 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -205,19 +205,15 @@ impl RepoStore { // Deletions may target any event of this repository. let deletion = update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; + let coordinate = update.coordinate.as_ref() == Some(addr); let author = update.author == addr.public_key; - // Announcements and state share the owner and identifier. - // State events carry `d`, not `a`, so `coordinate` misses them. + let authored = (update.kind == Kind::GitRepoAnnouncement || update.kind == Kind::RepoState) && author; - // NIP-22 comments carry no `a` tag. - // Coordinate matching fails for them. - // Any comment may reference this repository's roots. + let comment = update.kind == Kind::Comment; - // Status events may omit their `a` tag, NIP-34. - // Any status event may reference a root of this repository. let status = RepoStatus::from_kind(update.kind).is_some(); deletion || coordinate || authored || comment || status @@ -226,12 +222,11 @@ impl RepoStore { let announcement = event.kind == Kind::GitRepoAnnouncement; let author = event.pubkey == addr.public_key; let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr); - // State events carry their repository id in the `d` tag. + let state = event.kind == Kind::RepoState && author && event.tags.identifier().as_deref() == Some(addr.identifier.as_str()); - // Locally published deletions may target any event of this repository. - // Refresh so they take effect immediately, like relay deletions. + let deletion = event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; @@ -264,8 +259,6 @@ impl RepoStore { /// Announcement, state, activity and deletions targeting it. fn repo_filters(addr: &RepoAddr) -> Vec { let mut filters = vec![ - // Announcement and state share author and identifier. - // They combine into one filter, one fewer negentropy reconciliation per relay. Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .author(addr.public_key) @@ -314,9 +307,6 @@ impl RepoStore { } /// Re-query the local database and update all fields. - /// - /// Runs immediately. The backend pump already batches the relay events that - /// trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { if self.addr.is_none() { return; @@ -353,11 +343,10 @@ impl RepoStore { let deletions = Deletions::from_events(deletion_events); - // Parse and sort off the main thread. - // Only plain data crosses back into the entity. let all_announcements = announcements .into_iter() .filter(|e| !deletions.is_deleted(e)); + let announcement = latest(all_announcements) .as_ref() .and_then(Announcement::from_event); @@ -382,8 +371,6 @@ impl RepoStore { } } - // NIP-22 comments reference their root via an `E` or `e` tag. - // Not the repository's `a` tag, so query them by the root events. let mut seen_comments: HashSet = comments.iter().map(|e| e.id).collect(); let db = client.database(); @@ -401,8 +388,6 @@ impl RepoStore { } } - // Status events may omit their `a` tag. - // Query them by the root events they reference too. let mut seen_statuses: HashSet = statuses.iter().map(|e| e.id).collect(); let db = client.database(); @@ -425,9 +410,6 @@ impl RepoStore { sort_newest_first(&mut pull_requests); sort_oldest_first(&mut comments); - // Resolve every root's status once here. - // Render paths do HashMap lookups instead of per-root status scans. - // Those scans are quadratic, with an allocation per pair. let maintainers = announcement .as_ref() .map(Announcement::effective_maintainers) @@ -485,25 +467,13 @@ impl RepoStore { }; let again = this.update(cx, |this, cx| { - // Compare before moving the freshly queried data in, so a pass - // that found nothing new does not notify observers. The store - // is polled in bursts while a sync is in flight; notifying on - // every identical pass would re-render the repository panel - // several times for no visible change. - // - // The first pass is the exception: it must notify even when it - // found nothing, so views can leave their loading state and show - // the empty result. - // - // Keep the open-time hint until that first pass has confirmed what - // the database holds; afterwards the database is the truth, - // including a deletion. let keep_hint = announcement.is_none() && !this.loaded; - let first_pass = !this.loaded; + let head_changed = state .as_ref() .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); + let changed = first_pass || (!keep_hint && this.announcement != announcement) || head_changed @@ -524,6 +494,7 @@ impl RepoStore { .as_ref() .map(|a| a.relays.clone()) .unwrap_or_default(); + this.connect_announced_relays(&relays, cx); if let Some((_, head)) = state { @@ -539,10 +510,6 @@ impl RepoStore { this.open_pr_count = open_pr_count; this.loaded = true; - // Comments and statuses without an `a` tag. - // None are addressed to the repository. - // Fetch them by the root events they reference. - // Use the bootstrap relays and the relays this repository announced. let roots = this .issues .iter() @@ -559,13 +526,13 @@ impl RepoStore { if !new_roots.is_empty() { this.root_fetches.extend(new_roots.iter().copied()); - // Batch the per-root filters. - // One filter per root costs a negentropy reconciliation per relay. + let mut root_filters = filters::comments_for(new_roots.clone()); root_filters.push(filters::statuses_for(new_roots.iter().copied())); let announced: Vec = this.repo_relays.iter().cloned().collect(); let backend = Backend::global(cx); + backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(root_filters.clone(), cx); backend.connect_repo_relays(announced, root_filters, cx); @@ -579,8 +546,6 @@ impl RepoStore { this.refresh.finish() })?; - // Requests that arrived while the refresh was running. - // They are coalesced into one follow-up refresh. if again { this.update(cx, |this, cx| this.refresh(cx))?; } @@ -596,25 +561,16 @@ impl RepoStore { } /// Number of open issues. - /// - /// Issues whose resolved status is [`RepoStatus::Open`]. - /// Issues without status events default to open. pub fn issue_count(&self) -> usize { self.open_issue_count } /// Number of open pull requests. - /// - /// Only root PR events count, PR updates do not. - /// They must resolve to [`RepoStatus::Open`]. pub fn pull_request_count(&self) -> usize { self.open_pr_count } /// Whether `user` is the author or owner of this repository. - /// - /// The author is the public key of the repository address. - /// Only the author may manage pull requests, close, reopen or merge. pub fn is_author(&self, user: &PublicKey) -> bool { self.addr .as_ref() diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index df35679..0552e43 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -47,6 +47,8 @@ pub struct RepoListStore { /// /// Used for the Popular ranking of the explore list. pub counts: Arc>, + /// Own repositories whose state events were fetched from their announced relays. + state_synced_repos: HashSet, refresh: RefreshGate, _subscription: Subscription, } @@ -82,21 +84,16 @@ impl RepoListStore { }), BackendEvent::Published(event) => { let announcement = event.kind == Kind::GitRepoAnnouncement; - - // Locally published state events advance last activity. - // Refresh so a push made in this session dates the card immediately. let state = event.kind == Kind::RepoState; - - // Locally published deletions are already in the local database. - // Refresh so they take effect immediately, like relay deletions. let deletion = event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; announcement || state || deletion } - // Only a completed sync refreshes the list. - // Progress ticks would re-scan the whole database several times - // per sync to reveal entries incrementally. + BackendEvent::SignerChanged => { + this.state_synced_repos.clear(); + true + } BackendEvent::Synced => true, _ => false, }; @@ -118,6 +115,7 @@ impl RepoListStore { announcements: Arc::new(Vec::new()), last_activity: Arc::new(HashMap::new()), counts: Arc::new(HashMap::new()), + state_synced_repos: HashSet::new(), refresh: RefreshGate::default(), _subscription: subscription, } @@ -145,6 +143,30 @@ impl RepoListStore { }); } + /// Fetch the state events of the user's own repositories. + fn sync_own_repo_states(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); + let Some(me) = backend.read(cx).current_user() else { + return; + }; + + let pending: Vec<(RepoAddr, Vec)> = self + .announcements + .iter() + .filter(|announcement| announcement.owner == me && !announcement.relays.is_empty()) + .map(|announcement| (announcement.addr(), announcement.relays.clone())) + .filter(|(addr, _)| !self.state_synced_repos.contains(addr)) + .collect(); + + for (addr, relays) in pending { + self.state_synced_repos.insert(addr.clone()); + + backend.update(cx, |backend, cx| { + backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx); + }); + } + } + /// Re-query the local database. /// /// Runs immediately. The backend pump already batches the relay events that @@ -196,9 +218,6 @@ impl RepoListStore { let mut announcements: Vec = by_repo.into_values().collect(); announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); - // Last activity per repository. - // State updates count, and all NIP-34 activity events. - // The activity events are patches, PRs, issues and statuses. let mut last_activity: HashMap = announcements .iter() .map(|a| (a.addr(), a.created_at)) @@ -224,6 +243,7 @@ impl RepoListStore { let activity_filter = Filter::new() .kinds(filters::ACTIVITY_KINDS) .since(Timestamp::now() - ACTIVITY_WINDOW); + for event in client.database().query(activity_filter).await? { if deletions.is_deleted(&event) { continue; @@ -232,8 +252,6 @@ impl RepoListStore { if addr.kind != Kind::GitRepoAnnouncement { continue; } - // Skip events for repos we do not list. - // The map cannot grow beyond the number of announcements. let Some(entry) = last_activity.get_mut(&addr) else { continue; }; @@ -246,13 +264,12 @@ impl RepoListStore { let mut counts: HashMap = HashMap::new(); let count_filter = Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]); + for event in client.database().query(count_filter).await? { if deletions.is_deleted(&event) { continue; } for addr in event.tags.coordinates() { - // Skip events for repos we do not list. - // The map cannot grow beyond the number of announcements. if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr) { continue; @@ -285,6 +302,7 @@ impl RepoListStore { this.announcements = Arc::new(announcements); this.last_activity = Arc::new(last_activity); this.counts = Arc::new(counts); + this.sync_own_repo_states(cx); cx.notify(); this.refresh.finish() diff --git a/docs/repo-updated-timestamp.md b/docs/repo-updated-timestamp.md index 9d1799e..bf868ef 100644 --- a/docs/repo-updated-timestamp.md +++ b/docs/repo-updated-timestamp.md @@ -130,12 +130,12 @@ flowchart TD announcements. Caveat: coverage depends on whether state events reach the bootstrap relays; if a grasp server is the only holder, those repositories remain unseen. -- **B. Targeted fetch for the user's own repositories. — not implemented.** - In `sync_inbox` (`crates/signed_state/src/backend.rs` L1126-1152), where the user's - own repositories' relays are already connected, also sync `filters::state(addr)` per - own repository. This directly addresses "my repositories updated elsewhere" at low - cost. Note the existing race: `sync_inbox` reads the announcement list right after - login, which may not be loaded yet. +- **B. Targeted fetch for the user's own repositories. — implemented.** + `RepoListStore` fetches each own repository's state event from the relays that + repository announces, once the list discovers it. Driving this from the list rather + than `sync_inbox` sidesteps the login race described above: whenever the announcement + list is (re)loaded, any own repository not yet fetched is covered. The fetched-set is + cleared on `SignerChanged`, so a login or account switch re-fetches. - **C. Reactivity fixes. — implemented.** `Kind::RepoState` is now included in the `BackendEvent::Published` refresh condition of @@ -144,5 +144,4 @@ flowchart TD matching misses them — and locally published state updates by owner plus the `d` identifier. -A + C are implemented; B remains available if own-repository freshness on fresh profiles -matters. +A + B + C are implemented. -- 2.54.0 From 7a6877ab0ba4d970be02d3770283223e7dfdd278 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 25 Sep 2026 20:11:20 +0700 Subject: [PATCH 4/4] clean up --- CHANGELOG.md | 1 + crates/signed_state/src/backend.rs | 24 ++--- crates/signed_state/src/repos.rs | 2 - docs/repo-updated-timestamp.md | 147 ----------------------------- 4 files changed, 11 insertions(+), 163 deletions(-) delete mode 100644 docs/repo-updated-timestamp.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9409808..0e8d330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixed - Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size +- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date ### Removed diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 362c820..1c461b4 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1229,7 +1229,7 @@ impl Backend { self.sync_progress = Some((0, 0)); cx.notify(); - let progress_task: Task> = cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { let mut last_percent: u64 = 0; while rx.changed().await.is_ok() { @@ -1254,23 +1254,18 @@ impl Backend { } } - Ok(()) - }); - progress_task.detach(); + Ok::<(), anyhow::Error>(()) + }) + .detach(); let sync = cx.background_spawn(async move { let opts = SyncOptions::default().progress(tx); sync_bootstrap_only(&client, filter, opts).await }); - let task: Task> = cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { match sync.await { - Ok(summary) => { - log::debug!( - "sync done: {} received, {} sent", - summary.received.len(), - summary.sent.len() - ); + Ok(_) => { this.update(cx, |this, cx| { this.sync_progress = None; cx.emit(BackendEvent::Synced); @@ -1284,9 +1279,10 @@ impl Backend { })?; } } - Ok(()) - }); - task.detach(); + + Ok::<(), anyhow::Error>(()) + }) + .detach(); } /// Emit [`BackendEvent::Published`] for cross-store invalidation. diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index 0552e43..f13ee91 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -135,8 +135,6 @@ impl RepoListStore { backend.update(cx, |backend, cx| { backend.sync_bootstrap(filters::all_announcements(), cx); - // State events carry the last push time, so syncing them globally - // lets the list date every repository without the user opening it. backend.sync_bootstrap(filters::all_states(), cx); // Deletion requests, NIP-09/62, must be known before any announcement is shown. backend.sync_bootstrap(filters::deletions(), cx); diff --git a/docs/repo-updated-timestamp.md b/docs/repo-updated-timestamp.md deleted file mode 100644 index bf868ef..0000000 --- a/docs/repo-updated-timestamp.md +++ /dev/null @@ -1,147 +0,0 @@ -# Repository card "Updated" timestamps are stale until first open - -Feedback under investigation: - -> My repositories that I updated yesterday show up as being updated like 6 months ago, -> but then when I open them and they get cloned that timestamp is fixed, which means -> you're not reading the repository state event, only the date of the repository -> announcement event and the git history? - -## Summary - -The reviewer is essentially right. The backend *has* logic to include kind `30618` -repository state events in the "last activity" timestamp, but that logic only reads -events that are **already present in the local nostr database**. The app never -globally fetches state events for repositories the user has not opened, so for those -the timestamp falls back to the announcement's `created_at` — the repository's -creation date. Opening (and cloning) the repository fetches the state event, which is -why the timestamp corrects itself afterwards. - -The "git history" part of the hypothesis is not accurate for this label: no -git-derived timestamp feeds the repository card. The only git-derived times in the UI -are commit timestamps in commit lists and diffs, which are unrelated. - -This is a data-coverage bug, not a missing computation. The code that aggregates state -events is correct; it is simply starved of input on the explore list. - -## Where the timestamp comes from - -The label is rendered in `crates/workspace/src/views/repo_list.rs` (L209-212): - -```rust -let activity = last_activity - .map(relative_time) - .map(|label| SharedString::from(format!("Updated {label}"))) -``` - -`last_activity` is computed in `crates/signed_state/src/repos.rs`, -`RepoListStore::run_refresh` (L192-235), as a max over three sources: - -| Source | Code | Availability | -|---|---|---| -| `announcement.created_at` (kind 30617) | L195-198 | always; seeded for every listed repo | -| kind 30618 repository state events | L200-213 | only events already in the local database | -| NIP-34 activity (issues, PRs, patches, statuses, comments) | L215-235 | only local database, windowed to 90 days (`ACTIVITY_WINDOW`, L13-14) | - -The critical gap is the global sync scope. `RepoListStore::subscribe_remote` -(`crates/signed_state/src/repos.rs` L131-139) only syncs: - -```rust -backend.sync_bootstrap(filters::all_announcements(), cx); // kind 30617 only -backend.sync_bootstrap(filters::deletions(), cx); -``` - -State events are fetched **per repository, on open**, via `RepoStore`: - -- `repo_filters` (`crates/signed_state/src/repo.rs` L254-270) queries - `[GitRepoAnnouncement, RepoState]` plus activity and deletions. -- `subscribe_remote` (L296-306) runs those filters against the bootstrap relays. -- `connect_announced_relays` (L272-294) runs a negentropy sync of the same filters - against the relays in the announcement's NIP-34 `relays` tag. -- Both fire from `RepoStore::new`'s deferred initialization (L93-112). - -Publishes only ever create a state event, never re-publish the announcement: -`sign_state_event` in `crates/signed_state/src/backend.rs` (L1655-1682) and the -fan-out at L907-918. A repository created 6 months ago and pushed yesterday therefore -has an announcement dated 6 months ago and a state event dated yesterday. - -```mermaid -flowchart TD - A[App start / login] --> B[RepoListStore sync: 30617 announcements + deletions] - B --> C[Explore card: Updated = announcement created_at] - C --> D{Repo opened?} - D -- no --> C - D -- yes --> E[RepoStore fetches 30617 + 30618 from bootstrap + announced relays] - E --> F[State event stored in local DB] - F --> G[BackendEvent NostrUpdate RepoState] - G --> H[RepoListStore refreshes, last_activity recomputed] - H --> I[Card shows push date] -``` - -## Why the report reproduces exactly - -1. The announcement (kind 30617) is addressable and effectively static; pushes replace - only the state event (kind 30618). -2. On a database that has never fetched that repository (fresh install, another device, - or wasm where the database is in-memory per session), only the announcement is - known, so the card shows the creation date. -3. Opening the repository triggers the per-repo fetch; the state event lands in the - database, and `RepoListStore` does react to `RepoState` updates - (`crates/signed_state/src/repos.rs` L69-82), so the card corrects itself. - -## Secondary issues found in the same area - -1. **`RepoStore` ignores live state events.** - `subscribe_backend` (`crates/signed_state/src/repo.rs` L195-239) matches relay - updates via `Update.coordinate`, which is built from `a` tags only - (`crates/signed_nostr/src/update.rs` L13-21). A kind `30618` event carries `d`, not - `a`, and the kind check only accepts `GitRepoAnnouncement`. A state update arriving - from a relay therefore does not refresh an open repository panel's `head`. - `RepoListStore` is unaffected because its check is kind-based. - -2. **Locally published state events refresh nothing.** - `BackendEvent::Published` is ignored for `RepoState` by both stores - (`crates/signed_state/src/repos.rs` L83-92 and `crates/signed_state/src/repo.rs` - L221-231). The comment in `crates/signed_state/src/backend.rs` L907-909 - ("publishing notifies the repository views") is inaccurate: after an in-app push, - the explore card stays stale until the next unrelated refresh, sync or restart, - even though the event is in the local database. - -3. **Usage counts are similarly under-scoped.** - The Popular ranking (`crates/signed_state/src/repos.rs` L239-261) counts only - issues, PRs and patches present locally, i.e. mostly repositories the user has - opened. - -4. **The Recent tab is not "recently updated".** - `RepoFilter::Recent` (`crates/workspace/src/views/repo_list.rs` L73-84) truncates the - announcement-sorted list (`crates/signed_state/src/repos.rs` L189-190), so it orders - by creation, not by `last_activity`. - -5. **The 90-day activity window is deliberate.** - Activity-driven bumps decay after `ACTIVITY_WINDOW`; only state events would bump - indefinitely once fetched (L13-14). - -## Remediation options - -- **A. Global state sync (smallest change). — implemented.** - `RepoListStore::subscribe_remote` now also syncs `filters::all_states()` - (`Filter::new().kind(Kind::RepoState)`) alongside the announcement sync. Kind `30618` - is addressable, so relays keep one event per repository — volume comparable to - announcements. Caveat: coverage depends on whether state events reach the bootstrap - relays; if a grasp server is the only holder, those repositories remain unseen. - -- **B. Targeted fetch for the user's own repositories. — implemented.** - `RepoListStore` fetches each own repository's state event from the relays that - repository announces, once the list discovers it. Driving this from the list rather - than `sync_inbox` sidesteps the login race described above: whenever the announcement - list is (re)loaded, any own repository not yet fetched is covered. The fetched-set is - cleared on `SignerChanged`, so a login or account switch re-fetches. - -- **C. Reactivity fixes. — implemented.** - `Kind::RepoState` is now included in the `BackendEvent::Published` refresh condition of - both `RepoListStore` and `RepoStore`. `RepoStore::subscribe_backend` also matches relay - state updates by owner plus kind — state events carry `d`, not `a`, so coordinate - matching misses them — and locally published state updates by owner plus the `d` - identifier. - -A + B + C are implemented. -- 2.54.0