diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 18b58be2..cb7c5307 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -192,16 +192,38 @@ impl Room { let wot = WebOfTrust::global(cx).read(cx); + Self::trusted_by(&self.members, me, |key| wot.is_in_network(key)) + } + + /// Whether the room looks like spam: everyone other than the current user + /// is outside the web of trust + pub fn is_spam(&self, cx: &App) -> bool { + let nostr = NostrRegistry::global(cx); + let Some(me) = nostr.read(cx).current_user() else { + return false; + }; + + let wot = WebOfTrust::global(cx).read(cx); + + Self::trusted_by(&self.members, me, |key| !wot.is_outside(key)) + } + + /// Whether every member other than `me` passes the check + fn trusted_by( + members: &[PublicKey], + me: PublicKey, + check: impl Fn(&PublicKey) -> bool, + ) -> bool { let mut others = false; - for key in &self.members { + for key in members { if *key == me { continue; } others = true; - if !wot.is_in_network(key) { + if !check(key) { return false; } } diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs index 2613764d..ccd363c7 100644 --- a/crates/person/src/wot.rs +++ b/crates/person/src/wot.rs @@ -434,6 +434,11 @@ impl WebOfTrust { self.follows.contains(public_key) || self.score(public_key) > 0 } + /// Whether the graph is loaded and the public key is outside the network + pub fn is_outside(&self, public_key: &PublicKey) -> bool { + self.ready && !self.disabled && !self.is_in_network(public_key) + } + /// The current user's contacts who follow the public key pub fn mutuals(&self, public_key: &PublicKey) -> Vec { self.reverse @@ -572,6 +577,28 @@ mod tests { assert!(wot.reverse.is_empty()); } + #[test] + fn outside_requires_a_loaded_graph() { + let mut wot = registry(); + let (me, alice, carol) = (key(1), key(2), key(3)); + + wot.self_key = Some(me); + wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice])); + + // The graph is not loaded yet + assert!(!wot.is_outside(&carol)); + + wot.ready = true; + + assert!(wot.is_outside(&carol)); + assert!(!wot.is_outside(&alice)); + + // A disabled graph marks nothing as outside + wot.disabled = true; + + assert!(!wot.is_outside(&carol)); + } + #[test] fn caps_ingested_contact_lists() { let mut wot = registry(); diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 6c1f0af8..e50e4c7a 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -14,6 +14,7 @@ pub fn init(cx: &mut App) { const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; const LEGACY_FILE_SERVER: &str = "blossom.band"; + /// Built-in image shown behind chat and group panels pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg"; @@ -126,42 +127,30 @@ impl RoomConfig { pub struct Settings { #[serde(default, alias = "theme_mode")] pub appearance_mode: AppearanceMode, - /// Hide user avatars pub hide_avatar: bool, - /// Enable screening for unknown chat requests pub screening: bool, - /// Promote chat requests from people followed by the user's contacts - #[serde(default)] pub wot_auto_trust: bool, - /// Enable decoupling encryption key pub nip4e: bool, - /// Hide the notice that messages in public groups are not encrypted #[serde(default)] pub hide_group_notice: bool, - /// Show an image behind chat and group panels #[serde(default)] pub panel_background: bool, - /// Custom image for the panel background; the built-in one when unset #[serde(default)] pub panel_background_path: Option, - /// Trusted relays; Coop will automatically authenticate with these relays pub trusted_relays: Vec, - /// Server for blossom media attachments pub file_server: Url, - /// Pinned chat rooms, by conversation ID #[serde(default)] pub pinned_rooms: Vec, - /// Pinned groups, by cache tag #[serde(default)] pub pinned_groups: Vec, @@ -173,7 +162,7 @@ impl Default for Settings { appearance_mode: AppearanceMode::default(), hide_avatar: false, screening: true, - wot_auto_trust: false, + wot_auto_trust: true, nip4e: false, hide_group_notice: false, panel_background: true, @@ -345,30 +334,3 @@ impl AppSettings { }); } } - -#[cfg(test)] -mod tests { - use super::*; - - fn json_with(partial: &str) -> Result { - // Every field the persisted format has always required; `partial` - // contributes the fields under test. - serde_json::from_str(&format!( - r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false, - "trusted_relays": [], "file_server": "https://nostr.download/" }}"#, - partial - )) - } - - #[test] - fn old_theme_mode_key_keeps_its_pinned_choice() { - let settings = json_with(r#""theme_mode": "Dark", "#).unwrap(); - assert_eq!(settings.appearance_mode, AppearanceMode::Dark); - } - - #[test] - fn missing_appearance_mode_falls_back_to_dark() { - let settings = json_with("").unwrap(); - assert_eq!(settings.appearance_mode, AppearanceMode::Dark); - } -} diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index f52965d1..96fb46a5 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -394,7 +394,7 @@ impl Render for Screening { Button::new("njump") .icon(IconName::Link) .label("njump.me") - .secondary() + .ghost_alt() .small() .rounded() .on_click(cx.listener(move |this, _e, window, cx| { @@ -405,7 +405,6 @@ impl Render for Screening { Button::new("report") .tooltip("Report as a scam or impostor") .icon(IconName::Warning) - .small() .warning() .rounded() .on_click(cx.listener(move |this, _e, window, cx| { diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs index f5779955..b4e90d5f 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -120,7 +120,7 @@ impl Sidebar { .px_4() .text_style(TextStyle::Caption) .font_semibold() - .text_color(cx.theme().text_faint) + .text_color(cx.theme().text_muted) .child(list_title), ) }) diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs index 986eb145..824316c8 100644 --- a/crates/workspace/src/sidebar/rows.rs +++ b/crates/workspace/src/sidebar/rows.rs @@ -118,7 +118,7 @@ impl Sidebar { .label(label) .text_style(TextStyle::Caption) .font_semibold() - .text_color(cx.theme().text_faint) + .text_color(cx.theme().text_muted) .into_any_element() } SidebarRow::Empty(text) => v_flex() @@ -136,17 +136,20 @@ impl Sidebar { .border_color(cx.theme().border) .rounded(px(button_radius())) .text_style(TextStyle::Caption) - .text_color(cx.theme().text_faint) + .text_color(cx.theme().text_muted) .child(*text), ) .into_any_element(), SidebarRow::Room { room } => { + let id = room.read(cx).id; let name = room.read(cx).display_name(cx); let picture = room.read(cx).display_image(cx); let seed = room.read(cx).display_image_seed(cx); let created_at = room.read(cx).created_at.to_ago(); - let id = room.read(cx).id; let member = room.read(cx).members().first().copied(); + let spam = + room.read(cx).is_spam(cx) && room.read(cx).kind == RoomKind::Request; + let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx); let dock = self.dock.clone(); let room = room.clone(); @@ -163,11 +166,28 @@ impl Sidebar { ) }) .suffix( - div() - .font_normal() - .text_style(TextStyle::Caption) - .text_color(cx.theme().text_faint) - .child(created_at), + h_flex() + .gap_1() + .when(spam, |this| { + this.child( + div() + .px_1() + .rounded(px(button_radius())) + .bg(cx.theme().danger.opacity(0.15)) + .text_size(px(8.0)) + .text_color(cx.theme().danger) + .child("SPAM"), + ) + }) + .when(!spam, |this| { + this.child( + div() + .font_normal() + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child(created_at), + ) + }), ) .on_click(move |_event, window, cx| { open_room(&room, &dock, window, cx); diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md index 4cedff0b..ad752e5f 100644 --- a/docs/web-of-trust.md +++ b/docs/web-of-trust.md @@ -146,6 +146,13 @@ to be cached, so "mutual contacts" is usually empty) with the registry: ### Phase 2 — requests list badge +Status: **partially implemented**. Request rooms whose members are all outside +the web of trust show a `SPAM` badge in the sidebar +(`Room::is_spam` + `WebOfTrust::is_outside`, rendered in +`crates/workspace/src/sidebar/rows.rs`; the sidebar subscribes to `WoTEvent` so +badges update when the graph loads). The mutual-count caption below is still +open and will follow a different flow. + Where request rooms are rendered, show the mutual count for the sender: - `crates/workspace/src/sidebar/inbox.rs` (`requests_rows`) and @@ -162,7 +169,9 @@ flow). The payoff: strangers vouched for by the user's contacts can skip screening. - `pub wot_auto_trust: bool` in `setting_accessors!` in - `crates/settings/src/lib.rs` (default `false`) and a "Web of trust" toggle in + `crates/settings/src/lib.rs` (enabled out of the box: `Default` is `true` and + a `#[serde(default = "default_true")]` fallback covers settings files saved + before the field existed) and a "Web of trust" toggle in the preferences screen (`crates/workspace/src/dialogs/settings.rs`). - The rule lives in one predicate — `Room::auto_trusted(me, cx)` in `crates/chat/src/room.rs`: the setting is on and every member other than the