add fetch strategy
This commit is contained in:
@@ -1192,6 +1192,21 @@ impl Backend {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Sync filters through the SDK's NIP-65 gossip targeting.
|
||||
pub fn sync_auto(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
||||
use nostr::event::IntoEventBuilder;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::{EventFetchingStrategy, SettingsStore};
|
||||
use signed_core::{
|
||||
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
||||
pull_request_patches,
|
||||
@@ -84,6 +85,10 @@ pub struct RepoStore {
|
||||
///
|
||||
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
||||
root_fetches: HashSet<EventId>,
|
||||
/// Maintainers already synced through gossip in Uncensored mode.
|
||||
///
|
||||
/// Avoids re-running the maintainer Auto sync on every refresh.
|
||||
synced_maintainers: HashSet<PublicKey>,
|
||||
refresh: RefreshGate,
|
||||
/// Backend subscription of an announced repository. `None` while local-only.
|
||||
_subscription: Option<Subscription>,
|
||||
@@ -132,6 +137,7 @@ impl RepoStore {
|
||||
cloning: false,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
synced_maintainers: HashSet::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
@@ -160,6 +166,7 @@ impl RepoStore {
|
||||
cloning: false,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
synced_maintainers: HashSet::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: None,
|
||||
}
|
||||
@@ -295,6 +302,67 @@ impl RepoStore {
|
||||
});
|
||||
}
|
||||
|
||||
/// Filters the SDK resolves through NIP-65 gossip in Uncensored mode.
|
||||
fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec<Filter> {
|
||||
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);
|
||||
}
|
||||
|
||||
vec![
|
||||
// Announcement and state events, including co-maintainer states.
|
||||
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.
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.authors(pubkeys),
|
||||
]
|
||||
}
|
||||
|
||||
/// In Uncensored mode, sync the 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 = 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(|public_key| !self.synced_maintainers.contains(public_key))
|
||||
{
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
return;
|
||||
@@ -489,7 +557,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
// The announcement may list relays for this repository's activity.
|
||||
// Connect to any we have not fetched from yet.
|
||||
let relays = this
|
||||
.announcement
|
||||
.as_ref()
|
||||
@@ -498,6 +565,15 @@ impl RepoStore {
|
||||
|
||||
this.connect_announced_relays(&relays, cx);
|
||||
|
||||
// Uncensored mode also covers the maintainers' NIP-65 relays.
|
||||
let maintainers = this
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(Announcement::effective_maintainers)
|
||||
.unwrap_or_default();
|
||||
|
||||
this.sync_maintainer_relays(&maintainers, cx);
|
||||
|
||||
if let Some((_, head)) = state {
|
||||
this.head = head;
|
||||
}
|
||||
@@ -1734,9 +1810,11 @@ fn comment_builder(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use super::{comment_builder, patch_current_commit};
|
||||
use super::{RepoStore, comment_builder, patch_current_commit};
|
||||
|
||||
#[test]
|
||||
fn parses_format_patch_header() {
|
||||
@@ -1782,4 +1860,60 @@ mod tests {
|
||||
// Signed's own `references_root` must keep matching the comment.
|
||||
assert!(signed_core::references_root(&event, &root.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintainer_filters_name_owner_and_maintainers() {
|
||||
let owner = Keys::generate().public_key();
|
||||
let maintainer = Keys::generate().public_key();
|
||||
let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo");
|
||||
|
||||
// The owner is not among the maintainers, as on a subordinate fork.
|
||||
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
|
||||
assert_eq!(filters.len(), 4);
|
||||
|
||||
let expected = HashSet::from([owner, maintainer]);
|
||||
|
||||
// Gossip only resolves pubkeys from `authors` and the lowercase `#p` tag.
|
||||
let named = |filter: &Filter| -> HashSet<PublicKey> {
|
||||
let authors = filter.authors.iter().flatten().copied();
|
||||
let p_tag = filter
|
||||
.generic_tags
|
||||
.get(&SingleLetterTag::LOWERCASE_P)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|value| PublicKey::from_hex(value).ok());
|
||||
authors.chain(p_tag).collect()
|
||||
};
|
||||
|
||||
// Announcement and state events, scoped to the repository identifier.
|
||||
let announcement = &filters[0];
|
||||
assert_eq!(named(announcement), expected);
|
||||
assert!(
|
||||
announcement
|
||||
.generic_tags
|
||||
.contains_key(&SingleLetterTag::LOWERCASE_D)
|
||||
);
|
||||
|
||||
// Activity filters, scoped to the repository coordinate.
|
||||
for filter in &filters[1..3] {
|
||||
assert_eq!(named(filter), expected);
|
||||
assert!(
|
||||
filter
|
||||
.generic_tags
|
||||
.contains_key(&SingleLetterTag::LOWERCASE_A)
|
||||
);
|
||||
}
|
||||
|
||||
// Deletions, named by author only.
|
||||
let deletions = &filters[3];
|
||||
assert_eq!(
|
||||
deletions
|
||||
.authors
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use gpui_component::{
|
||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
||||
v_flex,
|
||||
};
|
||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
||||
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||
|
||||
use super::{normalize_server, server_host};
|
||||
@@ -52,6 +52,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
||||
/// Created once when the dialog opens, so control state survives re-renders.
|
||||
struct SettingsControls {
|
||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
|
||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
font_size: Entity<InputState>,
|
||||
@@ -89,6 +90,23 @@ impl SettingsControls {
|
||||
)
|
||||
});
|
||||
|
||||
let event_fetching_options = vec![
|
||||
SelectOption::new("curated", "Curated"),
|
||||
SelectOption::new("uncensored", "Uncensored"),
|
||||
];
|
||||
let event_fetching_value = match settings.event_fetching {
|
||||
EventFetchingStrategy::Curated => "curated",
|
||||
EventFetchingStrategy::Uncensored => "uncensored",
|
||||
};
|
||||
let event_fetching = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
event_fetching_options.clone(),
|
||||
selected_index(&event_fetching_options, event_fetching_value),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let (light_options, dark_options) = theme_options(cx);
|
||||
let light_theme = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
@@ -147,6 +165,19 @@ impl SettingsControls {
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&event_fetching, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let strategy = match value.as_ref() {
|
||||
"curated" => EventFetchingStrategy::Curated,
|
||||
_ => EventFetchingStrategy::Uncensored,
|
||||
};
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.event_fetching = strategy, cx);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let store = SettingsStore::global(cx);
|
||||
@@ -252,6 +283,7 @@ impl SettingsControls {
|
||||
|
||||
Self {
|
||||
appearance,
|
||||
event_fetching,
|
||||
light_theme,
|
||||
dark_theme,
|
||||
font_size,
|
||||
@@ -294,6 +326,8 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
|
||||
.child(Separator::horizontal())
|
||||
.child(grasp_servers_section(&settings, controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(event_fetching_section(controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(repositories_section(&settings, controls, cx))
|
||||
}
|
||||
|
||||
@@ -306,6 +340,16 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
|
||||
))
|
||||
}
|
||||
|
||||
/// Which relays repository activity is fetched from.
|
||||
fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex().w_full().gap_3().child(setting_row(
|
||||
cx,
|
||||
"Event Fetching Strategy",
|
||||
"Curated fetches events from relays in the repository's announcement. Uncensored also fetches from every maintainer's relays.",
|
||||
Select::new(&controls.event_fetching).w_full(),
|
||||
))
|
||||
}
|
||||
|
||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
|
||||
Reference in New Issue
Block a user