feat: add support for basic web of trust #65
+24
-2
@@ -192,16 +192,38 @@ impl Room {
|
|||||||
|
|
||||||
let wot = WebOfTrust::global(cx).read(cx);
|
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;
|
let mut others = false;
|
||||||
|
|
||||||
for key in &self.members {
|
for key in members {
|
||||||
if *key == me {
|
if *key == me {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
others = true;
|
others = true;
|
||||||
|
|
||||||
if !wot.is_in_network(key) {
|
if !check(key) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -434,6 +434,11 @@ impl WebOfTrust {
|
|||||||
self.follows.contains(public_key) || self.score(public_key) > 0
|
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
|
/// The current user's contacts who follow the public key
|
||||||
pub fn mutuals(&self, public_key: &PublicKey) -> Vec<PublicKey> {
|
pub fn mutuals(&self, public_key: &PublicKey) -> Vec<PublicKey> {
|
||||||
self.reverse
|
self.reverse
|
||||||
@@ -572,6 +577,28 @@ mod tests {
|
|||||||
assert!(wot.reverse.is_empty());
|
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]
|
#[test]
|
||||||
fn caps_ingested_contact_lists() {
|
fn caps_ingested_contact_lists() {
|
||||||
let mut wot = registry();
|
let mut wot = registry();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub fn init(cx: &mut App) {
|
|||||||
|
|
||||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||||
|
|
||||||
/// Built-in image shown behind chat and group panels
|
/// Built-in image shown behind chat and group panels
|
||||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||||
|
|
||||||
@@ -126,42 +127,30 @@ impl RoomConfig {
|
|||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
#[serde(default, alias = "theme_mode")]
|
#[serde(default, alias = "theme_mode")]
|
||||||
pub appearance_mode: AppearanceMode,
|
pub appearance_mode: AppearanceMode,
|
||||||
|
|
||||||
/// Hide user avatars
|
/// Hide user avatars
|
||||||
pub hide_avatar: bool,
|
pub hide_avatar: bool,
|
||||||
|
|
||||||
/// Enable screening for unknown chat requests
|
/// Enable screening for unknown chat requests
|
||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
|
|
||||||
/// Promote chat requests from people followed by the user's contacts
|
/// Promote chat requests from people followed by the user's contacts
|
||||||
#[serde(default)]
|
|
||||||
pub wot_auto_trust: bool,
|
pub wot_auto_trust: bool,
|
||||||
|
|
||||||
/// Enable decoupling encryption key
|
/// Enable decoupling encryption key
|
||||||
pub nip4e: bool,
|
pub nip4e: bool,
|
||||||
|
|
||||||
/// Hide the notice that messages in public groups are not encrypted
|
/// Hide the notice that messages in public groups are not encrypted
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hide_group_notice: bool,
|
pub hide_group_notice: bool,
|
||||||
|
|
||||||
/// Show an image behind chat and group panels
|
/// Show an image behind chat and group panels
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub panel_background: bool,
|
pub panel_background: bool,
|
||||||
|
|
||||||
/// Custom image for the panel background; the built-in one when unset
|
/// Custom image for the panel background; the built-in one when unset
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub panel_background_path: Option<PathBuf>,
|
pub panel_background_path: Option<PathBuf>,
|
||||||
|
|
||||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||||
pub trusted_relays: Vec<String>,
|
pub trusted_relays: Vec<String>,
|
||||||
|
|
||||||
/// Server for blossom media attachments
|
/// Server for blossom media attachments
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
|
|
||||||
/// Pinned chat rooms, by conversation ID
|
/// Pinned chat rooms, by conversation ID
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pinned_rooms: Vec<u64>,
|
pub pinned_rooms: Vec<u64>,
|
||||||
|
|
||||||
/// Pinned groups, by cache tag
|
/// Pinned groups, by cache tag
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pinned_groups: Vec<String>,
|
pub pinned_groups: Vec<String>,
|
||||||
@@ -173,7 +162,7 @@ impl Default for Settings {
|
|||||||
appearance_mode: AppearanceMode::default(),
|
appearance_mode: AppearanceMode::default(),
|
||||||
hide_avatar: false,
|
hide_avatar: false,
|
||||||
screening: true,
|
screening: true,
|
||||||
wot_auto_trust: false,
|
wot_auto_trust: true,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
hide_group_notice: false,
|
hide_group_notice: false,
|
||||||
panel_background: true,
|
panel_background: true,
|
||||||
@@ -345,30 +334,3 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn json_with(partial: &str) -> Result<Settings, serde_json::Error> {
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ impl Render for Screening {
|
|||||||
Button::new("njump")
|
Button::new("njump")
|
||||||
.icon(IconName::Link)
|
.icon(IconName::Link)
|
||||||
.label("njump.me")
|
.label("njump.me")
|
||||||
.secondary()
|
.ghost_alt()
|
||||||
.small()
|
.small()
|
||||||
.rounded()
|
.rounded()
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
@@ -405,7 +405,6 @@ impl Render for Screening {
|
|||||||
Button::new("report")
|
Button::new("report")
|
||||||
.tooltip("Report as a scam or impostor")
|
.tooltip("Report as a scam or impostor")
|
||||||
.icon(IconName::Warning)
|
.icon(IconName::Warning)
|
||||||
.small()
|
|
||||||
.warning()
|
.warning()
|
||||||
.rounded()
|
.rounded()
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ impl Sidebar {
|
|||||||
.px_4()
|
.px_4()
|
||||||
.text_style(TextStyle::Caption)
|
.text_style(TextStyle::Caption)
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_faint)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(list_title),
|
.child(list_title),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ impl Sidebar {
|
|||||||
.label(label)
|
.label(label)
|
||||||
.text_style(TextStyle::Caption)
|
.text_style(TextStyle::Caption)
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_faint)
|
.text_color(cx.theme().text_muted)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
SidebarRow::Empty(text) => v_flex()
|
SidebarRow::Empty(text) => v_flex()
|
||||||
@@ -136,17 +136,20 @@ impl Sidebar {
|
|||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.rounded(px(button_radius()))
|
.rounded(px(button_radius()))
|
||||||
.text_style(TextStyle::Caption)
|
.text_style(TextStyle::Caption)
|
||||||
.text_color(cx.theme().text_faint)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(*text),
|
.child(*text),
|
||||||
)
|
)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
SidebarRow::Room { room } => {
|
SidebarRow::Room { room } => {
|
||||||
|
let id = room.read(cx).id;
|
||||||
let name = room.read(cx).display_name(cx);
|
let name = room.read(cx).display_name(cx);
|
||||||
let picture = room.read(cx).display_image(cx);
|
let picture = room.read(cx).display_image(cx);
|
||||||
let seed = room.read(cx).display_image_seed(cx);
|
let seed = room.read(cx).display_image_seed(cx);
|
||||||
let created_at = room.read(cx).created_at.to_ago();
|
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 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 pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
|
||||||
let dock = self.dock.clone();
|
let dock = self.dock.clone();
|
||||||
let room = room.clone();
|
let room = room.clone();
|
||||||
@@ -163,12 +166,29 @@ impl Sidebar {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.suffix(
|
.suffix(
|
||||||
|
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()
|
div()
|
||||||
.font_normal()
|
.font_normal()
|
||||||
.text_style(TextStyle::Caption)
|
.text_style(TextStyle::Caption)
|
||||||
.text_color(cx.theme().text_faint)
|
.text_color(cx.theme().text_faint)
|
||||||
.child(created_at),
|
.child(created_at),
|
||||||
)
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
.on_click(move |_event, window, cx| {
|
.on_click(move |_event, window, cx| {
|
||||||
open_room(&room, &dock, window, cx);
|
open_room(&room, &dock, window, cx);
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-1
@@ -146,6 +146,13 @@ to be cached, so "mutual contacts" is usually empty) with the registry:
|
|||||||
|
|
||||||
### Phase 2 — requests list badge
|
### 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:
|
Where request rooms are rendered, show the mutual count for the sender:
|
||||||
|
|
||||||
- `crates/workspace/src/sidebar/inbox.rs` (`requests_rows`) and
|
- `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.
|
The payoff: strangers vouched for by the user's contacts can skip screening.
|
||||||
|
|
||||||
- `pub wot_auto_trust: bool` in `setting_accessors!` in
|
- `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 preferences screen (`crates/workspace/src/dialogs/settings.rs`).
|
||||||
- The rule lives in one predicate — `Room::auto_trusted(me, cx)` in
|
- 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
|
`crates/chat/src/room.rs`: the setting is on and every member other than the
|
||||||
|
|||||||
Reference in New Issue
Block a user