Compare commits
6
Commits
redesign
...
e1a6f0a669
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a6f0a669 | ||
|
|
c170c4a800 | ||
|
|
4d2d058396 | ||
|
|
3a70bb7228 | ||
|
|
80d4ca754b | ||
|
|
5a74bf1f0d |
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
|
- 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
|
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
|
||||||
|
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
/// GitWorkshop and `ngit` cover-note extension, kind 1624.
|
|
||||||
///
|
|
||||||
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
|
|
||||||
/// not part of the NIP-34 draft, read support for interop.
|
|
||||||
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
|
||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::{COVER_NOTE_KIND, RepoAddr};
|
use crate::RepoAddr;
|
||||||
|
|
||||||
/// Kinds that make up the activity of a repository.
|
/// Kinds that make up the activity of a repository.
|
||||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||||
@@ -18,19 +18,18 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPullRequest,
|
Kind::GitPullRequest,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
Kind::GitPullRequestUpdate,
|
Kind::GitPullRequestUpdate,
|
||||||
COVER_NOTE_KIND,
|
|
||||||
Kind::GitStatusOpen,
|
Kind::GitStatusOpen,
|
||||||
Kind::GitStatusApplied,
|
Kind::GitStatusApplied,
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Git root kinds that make a comment or cover note count as git activity.
|
/// Git root kinds that make a comment count as git activity.
|
||||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
@@ -38,6 +37,15 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
|||||||
Kind::GitRepoAnnouncement,
|
Kind::GitRepoAnnouncement,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Kinds that carry repository data: announcements, states, activity and deletions.
|
||||||
|
pub fn is_repo_kind(kind: Kind) -> bool {
|
||||||
|
kind == Kind::GitRepoAnnouncement
|
||||||
|
|| kind == Kind::RepoState
|
||||||
|
|| kind == Kind::EventDeletion
|
||||||
|
|| kind == Kind::RequestToVanish
|
||||||
|
|| ACTIVITY_KINDS.contains(&kind)
|
||||||
|
}
|
||||||
|
|
||||||
/// Value of the first tag named `name` on `event`.
|
/// Value of the first tag named `name` on `event`.
|
||||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||||
event
|
event
|
||||||
@@ -140,13 +148,7 @@ pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
|||||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||||
/// through [`is_git_activity`] before display.
|
/// through [`is_git_activity`] before display.
|
||||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||||
.kinds(
|
|
||||||
ACTIVITY_KINDS
|
|
||||||
.into_iter()
|
|
||||||
.chain(std::iter::once(COVER_NOTE_KIND)),
|
|
||||||
)
|
|
||||||
.author(me)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||||
@@ -155,12 +157,6 @@ fn is_git_comment(event: &Event) -> bool {
|
|||||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
|
|
||||||
fn is_git_cover_note(event: &Event) -> bool {
|
|
||||||
event.kind == COVER_NOTE_KIND
|
|
||||||
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a status event references a git root, checked via its `k` tag.
|
/// Whether a status event references a git root, checked via its `k` tag.
|
||||||
fn is_git_status(event: &Event) -> bool {
|
fn is_git_status(event: &Event) -> bool {
|
||||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
@@ -175,7 +171,7 @@ pub fn is_git_activity(event: &Event) -> bool {
|
|||||||
| Kind::GitStatusApplied
|
| Kind::GitStatusApplied
|
||||||
| Kind::GitStatusClosed
|
| Kind::GitStatusClosed
|
||||||
| Kind::GitStatusDraft => is_git_status(event),
|
| Kind::GitStatusDraft => is_git_status(event),
|
||||||
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,17 +263,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
|
fn status_activity_depends_on_the_lowercase_k_tag() {
|
||||||
let status = signed(
|
let status = signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||||
);
|
);
|
||||||
let cover = signed(
|
|
||||||
&keys(1),
|
|
||||||
COVER_NOTE_KIND,
|
|
||||||
vec![kind_tag("k", Kind::GitPatch)],
|
|
||||||
);
|
|
||||||
let unrelated = signed(
|
let unrelated = signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
@@ -285,7 +276,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(is_git_activity(&status));
|
assert!(is_git_activity(&status));
|
||||||
assert!(is_git_activity(&cover));
|
|
||||||
assert!(!is_git_activity(&unrelated));
|
assert!(!is_git_activity(&unrelated));
|
||||||
assert!(!is_git_activity(&signed(
|
assert!(!is_git_activity(&signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
|
use crate::{RepoAddr, activity_subject};
|
||||||
|
|
||||||
/// Window before `now` that an advanced cutoff retreats to.
|
/// Window before `now` that an advanced cutoff retreats to.
|
||||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||||
@@ -121,14 +121,11 @@ impl InboxItem {
|
|||||||
/// - patch (1617): its `e` parent patch, else itself
|
/// - patch (1617): its `e` parent patch, else itself
|
||||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||||
/// - PR update (1619): uppercase `E`
|
/// - PR update (1619): uppercase `E`
|
||||||
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
/// - statuses (1630-1633): NIP-10 root `e`
|
||||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||||
where
|
where
|
||||||
L: Fn(EventId) -> Option<Event>,
|
L: Fn(EventId) -> Option<Event>,
|
||||||
{
|
{
|
||||||
if event.kind == COVER_NOTE_KIND {
|
|
||||||
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
|
|
||||||
}
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||||
Kind::GitPatch => Some(match first_e_id(event) {
|
Kind::GitPatch => Some(match first_e_id(event) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod addr;
|
pub mod addr;
|
||||||
pub mod annotations;
|
|
||||||
pub mod deletions;
|
pub mod deletions;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
@@ -8,7 +7,6 @@ pub mod state;
|
|||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||||
pub use annotations::COVER_NOTE_KIND;
|
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use filters::{
|
pub use filters::{
|
||||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||||
|
|||||||
@@ -23,12 +23,7 @@ pub const USER_KEYRING: &str = "Signed Safe Storage";
|
|||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
/// Relays connected at startup, before any user-specific relay config is known.
|
/// Relays connected at startup, before any user-specific relay config is known.
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.ditto.pub",
|
|
||||||
"wss://index.ngit.dev",
|
|
||||||
"wss://profiles.nostr1.com",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Relays used to index the user's NIP-65 relay list.
|
/// Relays used to index the user's NIP-65 relay list.
|
||||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||||
@@ -42,20 +37,15 @@ pub enum BackendEvent {
|
|||||||
PassphraseRequired,
|
PassphraseRequired,
|
||||||
/// The signer changed on login, logout or account switch.
|
/// The signer changed on login, logout or account switch.
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
/// New events were received from a relay and stored in the database.
|
/// Kind-0 metadata arrived for these authors; re-read them from the store.
|
||||||
///
|
ProfileUpdates(Vec<PublicKey>),
|
||||||
/// Batched: [`Backend`]'s notification pump coalesces everything a
|
/// Repository events arrived: announcements, states, activity and deletions.
|
||||||
/// relay delivers within one debounce window into a single event,
|
RepoUpdates(Vec<Update>),
|
||||||
/// instead of emitting per-event and making every subscriber debounce
|
|
||||||
/// the same burst independently.
|
|
||||||
NostrUpdate(Vec<Update>),
|
|
||||||
Synced,
|
Synced,
|
||||||
SyncProgress {
|
SyncProgress {
|
||||||
total: u64,
|
total: u64,
|
||||||
current: u64,
|
current: u64,
|
||||||
},
|
},
|
||||||
/// An event built locally was signed, broadcast and stored.
|
|
||||||
Published(Box<Event>),
|
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,14 +90,16 @@ impl Backend {
|
|||||||
|
|
||||||
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let mut notifications = pump_client.notifications();
|
let mut notifications = pump_client.notifications();
|
||||||
let mut pending: Vec<Update> = Vec::new();
|
let mut pending_profiles: HashSet<PublicKey> = HashSet::new();
|
||||||
|
let mut pending_repos: Vec<Update> = Vec::new();
|
||||||
|
let mut seen: HashSet<EventId> = HashSet::new();
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
match notifications.next().await {
|
match next_update(&mut notifications, &mut seen).await {
|
||||||
Some(ClientNotification::Event { event, .. }) => {
|
Some(UpdateEvent::Profile(author)) => {
|
||||||
pending.push(Update::from_event(&event));
|
pending_profiles.insert(author);
|
||||||
}
|
}
|
||||||
Some(_) => continue,
|
Some(UpdateEvent::Repo(update)) => pending_repos.push(update),
|
||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,28 +115,33 @@ impl Backend {
|
|||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let next = notifications.next();
|
let next = next_update(&mut notifications, &mut seen);
|
||||||
futures::pin_mut!(next);
|
futures::pin_mut!(next);
|
||||||
|
|
||||||
match futures::future::select(next, timer).await {
|
match futures::future::select(next, timer).await {
|
||||||
futures::future::Either::Left((
|
futures::future::Either::Left((Some(UpdateEvent::Profile(author)), _)) => {
|
||||||
Some(ClientNotification::Event { event, .. }),
|
pending_profiles.insert(author);
|
||||||
_,
|
}
|
||||||
)) => {
|
futures::future::Either::Left((Some(UpdateEvent::Repo(update)), _)) => {
|
||||||
pending.push(Update::from_event(&event));
|
pending_repos.push(update);
|
||||||
}
|
}
|
||||||
futures::future::Either::Left((Some(_), _)) => continue,
|
|
||||||
futures::future::Either::Left((None, _)) => break 'outer,
|
futures::future::Either::Left((None, _)) => break 'outer,
|
||||||
futures::future::Either::Right(_) => break,
|
futures::future::Either::Right(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let batch = std::mem::take(&mut pending);
|
let profiles: Vec<PublicKey> = pending_profiles.drain().collect();
|
||||||
|
let repos = std::mem::take(&mut pending_repos);
|
||||||
|
|
||||||
if let Err(e) =
|
if let Err(e) = this.update(cx, |_this, cx| {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
|
if !profiles.is_empty() {
|
||||||
{
|
cx.emit(BackendEvent::ProfileUpdates(profiles));
|
||||||
log::warn!("failed to emit nostr update: {e}");
|
}
|
||||||
|
if !repos.is_empty() {
|
||||||
|
cx.emit(BackendEvent::RepoUpdates(repos));
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to emit backend update: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,9 +443,6 @@ impl Backend {
|
|||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
// Initialize directly at the user's chosen destination.
|
// Initialize directly at the user's chosen destination.
|
||||||
// No mirror is pre-populated: `GitCache::ensure_clone` lazily clones
|
|
||||||
// from the grasp server the first time the repo detail view needs it,
|
|
||||||
// exactly like every other repository.
|
|
||||||
let destination = {
|
let destination = {
|
||||||
let dir_name = signed_git::sanitize_path_component(&name);
|
let dir_name = signed_git::sanitize_path_component(&name);
|
||||||
let dir_name = if dir_name.is_empty() {
|
let dir_name = if dir_name.is_empty() {
|
||||||
@@ -513,9 +507,7 @@ impl Backend {
|
|||||||
let builder = announcement.into_event_builder();
|
let builder = announcement.into_event_builder();
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
require_relay_accepted(output, event)?
|
||||||
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
|
|
||||||
event
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The state event is the push authorization. Stage it on each
|
// The state event is the push authorization. Stage it on each
|
||||||
@@ -570,14 +562,10 @@ impl Backend {
|
|||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing makes it
|
// Staging already stored the event locally, publishing makes it
|
||||||
// visible to the other relays and clients.
|
// visible to the other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
{
|
||||||
}
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.announce_published(state_event.clone(), cx)
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event)
|
let announcement = Announcement::from_event(&event)
|
||||||
@@ -666,9 +654,7 @@ impl Backend {
|
|||||||
let builder = announcement.into_event_builder();
|
let builder = announcement.into_event_builder();
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
require_relay_accepted(output, event)?
|
||||||
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
|
|
||||||
event
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let refs = state.refs.clone();
|
let refs = state.refs.clone();
|
||||||
@@ -725,12 +711,10 @@ impl Backend {
|
|||||||
|
|
||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
|
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
{
|
||||||
}
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
this.update(cx, |this, cx| this.announce_published(state_event.clone(), cx))
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,14 +891,10 @@ impl Backend {
|
|||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing notifies
|
// Staging already stored the event locally, publishing notifies
|
||||||
// the repository views and other relays and clients.
|
// the repository views and other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
{
|
||||||
}
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.announce_published(state_event.clone(), cx)
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
@@ -1205,6 +1185,21 @@ impl Backend {
|
|||||||
.detach();
|
.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>) {
|
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
@@ -1222,7 +1217,8 @@ impl Backend {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
/// Sync several bootstrap filters in order, within a single task.
|
||||||
|
pub fn sync_bootstraps(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let (tx, mut rx) = SyncProgress::channel();
|
let (tx, mut rx) = SyncProgress::channel();
|
||||||
|
|
||||||
@@ -1259,8 +1255,19 @@ impl Backend {
|
|||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
let sync = cx.background_spawn(async move {
|
let sync = cx.background_spawn(async move {
|
||||||
let opts = SyncOptions::default().progress(tx);
|
let mut first_error = None;
|
||||||
sync_bootstrap_only(&client, filter, opts).await
|
|
||||||
|
for filter in filters {
|
||||||
|
let opts = SyncOptions::default().progress(tx.clone());
|
||||||
|
if let Err(error) = sync_bootstrap_only(&client, filter, opts).await {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match first_error {
|
||||||
|
Some(error) => Err(error),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
@@ -1285,14 +1292,6 @@ impl Backend {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit [`BackendEvent::Published`] for cross-store invalidation.
|
|
||||||
///
|
|
||||||
/// Callers publish with `client.send_event(...)` directly, then call this
|
|
||||||
/// so stores like `RepoListStore` refresh without re-querying the relays.
|
|
||||||
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
|
|
||||||
cx.emit(BackendEvent::Published(Box::new(event)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
||||||
///
|
///
|
||||||
/// Each target gets its own deletion event: a relay rejecting or
|
/// Each target gets its own deletion event: a relay rejecting or
|
||||||
@@ -1315,6 +1314,42 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A relay event the backend routes to a store group.
|
||||||
|
enum UpdateEvent {
|
||||||
|
Profile(PublicKey),
|
||||||
|
Repo(Update),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Await the next relay event from the notification stream.
|
||||||
|
async fn next_update(
|
||||||
|
notifications: &mut (impl futures::Stream<Item = ClientNotification> + Unpin),
|
||||||
|
seen: &mut HashSet<EventId>,
|
||||||
|
) -> Option<UpdateEvent> {
|
||||||
|
loop {
|
||||||
|
match notifications.next().await {
|
||||||
|
Some(ClientNotification::Message { message, .. }) => {
|
||||||
|
let RelayMessage::Event { event, .. } = *message else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let update = match event.kind {
|
||||||
|
Kind::Metadata => UpdateEvent::Profile(event.pubkey),
|
||||||
|
kind if filters::is_repo_kind(kind) => {
|
||||||
|
UpdateEvent::Repo(Update::from_event(&event))
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if seen.insert(event.id) {
|
||||||
|
return Some(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => continue,
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sign and send a single NIP-09 deletion request for `event`.
|
/// Sign and send a single NIP-09 deletion request for `event`.
|
||||||
async fn retract_event(
|
async fn retract_event(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -1324,16 +1359,14 @@ async fn retract_event(
|
|||||||
let builder = EventDeletionRequest::new()
|
let builder = EventDeletionRequest::new()
|
||||||
.id(event.id)
|
.id(event.id)
|
||||||
.into_event_builder();
|
.into_event_builder();
|
||||||
|
|
||||||
let deletion = builder.finalize_async(signer).await?;
|
let deletion = builder.finalize_async(signer).await?;
|
||||||
client.send_event(&deletion).broadcast().await?;
|
client.send_event(&deletion).broadcast().await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The event was accepted by at least one relay, or a descriptive error otherwise.
|
/// The event was accepted by at least one relay, or a descriptive error otherwise.
|
||||||
///
|
|
||||||
/// The SDK does not treat "accepted by zero relays" as an error on its own:
|
|
||||||
/// [`SendEventOutput::success`] may be empty while the call still returns `Ok`.
|
|
||||||
/// This turns that case into an error the caller can surface.
|
|
||||||
pub(crate) fn require_relay_accepted(
|
pub(crate) fn require_relay_accepted(
|
||||||
output: SendEventOutput,
|
output: SendEventOutput,
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -1419,6 +1452,7 @@ pub(crate) async fn sync_bootstrap_only(
|
|||||||
.with(BOOTSTRAP_RELAYS)
|
.with(BOOTSTRAP_RELAYS)
|
||||||
.opts(opts)
|
.opts(opts)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(output.value)
|
Ok(output.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,38 +92,28 @@ impl ProfileStore {
|
|||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
BackendEvent::NostrUpdate(updates) => {
|
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||||
for update in updates
|
for author in authors {
|
||||||
.iter()
|
this.apply_author(*author, cx);
|
||||||
.filter(|update| update.kind == Kind::Metadata)
|
|
||||||
{
|
|
||||||
this.apply_author(update.author, cx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
|
||||||
this.profiles
|
|
||||||
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch requests are queued on a channel, batched into one sync per debounce window.
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
|
||||||
let entity = cx.entity().downgrade();
|
let entity = cx.entity().downgrade();
|
||||||
|
let entity_clone = entity.clone();
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
|
|
||||||
cx.spawn(async move |_this, cx| {
|
cx.spawn(async move |_this, cx| {
|
||||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
Self::handle_requests(entity, &client, &receiver, cx).await
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
let weak = cx.entity().downgrade();
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
|
if let Err(error) = entity_clone.update(cx, |this, cx| this.load(cx)) {
|
||||||
log::warn!("profile store dropped before initial load could run: {error}");
|
log::warn!("profile store dropped before initial load could run: {error}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -228,8 +218,6 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of every requested author from the local database.
|
/// Re-read the latest metadata of every requested author from the local database.
|
||||||
///
|
|
||||||
/// Used after a sync, which produces no NostrUpdate events.
|
|
||||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||||
|
|
||||||
@@ -305,15 +293,20 @@ impl ProfileStore {
|
|||||||
|
|
||||||
// The channel has no async timeout, race the receive against a timer.
|
// The channel has no async timeout, race the receive against a timer.
|
||||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
if now >= deadline {
|
if now >= deadline {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let recv = receiver.recv_async();
|
let recv = receiver.recv_async();
|
||||||
futures::pin_mut!(recv);
|
futures::pin_mut!(recv);
|
||||||
|
|
||||||
match futures::future::select(recv, timer).await {
|
match futures::future::select(recv, timer).await {
|
||||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||||
batch.insert(public_key);
|
batch.insert(public_key);
|
||||||
@@ -327,9 +320,6 @@ impl ProfileStore {
|
|||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||||
|
|
||||||
// Negentropy-sync with the bootstrap relays.
|
|
||||||
// Synced events are written to the database directly, no NostrUpdate.
|
|
||||||
// Re-apply from the database afterwards.
|
|
||||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||||
|
|||||||
+149
-56
@@ -7,6 +7,7 @@ use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
|||||||
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
||||||
use nostr::event::IntoEventBuilder;
|
use nostr::event::IntoEventBuilder;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use settings::{EventFetchingStrategy, SettingsStore};
|
||||||
use signed_core::{
|
use signed_core::{
|
||||||
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
||||||
pull_request_patches,
|
pull_request_patches,
|
||||||
@@ -84,6 +85,10 @@ pub struct RepoStore {
|
|||||||
///
|
///
|
||||||
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
||||||
root_fetches: HashSet<EventId>,
|
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,
|
refresh: RefreshGate,
|
||||||
/// Backend subscription of an announced repository. `None` while local-only.
|
/// Backend subscription of an announced repository. `None` while local-only.
|
||||||
_subscription: Option<Subscription>,
|
_subscription: Option<Subscription>,
|
||||||
@@ -132,6 +137,7 @@ impl RepoStore {
|
|||||||
cloning: false,
|
cloning: false,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
|
synced_maintainers: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
_subscription: Some(subscription),
|
_subscription: Some(subscription),
|
||||||
}
|
}
|
||||||
@@ -160,6 +166,7 @@ impl RepoStore {
|
|||||||
cloning: false,
|
cloning: false,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
|
synced_maintainers: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
_subscription: None,
|
_subscription: None,
|
||||||
}
|
}
|
||||||
@@ -201,8 +208,7 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
BackendEvent::RepoUpdates(updates) => updates.iter().any(|update| {
|
||||||
// Deletions may target any event of this repository.
|
|
||||||
let deletion =
|
let deletion =
|
||||||
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
@@ -218,20 +224,6 @@ impl RepoStore {
|
|||||||
|
|
||||||
deletion || coordinate || authored || comment || status
|
deletion || coordinate || authored || comment || status
|
||||||
}),
|
}),
|
||||||
BackendEvent::Published(event) => {
|
|
||||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
|
||||||
let author = event.pubkey == addr.public_key;
|
|
||||||
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
|
|
||||||
|
|
||||||
let state = event.kind == Kind::RepoState
|
|
||||||
&& author
|
|
||||||
&& event.tags.identifier().as_deref() == Some(addr.identifier.as_str());
|
|
||||||
|
|
||||||
let deletion =
|
|
||||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
|
||||||
|
|
||||||
coordinate || (announcement && author) || state || deletion
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,9 +280,71 @@ impl RepoStore {
|
|||||||
self.repo_relays.extend(new.iter().cloned());
|
self.repo_relays.extend(new.iter().cloned());
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let filters = Self::repo_filters(&addr);
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
|
backend.connect_repo_relays(new, filters, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,7 +542,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The announcement may list relays for this repository's activity.
|
// The announcement may list relays for this repository's activity.
|
||||||
// Connect to any we have not fetched from yet.
|
|
||||||
let relays = this
|
let relays = this
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -497,6 +550,15 @@ impl RepoStore {
|
|||||||
|
|
||||||
this.connect_announced_relays(&relays, cx);
|
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 {
|
if let Some((_, head)) = state {
|
||||||
this.head = head;
|
this.head = head;
|
||||||
}
|
}
|
||||||
@@ -900,11 +962,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
Backend::global(cx)
|
|
||||||
.update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// A draft PR carries a kind-1633 status event, NIP-34.
|
// A draft PR carries a kind-1633 status event, NIP-34.
|
||||||
// Publish it right after the PR event so viewers never show it open.
|
// Publish it right after the PR event so viewers never show it open.
|
||||||
if draft {
|
if draft {
|
||||||
@@ -1094,20 +1151,11 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match publish_result {
|
if let Err(e) = publish_result {
|
||||||
Ok(event) => {
|
return this.update(cx, |this, cx| {
|
||||||
this.update(cx, |_this, cx| {
|
this.last_error = Some(e.to_string());
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
cx.notify();
|
||||||
backend.announce_published(event.clone(), cx)
|
});
|
||||||
})
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return this.update(cx, |this, cx| {
|
|
||||||
this.last_error = Some(e.to_string());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1545,19 +1593,11 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match publish_result {
|
if let Err(e) = publish_result {
|
||||||
Ok(event) => {
|
this.update(cx, |this, cx| {
|
||||||
this.update(cx, |_this, cx| {
|
this.last_error = Some(e.to_string());
|
||||||
Backend::global(cx)
|
cx.notify();
|
||||||
.update(cx, |backend, cx| backend.announce_published(event, cx))
|
})?;
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.last_error = Some(e.to_string());
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1690,11 +1730,6 @@ async fn publish_patch_series(
|
|||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
let event = require_relay_accepted(output, event)?;
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
|
||||||
backend.announce_published(event.clone(), cx)
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if root.is_none() {
|
if root.is_none() {
|
||||||
root = Some(event.clone());
|
root = Some(event.clone());
|
||||||
@@ -1733,9 +1768,11 @@ fn comment_builder(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use super::{comment_builder, patch_current_commit};
|
use super::{RepoStore, comment_builder, patch_current_commit};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_format_patch_header() {
|
fn parses_format_patch_header() {
|
||||||
@@ -1781,4 +1818,60 @@ mod tests {
|
|||||||
// Signed's own `references_root` must keep matching the comment.
|
// Signed's own `references_root` must keep matching the comment.
|
||||||
assert!(signed_core::references_root(&event, &root.id));
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,32 +68,11 @@ impl RepoListStore {
|
|||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
|
||||||
// Deletions may target anything we list, always refresh.
|
|
||||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
|
||||||
true
|
|
||||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
|
||||||
// Activity events are addressed to repos via `a` tags.
|
|
||||||
// Their author is not the repo owner, always refresh.
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
|
||||||
let is_repo_state = update.kind == Kind::RepoState;
|
|
||||||
is_announcement || is_repo_state
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
BackendEvent::Published(event) => {
|
|
||||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
|
||||||
let state = event.kind == Kind::RepoState;
|
|
||||||
let deletion =
|
|
||||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
|
||||||
|
|
||||||
announcement || state || deletion
|
|
||||||
}
|
|
||||||
BackendEvent::SignerChanged => {
|
BackendEvent::SignerChanged => {
|
||||||
this.state_synced_repos.clear();
|
this.state_synced_repos.clear();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
BackendEvent::RepoUpdates(_) => true,
|
||||||
BackendEvent::Synced => true,
|
BackendEvent::Synced => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -134,10 +113,15 @@ impl RepoListStore {
|
|||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.sync_bootstrap(filters::all_announcements(), cx);
|
backend.sync_bootstraps(
|
||||||
backend.sync_bootstrap(filters::all_states(), cx);
|
vec![
|
||||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
filters::all_announcements(),
|
||||||
backend.sync_bootstrap(filters::deletions(), cx);
|
filters::all_states(),
|
||||||
|
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||||
|
filters::deletions(),
|
||||||
|
],
|
||||||
|
cx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use gpui::{
|
|||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||||
use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters};
|
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||||
};
|
};
|
||||||
@@ -157,21 +157,8 @@ impl InboxView {
|
|||||||
|
|
||||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||||
match event {
|
match event {
|
||||||
BackendEvent::NostrUpdate(updates) => {
|
BackendEvent::RepoUpdates(_) => self.refresh(cx),
|
||||||
let relevant = updates.iter().any(|update| {
|
BackendEvent::Synced => self.refresh(cx),
|
||||||
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
|
|
||||||
let is_comment = update.kind == Kind::Comment;
|
|
||||||
let is_event_deletion = update.kind == Kind::EventDeletion;
|
|
||||||
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
|
|
||||||
|
|
||||||
is_notification || is_comment || is_event_deletion || is_request_to_vanish
|
|
||||||
});
|
|
||||||
|
|
||||||
if relevant {
|
|
||||||
self.refresh(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,10 +557,6 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn activity_phrase(kind: Kind) -> &'static str {
|
fn activity_phrase(kind: Kind) -> &'static str {
|
||||||
if kind == COVER_NOTE_KIND {
|
|
||||||
return "added a note";
|
|
||||||
}
|
|
||||||
|
|
||||||
match kind {
|
match kind {
|
||||||
Kind::GitIssue => "opened an issue",
|
Kind::GitIssue => "opened an issue",
|
||||||
Kind::GitPullRequest => "opened a PR",
|
Kind::GitPullRequest => "opened a PR",
|
||||||
|
|||||||
@@ -79,16 +79,12 @@ impl SidebarPanel {
|
|||||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||||
|
|
||||||
if !signer_changed && !signer_required {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if signer_required {
|
if signer_required {
|
||||||
this.banner = pick_banner();
|
this.banner = pick_banner();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
if this.refresh(cx) || signer_required {
|
if this.refresh(cx) || signer_changed {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -131,16 +127,14 @@ impl SidebarPanel {
|
|||||||
let user = backend.read(cx).current_user();
|
let user = backend.read(cx).current_user();
|
||||||
|
|
||||||
let (announcements, local_repos, scanning) = {
|
let (announcements, local_repos, scanning) = {
|
||||||
let repo_list = RepoListStore::global(cx);
|
let repo_list = RepoListStore::global(cx).read(cx);
|
||||||
let repo_list = repo_list.read(cx);
|
|
||||||
|
|
||||||
let announcements = user
|
let announcements = user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|user| repo_list.announcements_of(user))
|
.map(|user| repo_list.announcements_of(user))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let local = LocalReposStore::global(cx);
|
let local = LocalReposStore::global(cx).read(cx);
|
||||||
let local = local.read(cx);
|
|
||||||
let local_repos =
|
let local_repos =
|
||||||
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
|
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
||||||
v_flex,
|
v_flex,
|
||||||
};
|
};
|
||||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||||
|
|
||||||
use super::{normalize_server, server_host};
|
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.
|
/// Created once when the dialog opens, so control state survives re-renders.
|
||||||
struct SettingsControls {
|
struct SettingsControls {
|
||||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
|
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
font_size: Entity<InputState>,
|
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_options, dark_options) = theme_options(cx);
|
||||||
let light_theme = cx.new(|cx| {
|
let light_theme = cx.new(|cx| {
|
||||||
SelectState::new(
|
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| {
|
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
||||||
if let SelectEvent::Confirm(Some(value)) = event {
|
if let SelectEvent::Confirm(Some(value)) = event {
|
||||||
let store = SettingsStore::global(cx);
|
let store = SettingsStore::global(cx);
|
||||||
@@ -252,6 +283,7 @@ impl SettingsControls {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
appearance,
|
appearance,
|
||||||
|
event_fetching,
|
||||||
light_theme,
|
light_theme,
|
||||||
dark_theme,
|
dark_theme,
|
||||||
font_size,
|
font_size,
|
||||||
@@ -294,6 +326,8 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
|
|||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
.child(grasp_servers_section(&settings, controls, cx))
|
.child(grasp_servers_section(&settings, controls, cx))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
|
.child(event_fetching_section(controls, cx))
|
||||||
|
.child(Separator::horizontal())
|
||||||
.child(repositories_section(&settings, controls, cx))
|
.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 {
|
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
|
|||||||
Reference in New Issue
Block a user