feat: add support for basic web of trust (#65)

Reviewed-on: #65
This commit was merged in pull request #65.
This commit is contained in:
2026-10-20 13:43:37 +00:00
parent a22aecc694
commit f44f0704f3
14 changed files with 720 additions and 241 deletions
+15
View File
@@ -459,9 +459,24 @@ impl Render for ProfileDialog {
.filter(|website| !website.trim().is_empty())
.map(SharedString::from);
let score = {
let wot = PersonRegistry::global(cx).read(cx).wot().read(cx);
wot.is_loaded().then(|| wot.score(&self.public_key))
}
.map(|score| {
SharedString::from(format!(
"{} mutual {}",
score,
if score == 1 { "contact" } else { "contacts" }
))
});
this.child(field("Name", name, "Unknown", cx))
.child(field("About", about, "No bio", cx))
.child(field("Website", website, "No website", cx))
.when(!self.is_current_user, |this| {
this.child(field("Web of Trust", score, "Not loaded yet", cx))
})
})
.child(
h_flex()
+65 -29
View File
@@ -9,7 +9,7 @@ use gpui::{
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Directory, Person, shorten_pubkey};
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
@@ -33,14 +33,19 @@ pub struct Screening {
last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// Whether the fallback lookup for mutual contacts has run.
mutuals_fallback: bool,
/// Person directory for the person and their mutual contacts
directory: Entity<Directory>,
tasks: SmallVec<[Task<()>; 3]>,
_subscriptions: SmallVec<[Subscription; 1]>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let wot = PersonRegistry::global(cx).read(cx).wot();
let directory = Directory::new(cx);
let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
@@ -48,22 +53,28 @@ impl Screening {
window.close_all_dialogs(cx);
}));
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
cx.notify();
}));
directory.update(cx, |directory, cx| {
directory.track([public_key], cx);
});
cx.defer_in(window, |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
this.fetch_fallback(cx);
this.check_last_activity(cx);
this.verify_identifier(cx);
});
let directory = Directory::new(cx);
directory.update(cx, |directory, cx| directory.track([public_key], cx));
Self {
public_key,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
mutuals_fallback: false,
directory,
tasks: smallvec![],
_subscriptions: subscriptions,
@@ -110,42 +121,48 @@ impl Screening {
}));
}
fn check_wot(&mut self, cx: &mut Context<Self>) {
/// Fetch mutual contacts from the database while the web of trust loads
fn fetch_fallback(&mut self, cx: &mut Context<Self>) {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
if self.mutuals_fallback || persons.read(cx).wot().read(cx).is_loaded() {
return;
}
self.mutuals_fallback = true;
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
// Check mutual contacts
let task: Task<Vec<PublicKey>> = cx.background_spawn(async move {
// Check mutual contacts among the locally stored contact lists
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
let mut contacts = vec![];
if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
mutual_contacts.push(event.pubkey);
contacts.push(event.pubkey);
}
}
Ok(mutual_contacts)
contacts
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.mutual_contacts = contacts;
cx.notify();
})
.ok();
let contacts = task.await;
this.update(cx, |this, cx| {
if persons.read(cx).wot().read(cx).is_loaded() {
return;
}
Err(e) => {
log::error!("Failed to fetch mutual contacts: {}", e);
}
};
this.mutual_contacts = contacts;
cx.notify();
})
.ok();
}));
}
@@ -263,8 +280,12 @@ impl Screening {
}));
}
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone();
fn mutual_contacts(
&mut self,
contacts: Vec<PublicKey>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let directory = self.directory.clone();
window.open_dialog(cx, move |this, _window, _cx| {
@@ -317,11 +338,23 @@ impl Render for Screening {
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NO_NIP05: &str = "This person has not set up their friendly address";
let persons = PersonRegistry::global(cx);
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let last_active = self.last_active.map(|_| true);
let mutuals = self.mutual_contacts.len();
// Pull from the web of trust once loaded, else use the database fallback
let mutual_contacts = {
let wot = persons.read(cx).wot().read(cx);
if wot.is_loaded() {
wot.mutuals(&self.public_key)
} else {
self.mutual_contacts.clone()
}
};
let mutuals = mutual_contacts.len();
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
v_flex()
@@ -365,7 +398,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| {
@@ -376,7 +409,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| {
@@ -515,7 +547,11 @@ impl Render for Screening {
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
this.mutual_contacts(
mutual_contacts.clone(),
window,
cx,
);
})),
),
),
+11
View File
@@ -83,12 +83,14 @@ impl Preferences {
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const MODE: &str = "Follow the OS, or always use light or dark.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const BACKGROUND: &str = "Show an image behind chat and group panels.";
let screening = AppSettings::get_screening(cx);
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx);
@@ -116,6 +118,15 @@ impl Render for Preferences {
AppSettings::update_screening(!screening, cx);
}),
)
.child(
Switch::new("wot-auto-trust")
.label("Web of Trust")
.description(WOT)
.checked(wot_auto_trust)
.on_click(move |_, _window, cx| {
AppSettings::update_wot_auto_trust(!wot_auto_trust, cx);
}),
)
.child(
Switch::new("avatar")
.label("Hide user avatar")
+135 -109
View File
@@ -10,6 +10,7 @@ use gpui::{
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, svg,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
@@ -57,13 +58,14 @@ pub struct GreeterPanel {
/// The coalesced reload scheduled after registry updates
refresh_task: Option<Task<Result<(), Error>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
_subscriptions: SmallVec<[Subscription; 3]>,
}
impl GreeterPanel {
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let mut subscriptions = smallvec![];
@@ -76,6 +78,13 @@ impl GreeterPanel {
}),
);
subscriptions.push(
// Redraw when the web of trust loads or changes
cx.subscribe(&wot, |_, _, _, cx| {
cx.notify();
}),
);
subscriptions.push(
// Reload soon after rooms load, messages arrive, or requests change
cx.observe(&chat, |this, _, cx| {
@@ -176,120 +185,137 @@ impl GreeterPanel {
}));
}
fn render_requests(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.requests.is_empty() {
return None;
}
fn render_requests(&mut self, cx: &mut Context<Self>) -> AnyElement {
// Spam requests stay out of the greeter entirely
let requests: Vec<_> = self
.requests
.iter()
.filter(|(room, _)| !room.read(cx).is_spam(cx))
.cloned()
.collect();
let dock = self.dock.clone();
let rows: Vec<AnyElement> = self
.requests
.iter()
.take(SECTION_SIZE)
.enumerate()
.map(|(index, (room, preview))| {
let room = room.clone();
let dock = dock.clone();
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 preview = preview.clone();
let rows: Vec<AnyElement> = if requests.is_empty() {
vec![
h_flex()
.id(format!("request-{index}"))
.group("")
.h_16()
.w_full()
.h_9()
.px_1p5()
.gap_2()
.justify_center()
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(px(button_radius()))
.cursor_pointer()
.hover(|this| this.bg(cx.theme().element_hover))
.on_click({
let room = room.clone();
let dock = dock.clone();
move |_, window, cx| {
open_room(&room, &dock, window, cx);
}
})
.child(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
.child(
div()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.child(name),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.italic()
.child(preview),
)
.child(
div()
.relative()
.flex_shrink_0()
.h_6()
.flex()
.items_center()
.justify_end()
.child(
div()
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0()
.group_hover("", |this| this.invisible())
.child(
div()
.text_align(gpui::TextAlign::Right)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(created_at),
),
)
.child(
Button::new(format!("response-{index}"))
.label("Response")
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let room = room.clone();
let dock = dock.clone();
move |_, window, cx| {
cx.stop_propagation();
open_room(&room, &dock, window, cx);
}
}),
),
)
.into_any_element()
})
.collect();
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child("No chat requests")
.into_any_element(),
]
} else {
requests
.iter()
.enumerate()
.map(|(index, (room, preview))| {
let room = room.clone();
let dock = dock.clone();
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 preview = preview.clone();
Some(
GroupBox::new()
.id("requests")
.title("Requests")
.fill()
.child(v_flex().gap_0().w_full().children(rows))
.into_any_element(),
)
h_flex()
.id(format!("request-{index}"))
.group("")
.w_full()
.h_9()
.px_1p5()
.gap_2()
.rounded(px(button_radius()))
.cursor_pointer()
.hover(|this| this.bg(cx.theme().element_hover))
.on_click({
let room = room.clone();
let dock = dock.clone();
move |_, window, cx| {
open_room(&room, &dock, window, cx);
}
})
.child(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
.child(
div()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.child(name),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.italic()
.child(preview),
)
.child(
div()
.relative()
.flex_shrink_0()
.h_6()
.flex()
.items_center()
.justify_end()
.child(
div()
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0()
.group_hover("", |this| this.invisible())
.child(
div()
.text_align(gpui::TextAlign::Right)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(created_at),
),
)
.child(
Button::new(format!("response-{index}"))
.label("Response")
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let room = room.clone();
let dock = dock.clone();
move |_, window, cx| {
cx.stop_propagation();
open_room(&room, &dock, window, cx);
}
}),
),
)
.into_any_element()
})
.collect()
};
GroupBox::new()
.id("requests")
.title("Requests")
.fill()
.child(v_flex().gap_0().w_full().children(rows))
.into_any_element()
}
fn render_follow_ups(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
@@ -503,7 +529,7 @@ impl Render for GreeterPanel {
.size_full()
.p_2()
.gap_6()
.children(self.render_requests(cx))
.child(self.render_requests(cx))
.children(self.render_follow_ups(cx))
.child(self.render_find_friends(cx)),
)
+1 -1
View File
@@ -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),
)
})
+8 -1
View File
@@ -13,7 +13,7 @@ use gpui::{
use instant::Duration;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use person::Directory;
use person::{Directory, PersonRegistry};
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
@@ -103,6 +103,7 @@ impl Sidebar {
let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let find_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -121,6 +122,12 @@ impl Sidebar {
}),
);
subscriptions.push(
cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| {
cx.notify();
}),
);
subscriptions.push(
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
match event {
+28 -8
View File
@@ -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);