From 80d4ca754b41c316a37607b8cf70270610b004eb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 26 Sep 2026 15:58:49 +0700 Subject: [PATCH] add strategy setting --- crates/settings/src/settings.rs | 22 ++ docs/event-fetching-strategy.md | 531 ++++++++++++++++++-------------- 2 files changed, 314 insertions(+), 239 deletions(-) diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index a9a7264..bdef5ec 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -18,6 +18,16 @@ pub enum AppearanceMode { Dark, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventFetchingStrategy { + /// Only the relays declared in the repository announcement. + Curated, + /// Repository relays plus every maintainer's NIP-65 relays. + #[default] + Uncensored, +} + /// Fields mirror the gpui-component `Theme` surface customized at startup. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -141,6 +151,7 @@ pub struct CreateRepositorySettings { #[serde(default)] pub struct Settings { pub appearance: AppearanceMode, + pub event_fetching: EventFetchingStrategy, pub theme: ThemeSettings, pub tab_bar: TabBarSettings, pub grasp_servers: GraspServersSettings, @@ -157,6 +168,7 @@ mod tests { fn json_roundtrip_preserves_everything() { let settings = Settings { appearance: AppearanceMode::Dark, + event_fetching: EventFetchingStrategy::Curated, theme: ThemeSettings { radius: 8.0, ..Default::default() @@ -178,9 +190,19 @@ mod tests { serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap(); assert_eq!(settings.appearance, AppearanceMode::Dark); assert_eq!(settings.theme.radius, 4.0); + // Unset fields fall back to their defaults, including the fetching strategy. + assert_eq!(settings.event_fetching, EventFetchingStrategy::Uncensored); // The rest of the theme and the other groups keep their defaults. assert_eq!(settings.theme.light_theme, "Signed Light"); assert_eq!(settings.grasp_servers, GraspServersSettings::default()); assert_eq!(settings.create_repository.default_folder, None); } + + #[test] + fn event_fetching_uses_snake_case() { + let json = serde_json::to_string(&EventFetchingStrategy::Curated).unwrap(); + assert_eq!(json, r#""curated""#); + let parsed: EventFetchingStrategy = serde_json::from_str(r#""uncensored""#).unwrap(); + assert_eq!(parsed, EventFetchingStrategy::Uncensored); + } } diff --git a/docs/event-fetching-strategy.md b/docs/event-fetching-strategy.md index b94edc5..d267c9c 100644 --- a/docs/event-fetching-strategy.md +++ b/docs/event-fetching-strategy.md @@ -1,296 +1,349 @@ -# Proposal: Event Fetching Strategy (`Curated` / `Uncensored`) +# Plan: Event Fetching Strategy (`Curated` / `Uncensored`) -Status: proposal, not implemented. Verified against `docs/backend-audit.md`; -read that document first. +Status: implementation plan, not implemented. This document replaces the +earlier proposal of the same name; it keeps the verified background from it +and `docs/backend-audit.md` (authoritative for current behaviour). -References studied: +Cross-checked against: -- GitWorkshop `main` @ `420c0c3` - (`git clone nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr//gitworkshop`). -- rust-nostr @ `b230cecf9dbb38e0228e6fff4544ed9d261326fc` from `Cargo.lock`. - Every `nostr*` crate, including `nostr-gossip` and `nostr-gossip-memory`, - resolves to that single revision. +- rust-nostr at the revision from `Cargo.lock`, + `b230cecf9dbb38e0228e6fff4544ed9d261326fc` (local checkout + `~/.cargo/git/checkouts/nostr-619b808bb247a9ed/b230cec`). +- GitWorkshop at `420c0c3`. ## Goal -Add a global setting controlling which relays `RepoStore` queries for a -repository's activity, mirroring GitWorkshop's "Event Fetching Strategy": +Mirror GitWorkshop's "Event Fetching Strategy" for the per-repository fetches +in `RepoStore`: -- **Curated** (`repo`): only the relays declared in the repository - announcement. -- **Uncensored** (`outbox`): the repository's declared relays plus every - maintainer's relays, resolved through the NIP-65 outbox model. +- **Curated**: only the relays declared in the repository announcement. +- **Uncensored**: the repository's declared relays plus every maintainer's + NIP-65 relays. + +Global discovery is unchanged in both modes: `RepoListStore` keeps syncing +announcements, states and deletions from `BOOTSTRAP_RELAYS`. ## GitWorkshop reference -Source paths are relative to the cloned repository. - -- `src/services/settings.ts`: `RelayCurationMode = "repo" | "outbox"`, - persisted to `localStorage`, default `"outbox"` (the adjacent doc comment - wrongly says `repo`; the constant is authoritative). -- `src/pages/Settings.tsx` (`RelayCurationSection`, `CURATION_OPTIONS`): two - selectable cards, "Curated" and "Uncensored". -- `src/hooks/useResolvedRepository.ts` (layers 3-4): - - `repoRelayGroup` (`RepositoryRelayGroup`): the repo announcement's - `relays` tag. The **Curated** frontier. - - `extraRelaysForMaintainerMailboxCoverage`: a delta relay group built from - every maintainer's NIP-65 **outbox + inbox** relays, excluding relays - already in `repoRelayGroup`. The **Uncensored** addition. -- `src/services/nostr.ts`: - - `resolveMailboxes(pubkey)` reads NIP-65 kind `10002` with a 3 s timeout and - caps to `MAX_RESOLVED_RELAYS = 5` per direction. - - `nip34RepoLoader` / `nip34SupplementalRelayLoader` subscribe to the base - **and** extra groups in `outbox` mode, and additionally query each - discovered item's **author** inbox relays (`MAX_AUTHOR_INBOX_RELAYS = 3`). - - Deletions come from base + extra relays in `outbox` mode. - -## How the pinned Nostr SDK handles NIP-65 - -The SDK ships a full NIP-65 outbox model. `signed` configures it but, as of -the audit, no code path exercises it: every fetch uses an explicit relay list. - -- `crates/signed_nostr/src/backend.rs` builds the client with - `.gossip(NostrGossipMemory::unbounded())` and - `.gossip_config(GossipConfig::default().no_background_refresh())`. -- `nostr-sdk/src/client/builder.rs`: - - `GossipConfig { limits, allowed, sync/fetch timeouts, fetch_chunks, background_refresh }`. - `GossipRelayLimits` defaults: read 3, write 3, hint 1, most-used 1, - NIP-17 3, per user. `GossipAllowedRelays` gates onion/local/plain-TLS, not - read/write. - - `no_background_refresh()` disables the periodic refresher that re-fetches - NIP-65 lists for tracked and DB-seen keys. -- `nostr-sdk/src/client/api/req_target.rs`: `ReqTarget::auto` (from a `Filter`, - `Vec`, or `[Filter; N]`) vs `ReqTarget::manual` (from a relay map or - `(relay, filters)` pairs). -- `nostr-sdk/src/client/api/util.rs::build_targets` and - `nostr-sdk/src/client/api/sync.rs`: - - **Auto** target + gossip configured -> filters are broken down by NIP-65. - - **Manual** target (e.g. `client.sync(f).with(urls)`, or an explicit - `HashMap>`), or gossip unset -> no breakdown. -- `nostr-sdk/src/client/gossip/updater.rs::gossip_break_down_filter`: - - Extracts pubkeys via `Filter::extract_public_keys` - (`nostr/src/filter/mod.rs`), which reads **only `authors` and `#p`**. - - Ensures those pubkeys' NIP-65 lists are fresh (`ensure_gossip_public_keys_fresh` - syncs kind `10002` over `DISCOVERY | READ` relays), then breaks the filter - down (`gossip/resolver.rs::break_down_filter`): - - `authors` only -> each author's **write** relays (+ hints, + most-received). - - `#p` only -> each pubkey's **read** relays (+ hints, + most-received). - - both -> union of read + write relays. - - neither (`Other`) or no relay found (`Orphan`) -> the pool's **read** - relays (`pool/mod.rs::read_relay_urls`). - - Resolved gossip relays are added with `RelayCapabilities::GOSSIP` - (`relay/capabilities.rs`), which does **not** include `READ`. -- `gossip/nostr-gossip/src/lib.rs` exposes the public `NostrGossip` trait: - `get_best_relays(pubkey, BestRelaySelection, GossipAllowedRelays)`, plus - `process`. `NostrGossipMemory` (re-exported via - `nostr_gossip_memory::prelude`) implements it, so a retained handle can - resolve a pubkey's relays directly. It is a pure store read: it does not - fetch the kind `10002` itself. The store is primed by the freshness step of - an Auto request (or by any kind `10002` that arrives for another reason), - and marks a key outdated 24 h after its last fetch attempt. -- `client.send_event(event)` without `.broadcast()` is itself NIP-65 aware: - with gossip configured it resolves the author's outbox and the `p` tags' - inboxes. `signed` calls `.broadcast()` everywhere, so publishing goes to the - pool's write relays and is unaffected by this setting. - -### What this means for repository filters - -`RepoStore::repo_filters` mixes filter shapes. It is the **filter** (not the -event) that `extract_public_keys` reads, so filter tags decide what the SDK can -resolve: - -- announcement + state: `.author(owner).identifier(id)` -> has `authors`, so an - Auto request resolves the owner's **write** relays. -- `activity(addr)`: `.coordinate(addr)` only (`#a`). The events do carry the repo - owner in a lowercase `p` tag, per NIP-34 - issues `1621`, patches `1617`, PRs - `1618`, PR updates `1619`, statuses `1630..=1633` (see the SDK's - `nostr/src/nips/nip34.rs` builders and `RepoStore::set_status` / - `publish_patch_series`) - but the filter never asks for `p`, so - `extract_public_keys` is empty and the filter is classified `Other`. -- `deletions_for_repo(addr)`: one `.author(owner)` filter (owner write relays) - and one `.coordinate(addr)` filter (`Other`). - -Adding `.pubkey(owner)` (or all `effective_maintainers()`) to the activity -filter makes an Auto request resolve those pubkeys' **read** relays (the -`#p`-only branch, plus hints and most-received). Two caveats: - -- `Filter::pubkey` ANDs with `.coordinate`. Root kinds and statuses carry the - owner's `p`, per the SDK's `nostr/src/nips/nip34.rs` builders and - `RepoStore::set_status` / `publish_patch_series`, but kind-1111 comments set - `p` to the parent author (`nostr/src/nips/nip22.rs::as_vec` emits the root - as uppercase `E`/`K`/`P` and the parent as lowercase `e`/`k`/`p`), which for - a top-level comment is the issue/PR author, not the repository owner. A - single `#a` + `#p` filter can drop comments. Keep them as two filters (a `#a` - filter plus a `#p` filter, unioned in the database) or accept the loss. -- `#p` yields the **read** (inbox) relays only. GitWorkshop's extra group is - outbox **and** inbox, so covering the write side still needs an explicit - target. +- `src/services/settings.ts:197-210`: `RelayCurationMode = "repo" | "outbox"`, + persisted to `localStorage`, default `"outbox"` (Uncensored). +- `src/pages/Settings.tsx:85-100`: two selectable cards, "Curated" and + "Uncensored". +- `src/hooks/useResolvedRepository.ts`: `repoRelayGroup` is the announcement's + `relays` tag. `extraRelaysForMaintainerMailboxCoverage` is a delta group of + every maintainer's NIP-65 outbox + inbox relays, excluding relays already in + the repo group, capped at `MAX_MAILBOX_RELAYS_PER_USER = 3` per direction + (`addMailboxRelaysToGroup`). The pubkey set is the announcement chain's + `discoveryPubkeys` (maintainers, moderators, owner). +- Gating: `src/hooks/useNip34Loaders.ts:447` and + `src/pages/repo/RepoLayout.tsx:319-360` only subscribe the item loaders to + the maintainer group when the mode is `outbox`. Curated never touches + maintainer relays. ## Current behaviour in `signed` -Global discovery is unchanged: `RepoListStore::subscribe_remote` -(`crates/signed_state/src/repos.rs`) syncs `all_announcements`, `all_states`, -and `deletions` from `BOOTSTRAP_RELAYS`. +`crates/signed_state/src/repo.rs`: -Per-repository fetching in `crates/signed_state/src/repo.rs`: +- `subscribe_remote` -> `Backend::subscribe_bootstrap`: one-shot REQ of + `repo_filters` on `BOOTSTRAP_RELAYS` (manual target). +- `connect_announced_relays` -> `Backend::connect_repo_relays`: add and + connect the announcement's `relays` tag, then negentropy-`sync` + `repo_filters` (manual target). Deduped by the `repo_relays` set. Called + from `new`, `announce`, and on every announcement change in `run_refresh`. +- `run_refresh` also fetches comments (`filters::comments_for`) and statuses + (`filters::statuses_for`) for each newly seen root from bootstrap + + `repo_relays`. +- No fetch path uses NIP-65: the gossip store is configured but never + consulted (`docs/backend-audit.md`, section 2). -- `RepoStore::subscribe_remote` -> `Backend::subscribe_bootstrap`, a one-shot - REQ of `repo_filters` on `BOOTSTRAP_RELAYS`. It passes an explicit - `HashMap<&str, Vec>`, i.e. a **Manual** target, so gossip is skipped. -- `RepoStore::connect_announced_relays` -> `Backend::connect_repo_relays`, - which connects the announcement's `relays` tag and negentropy-`sync`s - `repo_filters` with `.with(relays.iter())`, again a **Manual** target. -- `RepoStore::run_refresh` reads results back from the local database. - -Net effect: a repository's activity and per-repo deletions are fetched from the -global bootstrap relays and its announced relays. Although `signed` configures a -gossip store, **every current fetch path uses manual targets and bypasses the -SDK's NIP-65 handling**. - -## Proposed behaviour +## Design ```mermaid flowchart TD - A[RepoStore opens repo] --> B{Event fetching strategy} - B -->|Curated| C[Repo-declared relays\nmanual targets] - B -->|Uncensored| D[Resolve maintainers' relays\nvia NostrGossip] - D --> E[Repo-declared + maintainer relays] - C --> F[fetch repo_filters] + A[RepoStore opens a repo] --> B{Event fetching strategy} + B -->|Curated| C[Manual sync to repo-declared relays] + B -->|Uncensored| D[Auto sync: SDK resolves maintainers' NIP-65 relays from authors and #p] --> E[Manual sync to repo-declared relays] + C --> F[(Local database)] + D --> F E --> F - F --> G[Local database] - G --> H[run_refresh] ``` -- **Curated**: fetch `repo_filters` from the announcement's `relays` tag only. -- **Uncensored**: additionally fetch the same filters from every - `Announcement::effective_maintainers()`'s NIP-65 write + read relays. +1. **Curated stays exactly what the code does today**: bootstrap REQ plus the + manual announced-relay sync. +2. **Uncensored adds one SDK Auto sync.** No relay URLs are resolved, stored, + or tracked, and no kind `10002` events are fetched or parsed by `signed`: + the SDK's NIP-65 gossip targeting resolves the maintainers' relays per + request (`client.sync(filter)` with no `.with(..)`, verified in + `nostr-sdk/src/client/api/sync.rs:151-166` and + `api/util.rs:12-29`). The existing manual announced-relay sync stays for + the repo's own relays in both modes. +3. **The filters must name the maintainers.** Gossip resolution is driven by + `Filter::extract_public_keys` (`nostr/src/filter/mod.rs:591`), which reads + only `authors` and the lowercase `#p` tag: -Announcements, state events, and global deletions keep arriving from the global -`RepoListStore` bootstrap sync in both modes. + | Filter shape | Auto target | + | --- | --- | + | `authors` only | each author's NIP-65 **write** relays | + | `#p` only | each pubkey's NIP-65 **read** relays | + | both | union of read and write relays | + | neither | the pool's read relays | -### Recommended: an Auto request plus a Manual request + Today's `repo_filters` resolve nothing for this purpose: the + announcement/state filters carry only the owner in `authors`, and the + activity filter is `#a`-only, so it is classified `Other` and falls back to + the pool's read relays. Uncensored therefore sends a separate + maintainer-shaped filter set to the Auto sync. -Both can run on the same `Client`. They are independent - the pool supports -concurrent subscriptions and syncs, and events deduplicate in the database. Use -each for what it is good at: +4. **Maintainer filter set** (Uncensored only): -1. **Auto**: pass filters straight to `client.subscribe(filters)` / - `client.sync(filter)` (no `.with(...)`). Broadening the author-scoped - filters (announcement, state, author-scoped deletions) to - `effective_maintainers()` resolves each maintainer's NIP-65 **write** relays; - adding `.pubkey(..)` to the activity filter (see above) resolves their - **read** relays. This request also drives `ensure_gossip_public_keys_fresh`, - populating the gossip store. That side effect is what makes step 2 possible - at all, so the Auto request must run before resolution; resolution is a - pure store read. -2. **Manual**: the activity filter cannot reach maintainers' **write** relays - through the Auto path (that branch needs `authors`), so cover the outbox side - explicitly: resolve each maintainer's relays from the retained gossip handle - via `get_best_relays(pk, BestRelaySelection::All { .. }, ...)`, then - negentropy-`sync` through the existing `Backend::connect_repo_relays`. `All` - returns the union of read, write, hint and most-received relays, i.e. - GitWorkshop's outbox **and** inbox. Because this leg targets the whole - filter, it also covers comments that an `#a` + `#p` filter would drop. + ```rust + /// Filters the SDK resolves through NIP-65 gossip in Uncensored mode. + /// + /// Gossip reads pubkeys from `authors` and the lowercase `#p` tag only, + /// so every filter names the owner and the maintainers. + fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec { + let mut pubkeys = maintainers.to_vec(); + // NIP-34 events tag the announcement author, which may not be a + // maintainer for subordinate forks. + if !pubkeys.contains(&addr.public_key) { + pubkeys.push(addr.public_key); + } -Retain the gossip store handle so step 2 can resolve relays: + vec![ + // Announcement and state events, including co-maintainer states, + // resolved to write relays. + Filter::new() + .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) + .authors(pubkeys.clone()) + .identifier(addr.identifier.clone()), + // Activity tagging a maintainer, resolved to their read relays. + Filter::new() + .kinds(filters::ACTIVITY_KINDS) + .coordinate(addr) + .pubkeys(pubkeys.clone()), + // Activity authored by a maintainer, resolved to their write relays. + Filter::new() + .kinds(filters::ACTIVITY_KINDS) + .coordinate(addr) + .authors(pubkeys.clone()), + // Deletions authored by a maintainer, resolved to write relays. + Filter::new() + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .authors(pubkeys), + ] + } + ``` -```rust -let gossip = Arc::new(NostrGossipMemory::unbounded()); -let client = ClientBuilder::default().gossip(gossip.clone()) /* ... */ .build(); -``` + `maintainers` is `Announcement::effective_maintainers()`, already computed + by `run_refresh`. Because the announcement/state filter names every + maintainer, co-maintainer state events (kind `30618`) land in the local + database. They are not displayed yet: `run_refresh` reads state through + the owner-only `filters::state` (`docs/backend-audit.md`, finding 3). -Store `gossip` (as `Arc`) on `Backend`, then call -`gossip.get_best_relays(..)` per maintainer, union the results, drop relays -already in `repo_relays`, and track the rest in a new -`mailbox_relays: HashSet`. +5. **The manual leg is unchanged**: `subscribe_bootstrap` plus + `connect_announced_relays` keep covering bootstrap and repo-declared + relays. Per-root follow-ups (`comments_for`, `statuses_for`) also stay + as-is. -Prefer splitting by filter shape (author-scoped -> Auto, coordinate-scoped -> -Manual) to avoid duplicate REQs. Sending the full `repo_filters` through both is -also valid; the database dedups events, at the cost of re-querying overlapping -relays. +6. **Default: Uncensored**, matching GitWorkshop + (`DEFAULT_RELAY_CURATION_MODE = "outbox"`). Flagged under "Decisions" + because it changes what existing users fetch. -### Variant: Auto only +### Coverage -Drop the manual maintainer request and pass `repo_filters` directly to -`client.subscribe(filters)` / `client.sync(filter)`, adding `.pubkey(..)` to the -activity filter. Simpler, but activity then reaches only maintainers' **read** -relays (plus hints/most-received); their write relays never receive it. +- The `#p` activity filter finds roots and statuses that tag a maintainer + (NIP-34 root events and statuses tag the owner) on the maintainers' read + relays. +- The `authors` activity filter finds maintainer-authored events (issues, + PRs, patches, comments, statuses) on their write relays. `signed`'s + comments and statuses carry the `a` tag + (`comment_builder`, `set_status`, `publish_applied_status`), so this filter + does not drop them. +- Comments whose parent author is not a maintainer are not matched by the + `#p` filter, but they are published to the parent author's inbox, not to a + maintainer's relays; maintainer-authored comments are still caught by the + `authors` filter. +- Not covered: status events without an `a` tag published to a maintainer's + outbox. Per-root `#e` filters carry no pubkeys, so they cannot resolve + through gossip; GitWorkshop covers these with per-item supplemental + queries (out of scope below). -Recommendation: the Auto + Manual combination for `Uncensored`, matching -GitWorkshop's base + extra relay groups. +## Changes -## Setting and UI +### 1. Setting, `crates/settings/src/settings.rs` -Add to `crates/settings/src/settings.rs`, following the bare-enum pattern of -`AppearanceMode`: +Follow the bare-enum pattern of `AppearanceMode`: ```rust +/// Which relays `RepoStore` queries for a repository's activity. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EventFetchingStrategy { /// Only the relays declared in the repository announcement. - #[default] Curated, /// Repository relays plus every maintainer's NIP-65 relays. + #[default] Uncensored, } ``` -Add `pub event_fetching: EventFetchingStrategy` to `Settings`, and a section to -`crates/workspace/src/views/sidebar/settings_dialog.rs` rendered from -`settings_view`, reusing the `Select` used by `appearance_section` (or -`setting_block` from `signed_ui` for a two-card layout closer to GitWorkshop). +Add `pub event_fetching: EventFetchingStrategy` to `Settings`. -### Default +### 2. Settings UI, `crates/workspace/src/views/sidebar/settings_dialog.rs` -Recommend **Uncensored**, matching GitWorkshop. Curated remains the -spam-resistant choice. Note the default changes what existing users fetch. +- Options: `SelectOption::new("curated", "Curated")` and + `SelectOption::new("uncensored", "Uncensored")`. +- Add an `event_fetching: Entity>>` field to + `SettingsControls`, seeded from the persisted value, and a + `SelectEvent::Confirm` subscription that maps the value to the enum and + calls `store.edit(|settings| settings.event_fetching = strategy, cx)`. +- Add an `event_fetching_section` next to `appearance_section`, using + `setting_row` with the title "Event Fetching Strategy" and a description of + both modes, and insert it in `settings_view`. -## Files to change +### 3. Backend, `crates/signed_state/src/backend.rs` -- `crates/settings/src/settings.rs` - enum and `Settings` field. -- `crates/workspace/src/views/sidebar/settings_dialog.rs` - new section. -- `crates/signed_nostr/src/backend.rs` - retain the `NostrGossipMemory` handle; - optionally reconsider `no_background_refresh`. -- `crates/signed_state/src/backend.rs` - store the gossip handle; expose a - maintainer-relay resolver using `get_best_relays`; reuse `connect_repo_relays`. -- `crates/signed_state/src/repo.rs` - strategy-aware `subscribe_remote` / - `connect_announced_relays`, new `connect_maintainer_relays`, new - `mailbox_relays` field. -- `crates/signed_core/src/filters.rs` - `relay_list(public_keys)` filter for the - freshness trigger. Note `ensure_gossip_public_keys_fresh` is private to the - client; an Auto request naming the pubkeys is the public way to trigger it, so - this helper is only useful as part of an Auto filter list. +New method next to `connect_repo_relays`: -## Open questions / decisions +```rust +/// Sync filters through the SDK's NIP-65 gossip targeting. +/// +/// `client.sync(filter)` without `.with(..)` is an Auto request: the SDK +/// resolves each filter's `authors` and lowercase `#p` pubkeys to their +/// NIP-65 relays, connects them, and negentropy-syncs there. +pub fn sync_auto(&mut self, filters: Vec, cx: &mut Context) { + let client = self.client.clone(); -1. **Activity `#p` shaping.** Add a second `.pubkey(maintainers)` activity - filter so the Auto leg reaches maintainer inboxes, or keep the activity - filter coordinate-only? The manual leg already reaches read and write relays - for the whole filter, so comments are covered either way; this only decides - how much the Auto leg contributes. -2. **Bootstrap REQ in Curated mode.** Recommended: drop it for this repository - (global `RepoListStore` still covers announcements, state, deletions). - Alternative: keep it and make the setting purely additive. -3. **Gossip freshness.** Trigger kind `10002` on demand per repo, or re-enable - the SDK background refresher? Background refresh is currently disabled in - `signed_nostr/src/backend.rs`. -4. **Runtime changes.** Re-fetch on `SettingsStore` edit (subscribe - `RepoStore`), or apply on next open? -5. **Per-item author inbox relays.** GitWorkshop also queries each discovered - item author's inbox relays in `outbox` mode. Larger change; propose a phase 2. + cx.spawn(async move |_this, _cx| { + for filter in filters { + if let Err(e) = client.sync(filter).await { + log::warn!("gossip relay fetch failed: {e}"); + } + } + Ok::<(), Error>(()) + }) + .detach(); +} +``` -## Testing +Errors stay log-only, like `connect_repo_relays`; `BackendEvent::Synced` is +deliberately not emitted (it would refresh `RepoListStore` for repo-level +traffic). -- Unit-test maintainer-relay resolution: given maintainers and a gossip store - seeded with kind `10002`, assert the resolved, deduped relay set reaches - `connect_repo_relays`. -- Assert Curated uses only the announced relays. -- Keep the `settings` round-trip tests updated for the new field. +### 4. `RepoStore`, `crates/signed_state/src/repo.rs` + +New field, initialized empty in `new` and `new_local`: + +```rust +/// Maintainers already synced through gossip in Uncensored mode. +synced_maintainers: HashSet, +``` + +New methods after `connect_announced_relays`: `maintainer_filters` (listed +under Design) and + +```rust +/// In Uncensored mode, sync this repository's maintainer-shaped filters +/// through the SDK's NIP-65 gossip targeting. +fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context) { + let strategy = settings::SettingsStore::try_global(cx) + .map(|store| store.read(cx).settings().event_fetching) + .unwrap_or_default(); + if strategy != EventFetchingStrategy::Uncensored { + return; + } + + let Some(addr) = self.addr.clone() else { + return; + }; + + if !maintainers + .iter() + .any(|pk| !self.synced_maintainers.contains(pk)) + { + return; + } + self.synced_maintainers.extend(maintainers.iter().copied()); + + let filters = Self::maintainer_filters(&addr, maintainers); + let backend = Backend::global(cx); + backend.update(cx, |backend, cx| backend.sync_auto(filters, cx)); +} +``` + +Hook into `run_refresh`, in the foreground update after +`connect_announced_relays`: + +```rust +let maintainers = this + .announcement + .as_ref() + .map(Announcement::effective_maintainers) + .unwrap_or_default(); +this.sync_maintainer_relays(&maintainers, cx); +``` + +Notes: + +- `SettingsStore::try_global` keeps wasm safe: the settings store is only + installed by the desktop app (`desktop/src/main.rs:21`). +- No new crate dependencies: `signed_state` already depends on `settings` and + `nostr_sdk`. + +## Tests + +- `crates/settings`: extend `json_roundtrip_preserves_everything` and + `partial_json_merges_with_defaults` for `event_fetching` (snake_case + values, default). +- `crates/signed_state/src/repo.rs`, `mod tests`: unit-test + `maintainer_filters`: every filter names the owner and maintainers via + `authors` or `#p` (the announcement/state filter included), activity + filters carry the `#a` coordinate, and the owner is added for a + subordinate fork. +- `cargo test -p settings -p signed_state` and + `cargo check -p signed_workspace` for the UI. +- Manual smoke: open a repository whose activity exists only on a + maintainer's relays (not on the announced relays or bootstrap) in both + modes. + +## Decisions + +1. **Default.** Uncensored, to match GitWorkshop. Curated preserves today's + relay traffic; flipping the default is a one-line change. +2. **Bootstrap REQ stays in Curated.** It is the index path that keeps + repositories with unreachable announced relays usable. Strict GitWorkshop + parity (repo relays only in Curated) is possible later but is a behaviour + change unrelated to the option itself. +3. **Read and write coverage is approximated by two activity filters** (`#p` + and `authors`) rather than a resolved "all maintainer relays" set. The SDK + resolves the relay sets per filter shape; `signed` stores no relay URLs. +4. **The announcement/state Auto filter names every maintainer plus the + owner.** This also fetches co-maintainer state events into the local + database; showing them is a separate display-side change. +5. **Runtime switching.** `sync_maintainer_relays` reads the setting on every + `run_refresh`, so switching to Uncensored applies at the next refresh + without a restart. Switching back stops new Auto syncs but does not undo + relays already resolved by the gossip pool. +6. **Gossip pool growth.** Auto requests add resolved relays with + `RelayCapabilities::GOSSIP` and never remove them for the session + (`docs/backend-audit.md`); accepted, as the SDK is designed this way. +7. **Errors stay log-only**, consistent with existing background fetches. + +## Out of scope + +- Status events without an `a` tag published to maintainer outboxes + (GitWorkshop's per-item supplemental loader). +- Per-item author inbox relays + (`src/services/nostr.ts:1042,1070`, `MAX_AUTHOR_INBOX_RELAYS = 3`). +- Co-maintainer state display: co-maintainer states are fetched (decision 4) + but `run_refresh` still reads state through the owner-only + `filters::state` (`docs/backend-audit.md`, finding 3). +- Gating or replacing the bootstrap REQ in Curated mode. ## Phasing -1. Setting, UI, and gossip-handle plumbing. -2. Curated wiring (bootstrap REQ gating) + Uncensored maintainer relay fetch. -3. Runtime re-fetch on setting change. -4. (Optional) per-item author inbox relays. +1. Setting enum, field and settings tests. **Done.** +2. Settings UI control. +3. `Backend::sync_auto` and `RepoStore::sync_maintainer_relays` with the + maintainer filter set, plus the filter unit test. +4. `cargo test` / `cargo check`, then a manual smoke test.