feat: add event fetching strategy #24
@@ -18,6 +18,16 @@ pub enum AppearanceMode {
|
|||||||
Dark,
|
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.
|
/// Fields mirror the gpui-component `Theme` surface customized at startup.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -141,6 +151,7 @@ pub struct CreateRepositorySettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub appearance: AppearanceMode,
|
pub appearance: AppearanceMode,
|
||||||
|
pub event_fetching: EventFetchingStrategy,
|
||||||
pub theme: ThemeSettings,
|
pub theme: ThemeSettings,
|
||||||
pub tab_bar: TabBarSettings,
|
pub tab_bar: TabBarSettings,
|
||||||
pub grasp_servers: GraspServersSettings,
|
pub grasp_servers: GraspServersSettings,
|
||||||
@@ -157,6 +168,7 @@ mod tests {
|
|||||||
fn json_roundtrip_preserves_everything() {
|
fn json_roundtrip_preserves_everything() {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
appearance: AppearanceMode::Dark,
|
appearance: AppearanceMode::Dark,
|
||||||
|
event_fetching: EventFetchingStrategy::Curated,
|
||||||
theme: ThemeSettings {
|
theme: ThemeSettings {
|
||||||
radius: 8.0,
|
radius: 8.0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -178,9 +190,19 @@ mod tests {
|
|||||||
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
||||||
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
||||||
assert_eq!(settings.theme.radius, 4.0);
|
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.
|
// The rest of the theme and the other groups keep their defaults.
|
||||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||||
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
||||||
assert_eq!(settings.create_repository.default_folder, None);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+292
-239
@@ -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`;
|
Status: implementation plan, not implemented. This document replaces the
|
||||||
read that document first.
|
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`
|
- rust-nostr at the revision from `Cargo.lock`,
|
||||||
(`git clone nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr//gitworkshop`).
|
`b230cecf9dbb38e0228e6fff4544ed9d261326fc` (local checkout
|
||||||
- rust-nostr @ `b230cecf9dbb38e0228e6fff4544ed9d261326fc` from `Cargo.lock`.
|
`~/.cargo/git/checkouts/nostr-619b808bb247a9ed/b230cec`).
|
||||||
Every `nostr*` crate, including `nostr-gossip` and `nostr-gossip-memory`,
|
- GitWorkshop at `420c0c3`.
|
||||||
resolves to that single revision.
|
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Add a global setting controlling which relays `RepoStore` queries for a
|
Mirror GitWorkshop's "Event Fetching Strategy" for the per-repository fetches
|
||||||
repository's activity, mirroring GitWorkshop's "Event Fetching Strategy":
|
in `RepoStore`:
|
||||||
|
|
||||||
- **Curated** (`repo`): only the relays declared in the repository
|
- **Curated**: only the relays declared in the repository announcement.
|
||||||
announcement.
|
- **Uncensored**: the repository's declared relays plus every maintainer's
|
||||||
- **Uncensored** (`outbox`): the repository's declared relays plus every
|
NIP-65 relays.
|
||||||
maintainer's relays, resolved through the NIP-65 outbox model.
|
|
||||||
|
Global discovery is unchanged in both modes: `RepoListStore` keeps syncing
|
||||||
|
announcements, states and deletions from `BOOTSTRAP_RELAYS`.
|
||||||
|
|
||||||
## GitWorkshop reference
|
## GitWorkshop reference
|
||||||
|
|
||||||
Source paths are relative to the cloned repository.
|
- `src/services/settings.ts:197-210`: `RelayCurationMode = "repo" | "outbox"`,
|
||||||
|
persisted to `localStorage`, default `"outbox"` (Uncensored).
|
||||||
- `src/services/settings.ts`: `RelayCurationMode = "repo" | "outbox"`,
|
- `src/pages/Settings.tsx:85-100`: two selectable cards, "Curated" and
|
||||||
persisted to `localStorage`, default `"outbox"` (the adjacent doc comment
|
"Uncensored".
|
||||||
wrongly says `repo`; the constant is authoritative).
|
- `src/hooks/useResolvedRepository.ts`: `repoRelayGroup` is the announcement's
|
||||||
- `src/pages/Settings.tsx` (`RelayCurationSection`, `CURATION_OPTIONS`): two
|
`relays` tag. `extraRelaysForMaintainerMailboxCoverage` is a delta group of
|
||||||
selectable cards, "Curated" and "Uncensored".
|
every maintainer's NIP-65 outbox + inbox relays, excluding relays already in
|
||||||
- `src/hooks/useResolvedRepository.ts` (layers 3-4):
|
the repo group, capped at `MAX_MAILBOX_RELAYS_PER_USER = 3` per direction
|
||||||
- `repoRelayGroup` (`RepositoryRelayGroup`): the repo announcement's
|
(`addMailboxRelaysToGroup`). The pubkey set is the announcement chain's
|
||||||
`relays` tag. The **Curated** frontier.
|
`discoveryPubkeys` (maintainers, moderators, owner).
|
||||||
- `extraRelaysForMaintainerMailboxCoverage`: a delta relay group built from
|
- Gating: `src/hooks/useNip34Loaders.ts:447` and
|
||||||
every maintainer's NIP-65 **outbox + inbox** relays, excluding relays
|
`src/pages/repo/RepoLayout.tsx:319-360` only subscribe the item loaders to
|
||||||
already in `repoRelayGroup`. The **Uncensored** addition.
|
the maintainer group when the mode is `outbox`. Curated never touches
|
||||||
- `src/services/nostr.ts`:
|
maintainer relays.
|
||||||
- `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<Filter>`, 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<RelayUrl, Vec<Filter>>`), 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.
|
|
||||||
|
|
||||||
## Current behaviour in `signed`
|
## Current behaviour in `signed`
|
||||||
|
|
||||||
Global discovery is unchanged: `RepoListStore::subscribe_remote`
|
`crates/signed_state/src/repo.rs`:
|
||||||
(`crates/signed_state/src/repos.rs`) syncs `all_announcements`, `all_states`,
|
|
||||||
and `deletions` from `BOOTSTRAP_RELAYS`.
|
|
||||||
|
|
||||||
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
|
## Design
|
||||||
REQ of `repo_filters` on `BOOTSTRAP_RELAYS`. It passes an explicit
|
|
||||||
`HashMap<&str, Vec<Filter>>`, 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
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[RepoStore opens repo] --> B{Event fetching strategy}
|
A[RepoStore opens a repo] --> B{Event fetching strategy}
|
||||||
B -->|Curated| C[Repo-declared relays\nmanual targets]
|
B -->|Curated| C[Manual sync to repo-declared relays]
|
||||||
B -->|Uncensored| D[Resolve maintainers' relays\nvia NostrGossip]
|
B -->|Uncensored| D[Auto sync: SDK resolves maintainers' NIP-65 relays from authors and #p] --> E[Manual sync to repo-declared relays]
|
||||||
D --> E[Repo-declared + maintainer relays]
|
C --> F[(Local database)]
|
||||||
C --> F[fetch repo_filters]
|
D --> F
|
||||||
E --> F
|
E --> F
|
||||||
F --> G[Local database]
|
|
||||||
G --> H[run_refresh]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Curated**: fetch `repo_filters` from the announcement's `relays` tag only.
|
1. **Curated stays exactly what the code does today**: bootstrap REQ plus the
|
||||||
- **Uncensored**: additionally fetch the same filters from every
|
manual announced-relay sync.
|
||||||
`Announcement::effective_maintainers()`'s NIP-65 write + read relays.
|
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
|
| Filter shape | Auto target |
|
||||||
`RepoListStore` bootstrap sync in both modes.
|
| --- | --- |
|
||||||
|
| `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
|
4. **Maintainer filter set** (Uncensored only):
|
||||||
concurrent subscriptions and syncs, and events deduplicate in the database. Use
|
|
||||||
each for what it is good at:
|
|
||||||
|
|
||||||
1. **Auto**: pass filters straight to `client.subscribe(filters)` /
|
```rust
|
||||||
`client.sync(filter)` (no `.with(...)`). Broadening the author-scoped
|
/// Filters the SDK resolves through NIP-65 gossip in Uncensored mode.
|
||||||
filters (announcement, state, author-scoped deletions) to
|
///
|
||||||
`effective_maintainers()` resolves each maintainer's NIP-65 **write** relays;
|
/// Gossip reads pubkeys from `authors` and the lowercase `#p` tag only,
|
||||||
adding `.pubkey(..)` to the activity filter (see above) resolves their
|
/// so every filter names the owner and the maintainers.
|
||||||
**read** relays. This request also drives `ensure_gossip_public_keys_fresh`,
|
fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec<Filter> {
|
||||||
populating the gossip store. That side effect is what makes step 2 possible
|
let mut pubkeys = maintainers.to_vec();
|
||||||
at all, so the Auto request must run before resolution; resolution is a
|
// NIP-34 events tag the announcement author, which may not be a
|
||||||
pure store read.
|
// maintainer for subordinate forks.
|
||||||
2. **Manual**: the activity filter cannot reach maintainers' **write** relays
|
if !pubkeys.contains(&addr.public_key) {
|
||||||
through the Auto path (that branch needs `authors`), so cover the outbox side
|
pubkeys.push(addr.public_key);
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
`maintainers` is `Announcement::effective_maintainers()`, already computed
|
||||||
let gossip = Arc::new(NostrGossipMemory::unbounded());
|
by `run_refresh`. Because the announcement/state filter names every
|
||||||
let client = ClientBuilder::default().gossip(gossip.clone()) /* ... */ .build();
|
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<NostrGossipMemory>`) on `Backend`, then call
|
5. **The manual leg is unchanged**: `subscribe_bootstrap` plus
|
||||||
`gossip.get_best_relays(..)` per maintainer, union the results, drop relays
|
`connect_announced_relays` keep covering bootstrap and repo-declared
|
||||||
already in `repo_relays`, and track the rest in a new
|
relays. Per-root follow-ups (`comments_for`, `statuses_for`) also stay
|
||||||
`mailbox_relays: HashSet<RelayUrl>`.
|
as-is.
|
||||||
|
|
||||||
Prefer splitting by filter shape (author-scoped -> Auto, coordinate-scoped ->
|
6. **Default: Uncensored**, matching GitWorkshop
|
||||||
Manual) to avoid duplicate REQs. Sending the full `repo_filters` through both is
|
(`DEFAULT_RELAY_CURATION_MODE = "outbox"`). Flagged under "Decisions"
|
||||||
also valid; the database dedups events, at the cost of re-querying overlapping
|
because it changes what existing users fetch.
|
||||||
relays.
|
|
||||||
|
|
||||||
### Variant: Auto only
|
### Coverage
|
||||||
|
|
||||||
Drop the manual maintainer request and pass `repo_filters` directly to
|
- The `#p` activity filter finds roots and statuses that tag a maintainer
|
||||||
`client.subscribe(filters)` / `client.sync(filter)`, adding `.pubkey(..)` to the
|
(NIP-34 root events and statuses tag the owner) on the maintainers' read
|
||||||
activity filter. Simpler, but activity then reaches only maintainers' **read**
|
relays.
|
||||||
relays (plus hints/most-received); their write relays never receive it.
|
- 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
|
## Changes
|
||||||
GitWorkshop's base + extra relay groups.
|
|
||||||
|
|
||||||
## Setting and UI
|
### 1. Setting, `crates/settings/src/settings.rs`
|
||||||
|
|
||||||
Add to `crates/settings/src/settings.rs`, following the bare-enum pattern of
|
Follow the bare-enum pattern of `AppearanceMode`:
|
||||||
`AppearanceMode`:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
/// Which relays `RepoStore` queries for a repository's activity.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum EventFetchingStrategy {
|
pub enum EventFetchingStrategy {
|
||||||
/// Only the relays declared in the repository announcement.
|
/// Only the relays declared in the repository announcement.
|
||||||
#[default]
|
|
||||||
Curated,
|
Curated,
|
||||||
/// Repository relays plus every maintainer's NIP-65 relays.
|
/// Repository relays plus every maintainer's NIP-65 relays.
|
||||||
|
#[default]
|
||||||
Uncensored,
|
Uncensored,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `pub event_fetching: EventFetchingStrategy` to `Settings`, and a section to
|
Add `pub event_fetching: EventFetchingStrategy` to `Settings`.
|
||||||
`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).
|
|
||||||
|
|
||||||
### Default
|
### 2. Settings UI, `crates/workspace/src/views/sidebar/settings_dialog.rs`
|
||||||
|
|
||||||
Recommend **Uncensored**, matching GitWorkshop. Curated remains the
|
- Options: `SelectOption::new("curated", "Curated")` and
|
||||||
spam-resistant choice. Note the default changes what existing users fetch.
|
`SelectOption::new("uncensored", "Uncensored")`.
|
||||||
|
- Add an `event_fetching: Entity<SelectState<Vec<SelectOption>>>` 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.
|
New method next to `connect_repo_relays`:
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 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<Filter>, cx: &mut Context<Self>) {
|
||||||
|
let client = self.client.clone();
|
||||||
|
|
||||||
1. **Activity `#p` shaping.** Add a second `.pubkey(maintainers)` activity
|
cx.spawn(async move |_this, _cx| {
|
||||||
filter so the Auto leg reaches maintainer inboxes, or keep the activity
|
for filter in filters {
|
||||||
filter coordinate-only? The manual leg already reaches read and write relays
|
if let Err(e) = client.sync(filter).await {
|
||||||
for the whole filter, so comments are covered either way; this only decides
|
log::warn!("gossip relay fetch failed: {e}");
|
||||||
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).
|
Ok::<(), Error>(())
|
||||||
Alternative: keep it and make the setting purely additive.
|
})
|
||||||
3. **Gossip freshness.** Trigger kind `10002` on demand per repo, or re-enable
|
.detach();
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
### 4. `RepoStore`, `crates/signed_state/src/repo.rs`
|
||||||
seeded with kind `10002`, assert the resolved, deduped relay set reaches
|
|
||||||
`connect_repo_relays`.
|
New field, initialized empty in `new` and `new_local`:
|
||||||
- Assert Curated uses only the announced relays.
|
|
||||||
- Keep the `settings` round-trip tests updated for the new field.
|
```rust
|
||||||
|
/// Maintainers already synced through gossip in Uncensored mode.
|
||||||
|
synced_maintainers: HashSet<PublicKey>,
|
||||||
|
```
|
||||||
|
|
||||||
|
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<Self>) {
|
||||||
|
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
|
## Phasing
|
||||||
|
|
||||||
1. Setting, UI, and gossip-handle plumbing.
|
1. Setting enum, field and settings tests. **Done.**
|
||||||
2. Curated wiring (bootstrap REQ gating) + Uncensored maintainer relay fetch.
|
2. Settings UI control.
|
||||||
3. Runtime re-fetch on setting change.
|
3. `Backend::sync_auto` and `RepoStore::sync_maintainer_relays` with the
|
||||||
4. (Optional) per-item author inbox relays.
|
maintainer filter set, plus the filter unit test.
|
||||||
|
4. `cargo test` / `cargo check`, then a manual smoke test.
|
||||||
|
|||||||
Reference in New Issue
Block a user