use std::collections::HashMap; use anyhow::Error; use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::spinner::Spinner; use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; use instant::Duration; use nostr_sdk::prelude::*; use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use smallvec::{SmallVec, smallvec}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; use ui::Avatar; pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Screening::new(public_key, window, cx)) } pub struct Screening { /// Public Key of the person being screened. public_key: PublicKey, /// Whether the person's address is verified. verified: bool, /// Whether the person is followed by current user. followed: bool, /// Last time the person was active. last_active: Option, /// All mutual contacts of the person being screened. mutual_contacts: Vec, /// Whether the fallback lookup for mutual contacts has run. mutuals_fallback: bool, /// Person directory for the person and their mutual contacts directory: Entity, tasks: SmallVec<[Task<()>; 3]>, _subscriptions: SmallVec<[Subscription; 2]>, } impl Screening { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> 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| { this.tasks.clear(); 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.fetch_fallback(cx); this.check_last_activity(cx); this.verify_identifier(cx); }); Self { public_key, verified: false, followed: false, last_active: None, mutual_contacts: vec![], mutuals_fallback: false, directory, tasks: smallvec![], _subscriptions: subscriptions, } } fn check_contact(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let public_key = self.public_key; let Some(current_user) = nostr.read(cx).current_user() else { return; }; let task: Task> = cx.background_spawn(async move { // Check if user is in contact list let filter = Filter::new() .author(current_user) .kind(Kind::ContactList) .limit(1); let followed = client .database() .query(filter) .await .unwrap_or_default() .into_iter() .next() .map(|event| event.tags.public_keys().any(|k| k == public_key)) .unwrap_or(false); Ok(followed) }); self.tasks.push(cx.spawn(async move |this, cx| { let result = task.await.unwrap_or(false); this.update(cx, |this, cx| { this.followed = result; cx.notify(); }) .ok(); })); } /// Fetch mutual contacts from the database while the web of trust loads fn fetch_fallback(&mut self, cx: &mut Context) { 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> = 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 contacts = vec![]; if let Ok(events) = client.database().query(filter).await { for event in events.into_iter().filter(|ev| ev.pubkey != current_user) { contacts.push(event.pubkey); } } contacts }); self.tasks.push(cx.spawn(async move |this, cx| { let contacts = task.await; this.update(cx, |this, cx| { if persons.read(cx).wot().read(cx).is_loaded() { return; } this.mutual_contacts = contacts; cx.notify(); }) .ok(); })); } fn check_last_activity(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let public_key = self.public_key; let task: Task> = cx.background_spawn(async move { let filter = Filter::new().author(public_key).limit(1); let mut activity: Option = None; // Construct target for subscription let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS .into_iter() .map(|relay| (relay, vec![filter.clone()])) .collect(); if let Ok(mut stream) = client .stream_events(target) .timeout(Duration::from_secs(TIMEOUT)) .await { while let Some((_url, event)) = stream.next().await { if let Ok(event) = event { activity = Some(event.created_at); } } } activity }); self.tasks.push(cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| { this.last_active = result; cx.notify(); }) .ok(); })); } fn verify_identifier(&mut self, cx: &mut Context) { let http_client = cx.http_client(); let public_key = self.public_key; // Skip if the user doesn't have a NIP-05 identifier let Some(address) = self.address(cx) else { return; }; let task: Task> = cx.background_spawn(async move { address.verify(&http_client, &public_key).await }); self.tasks.push(cx.spawn(async move |this, cx| { let result = task.await.unwrap_or(false); this.update(cx, |this, cx| { this.verified = result; cx.notify(); }) .ok(); })); } fn profile(&self, cx: &Context) -> Person { self.directory.read(cx).person(&self.public_key, cx) } fn address(&self, cx: &Context) -> Option { self.profile(cx) .metadata() .nip05 .and_then(|addr| Nip05Address::parse(&addr).ok()) } fn open_njump(&mut self, _window: &mut Window, cx: &mut Context) { let Ok(bech32) = self.profile(cx).public_key().to_bech32(); cx.open_url(&format!("https://njump.me/{bech32}")); } fn report(&mut self, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); let public_key = self.public_key; let task: Task> = cx.background_spawn(async move { let tag = Tag::from(Nip56Tag::PublicKey { public_key, report: Report::Impersonation, }); let event = EventBuilder::new(Kind::Reporting, "") .tag(tag) .finalize_async(&signer) .await?; // Send the report to the public relays client.send_event(&event).to(BOOTSTRAP_RELAYS).await?; Ok(()) }); self.tasks.push(cx.spawn_in(window, async move |_, cx| { if task.await.is_ok() { cx.update(|window, cx| { window.close_dialog(cx); window.push_notification("Report submitted successfully", cx); }) .ok(); } })); } fn mutual_contacts( &mut self, contacts: Vec, window: &mut Window, cx: &mut Context, ) { let directory = self.directory.clone(); window.open_dialog(cx, move |this, _window, _cx| { let contacts = contacts.clone(); let directory = directory.clone(); let total = contacts.len(); this.title("Mutual contacts").child( v_flex().gap_1().pb_2().child( uniform_list("contacts", total, move |range, _window, cx| { let mut items = Vec::with_capacity(total); for ix in range { let Some(contact) = contacts.get(ix) else { continue; }; let profile = directory.read(cx).person(contact, cx); items.push( h_flex() .h_11() .w_full() .px_2() .gap_1p5() .rounded(cx.theme().radius) .text_xs() .hover(|this| this.bg(cx.theme().primary)) .child( Avatar::new(profile.avatar()) .seed(profile.avatar_seed()) .small(), ) .child(profile.name()), ); } items }) .h(px(300.)), ), ) }); } } impl Render for Screening { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { const CONTACT: &str = "This person is one of your contacts."; const NOT_CONTACT: &str = "This person is not one of your contacts."; const NO_ACTIVITY: &str = "This person hasn't had any activity."; const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate."; const NO_MUTUAL: &str = "You don't have any mutual contacts."; const NIP05_MATCH: &str = "The address matches the user's public key."; 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); // 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() .gap_4() .child( v_flex() .gap_3() .items_center() .justify_center() .text_center() .child( Avatar::new(profile.avatar()) .seed(profile.avatar_seed()) .large(), ) .child( div() .font_semibold() .line_height(relative(1.25)) .child(profile.name()), ), ) .child( h_flex() .gap_3() .child( h_flex() .p_1() .flex_1() .h_7() .justify_center() .rounded_full() .bg(cx.theme().secondary) .text_xs() .truncate() .text_ellipsis() .text_center() .line_height(relative(1.)) .child(shorten_pubkey), ) .child( h_flex() .gap_1() .child( Button::new("njump") .icon(IconName::Link) .label("njump.me") .ghost() .small() .on_click(cx.listener(move |this, _e, window, cx| { this.open_njump(window, cx); })), ) .child( Button::new("report") .tooltip("Report as a scam or impostor") .icon(IconName::TriangleAlert) .warning() .on_click(cx.listener(move |this, _e, window, cx| { this.report(window, cx); })), ), ), ) .child( v_flex() .gap_3() .child( h_flex() .items_start() .gap_2() .text_xs() .child(status_badge(Some(self.followed), cx)) .child( v_flex().text_xs().child("Contact").child( div() .line_clamp(1) .text_color(cx.theme().muted_foreground) .child({ if self.followed { SharedString::from(CONTACT) } else { SharedString::from(NOT_CONTACT) } }), ), ), ) .child( h_flex() .items_start() .gap_2() .text_xs() .child(status_badge(last_active, cx)) .child( v_flex() .text_xs() .child( h_flex() .gap_0p5() .child("Activity on Public Relays") .child( Button::new("active") .icon(IconName::Info) .xsmall() .ghost() .tooltip(RELAY_INFO), ), ) .child( div() .w_full() .line_clamp(1) .text_color(cx.theme().muted_foreground) .map(|this| { if let Some(t) = self.last_active { this.child(SharedString::from(format!( "Last active: {}.", t.to_human_time() ))) } else { this.child(SharedString::from(NO_ACTIVITY)) } }), ), ), ) .child( h_flex() .items_start() .gap_2() .child(status_badge(Some(self.verified), cx)) .child( v_flex() .text_xs() .child({ if let Some(addr) = self.address(cx) { SharedString::from(format!("{} validation", addr)) } else { SharedString::from( "Friendly Address (NIP-05) validation", ) } }) .child( div() .line_clamp(1) .text_color(cx.theme().muted_foreground) .child({ if self.address(cx).is_some() { if self.verified { SharedString::from(NIP05_MATCH) } else { SharedString::from(NIP05_NOT_MATCH) } } else { SharedString::from(NO_NIP05) } }), ), ), ) .child( h_flex() .items_start() .gap_2() .child(status_badge(Some(mutuals > 0), cx)) .child( h_flex() .text_xs() .child( div() .line_clamp(1) .text_color(cx.theme().muted_foreground) .child({ if mutuals > 0 { SharedString::from(mutuals_str) } else { SharedString::from(NO_MUTUAL) } }), ) .child( Button::new("mutuals") .icon(IconName::Info) .xsmall() .ghost() .disabled(mutuals == 0) .on_click(cx.listener(move |this, _, window, cx| { this.mutual_contacts( mutual_contacts.clone(), window, cx, ); })), ), ), ), ) } } fn status_badge(status: Option, cx: &App) -> Div { h_flex() .size_6() .justify_center() .flex_shrink_0() .map(|this| { if let Some(status) = status { this.child(Icon::new(IconName::CircleCheck).small().text_color({ if status { cx.theme().primary } else { cx.theme().muted_foreground } })) } else { this.child(Spinner::new().small()) } }) }