From 828986cd5c231104b47e2d01ab519e0fb8388189 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 29 Sep 2026 08:12:53 +0700 Subject: [PATCH] re-add screening dialog --- crates/workspace/src/dialogs/mod.rs | 1 + crates/workspace/src/dialogs/screening.rs | 545 ++++++++++++++++++++++ crates/workspace/src/sidebar/mod.rs | 25 +- 3 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 crates/workspace/src/dialogs/screening.rs diff --git a/crates/workspace/src/dialogs/mod.rs b/crates/workspace/src/dialogs/mod.rs index 43495b33..11eefa68 100644 --- a/crates/workspace/src/dialogs/mod.rs +++ b/crates/workspace/src/dialogs/mod.rs @@ -2,4 +2,5 @@ pub mod import; pub mod new_chat; pub mod new_community; pub mod restore; +pub mod screening; pub mod settings; diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs new file mode 100644 index 00000000..79738788 --- /dev/null +++ b/crates/workspace/src/dialogs/screening.rs @@ -0,0 +1,545 @@ +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 instant::Duration; +use nostr_sdk::prelude::*; +use person::{Person, PersonRegistry, shorten_pubkey}; +use smallvec::{SmallVec, smallvec}; +use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::indicator::Indicator; +use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Screening::new(public_key, window, cx)) +} + +/// Screening +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, + + /// Async tasks + tasks: SmallVec<[Task<()>; 3]>, + + /// Subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl Screening { + pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.on_release_in(window, move |this, window, cx| { + this.tasks.clear(); + window.close_all_modals(cx); + })); + + cx.defer_in(window, |this, _window, cx| { + this.check_contact(cx); + this.check_wot(cx); + this.check_last_activity(cx); + this.verify_identifier(cx); + }); + + Self { + public_key, + verified: false, + followed: false, + last_active: None, + mutual_contacts: vec![], + 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(); + })); + } + + fn check_wot(&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, Error>> = cx.background_spawn(async move { + // Check mutual contacts + let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key); + let mut mutual_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); + } + } + + Ok(mutual_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(); + } + Err(e) => { + log::error!("Failed to fetch mutual contacts: {}", e); + } + }; + })); + } + + 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 { + let persons = PersonRegistry::global(cx); + persons.read(cx).get(&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_modal(cx); + window.push_notification("Report submitted successfully", cx); + }) + .ok(); + } + })); + } + + fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { + let contacts = self.mutual_contacts.clone(); + + window.open_modal(cx, move |this, _window, _cx| { + let contacts = contacts.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 persons = PersonRegistry::global(cx); + let mut items = Vec::with_capacity(total); + + for ix in range { + let Some(contact) = contacts.get(ix) else { + continue; + }; + let profile = persons.read(cx).get(contact, cx); + + items.push( + h_flex() + .h_11() + .w_full() + .px_2() + .gap_1p5() + .rounded(cx.theme().radius) + .text_sm() + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .child(Avatar::new(profile.avatar()).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 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(); + 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()).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().elevated_surface_background) + .text_sm() + .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") + .secondary() + .small() + .rounded() + .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::Warning) + .small() + .warning() + .rounded() + .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_sm() + .child(status_badge(Some(self.followed), cx)) + .child( + v_flex().text_sm().child("Contact").child( + div() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .child({ + if self.followed { + SharedString::from(CONTACT) + } else { + SharedString::from(NOT_CONTACT) + } + }), + ), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .text_sm() + .child(status_badge(last_active, cx)) + .child( + v_flex() + .text_sm() + .child( + h_flex() + .gap_0p5() + .child("Activity on Public Relays") + .child( + Button::new("active") + .icon(IconName::Info) + .xsmall() + .ghost() + .rounded() + .tooltip(RELAY_INFO), + ), + ) + .child( + div() + .w_full() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .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_sm() + .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().text_muted) + .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_sm() + .child( + div() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .child({ + if mutuals > 0 { + SharedString::from(mutuals_str) + } else { + SharedString::from(NO_MUTUAL) + } + }), + ) + .child( + Button::new("mutuals") + .icon(IconName::Info) + .xsmall() + .ghost() + .rounded() + .disabled(mutuals == 0) + .on_click(cx.listener(move |this, _, window, cx| { + this.mutual_contacts(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::CheckCircle).small().text_color({ + if status { + cx.theme().icon_accent + } else { + cx.theme().icon_muted + } + })) + } else { + this.child(Indicator::new().small()) + } + }) +} diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 36584358..8a13df02 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -16,14 +16,16 @@ use gpui::{ }; use nostr_sdk::prelude::*; use person::PersonRegistry; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; -use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; +use ui::dock::{ClosePanel, DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::indicator::Indicator; use ui::menu::{DropdownMenu, PopupMenuItem}; +use ui::modal::ModalButtonProps; use ui::nav::Nav; use ui::nav_item::NavItem; use ui::notification::Notification; @@ -36,7 +38,7 @@ use ui::{ }; use crate::Command; -use crate::dialogs::import; +use crate::dialogs::{import, screening}; mod tab; mod utils; @@ -844,6 +846,8 @@ impl Sidebar { 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 kind = room.read(cx).kind; + let peer = room.read(cx).display_member(cx).public_key(); let dock = self.dock.clone(); let room = room.clone(); @@ -869,6 +873,23 @@ impl Sidebar { window, cx, ); + if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { + let screening = screening::init(peer, window, cx); + + window.open_modal(cx, move |this, _window, _cx| { + this.confirm() + .child(screening.clone()) + .button_props( + ModalButtonProps::default() + .cancel_text("Ignore") + .ok_text("Response"), + ) + .on_cancel(move |_event, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + true + }) + }); + } }) .into_any_element() }