feat: extended screening (#144)

* improve mutual contacts check

* .

* .
This commit is contained in:
reya
2025-09-08 17:11:29 +07:00
committed by GitHub
parent 71140beb52
commit d0f7a1abd3
6 changed files with 349 additions and 166 deletions

View File

@@ -221,12 +221,12 @@ impl ChatSpace {
let nip65 = Filter::new().kind(Kind::RelayList).author(public_key);
if client.database().count(nip65).await.unwrap_or(0) > 0 {
let nip17 = Filter::new().kind(Kind::InboxRelays).author(public_key);
let dm_relays = Filter::new().kind(Kind::InboxRelays).author(public_key);
match client.database().query(nip17).await {
match client.database().query(dm_relays).await {
Ok(events) => {
if let Some(event) = events.first_owned() {
let relay_urls = Self::extract_relay_list(&event);
let relay_urls = nip17::extract_relay_list(&event).collect_vec();
if relay_urls.is_empty() {
if !is_sent_signal {
@@ -374,9 +374,6 @@ impl ChatSpace {
let ingester = ingester();
let css = css();
let auto_close =
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let mut processed_events: HashSet<EventId> = HashSet::new();
let mut challenges: HashSet<Cow<'_, str>> = HashSet::new();
let mut notifications = client.notifications();
@@ -395,7 +392,6 @@ impl ChatSpace {
match event.kind {
Kind::RelayList => {
// Get metadata for event's pubkey that matches the current user's pubkey
if let Ok(true) = Self::is_self_event(&event).await {
// Fetch user's metadata event
Self::fetch_single_event(Kind::Metadata, event.pubkey).await;
@@ -409,10 +405,10 @@ impl ChatSpace {
}
Kind::InboxRelays => {
if let Ok(true) = Self::is_self_event(&event).await {
let relays: Vec<RelayUrl> = Self::extract_relay_list(&event);
let relays = nip17::extract_relay_list(&event).collect_vec();
if !relays.is_empty() {
for relay in relays.iter() {
for relay in relays.clone().into_iter() {
if client.add_relay(relay).await.is_err() {
let notice = Notice::RelayFailed(relay.clone());
ingester.send(Signal::Notice(notice)).await;
@@ -424,7 +420,7 @@ impl ChatSpace {
}
// Subscribe to gift wrap events only in the current user's NIP-17 relays
Self::fetch_gift_wrap(&relays, event.pubkey).await;
Self::fetch_gift_wrap(relays, event.pubkey).await;
}
}
}
@@ -437,7 +433,7 @@ impl ChatSpace {
Filter::new().limit(limit).authors(public_keys).kinds(kinds);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(auto_close))
.subscribe_to(BOOTSTRAP_RELAYS, filter, css.auto_close_opts)
.await
.ok();
}
@@ -587,20 +583,6 @@ impl ChatSpace {
}
}
fn extract_relay_list(event: &Event) -> Vec<RelayUrl> {
event
.tags
.filter_standardized(TagKind::Relay)
.filter_map(|t| {
if let TagStandard::Relay(url) = t {
Some(url.to_owned())
} else {
None
}
})
.collect()
}
/// Checks if an event is belong to the current user
async fn is_self_event(event: &Event) -> Result<bool, Error> {
let client = nostr_client();
@@ -613,22 +595,21 @@ impl ChatSpace {
/// Fetches a single event by kind and public key
pub async fn fetch_single_event(kind: Kind, public_key: PublicKey) {
let client = nostr_client();
let auto_close =
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let css = css();
let filter = Filter::new().kind(kind).author(public_key).limit(1);
if let Err(e) = client.subscribe(filter, Some(auto_close)).await {
if let Err(e) = client.subscribe(filter, css.auto_close_opts).await {
log::info!("Failed to subscribe: {e}");
}
}
pub async fn fetch_gift_wrap(relays: &[RelayUrl], public_key: PublicKey) {
pub async fn fetch_gift_wrap(relays: Vec<&RelayUrl>, public_key: PublicKey) {
let client = nostr_client();
let sub_id = css().gift_wrap_sub_id.clone();
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
if client
.subscribe_with_id_to(relays.to_owned(), sub_id, filter, None)
.subscribe_with_id_to(relays.clone(), sub_id, filter, None)
.await
.is_ok()
{
@@ -639,8 +620,7 @@ impl ChatSpace {
/// Fetches NIP-65 relay list for a given public key
pub async fn fetch_nip65_relays(public_key: PublicKey) -> Result<(), Error> {
let client = nostr_client();
let auto_close =
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let css = css();
let filter = Filter::new()
.kind(Kind::RelayList)
@@ -648,7 +628,7 @@ impl ChatSpace {
.limit(1);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(auto_close))
.subscribe_to(BOOTSTRAP_RELAYS, filter, css.auto_close_opts)
.await?;
Ok(())
@@ -656,18 +636,15 @@ impl ChatSpace {
/// Fetches metadata for a list of public keys
async fn fetch_metadata_for_pubkeys(public_keys: HashSet<PublicKey>) {
if public_keys.is_empty() {
return;
};
let client = nostr_client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let kinds = vec![Kind::Metadata, Kind::ContactList];
let limit = public_keys.len() * kinds.len();
let css = css();
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
let limit = public_keys.len() * kinds.len() + 20; // + 20 to ensure Coop has enough metadata
let filter = Filter::new().limit(limit).authors(public_keys).kinds(kinds);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.subscribe_to(BOOTSTRAP_RELAYS, filter, css.auto_close_opts)
.await
.ok();
}

View File

@@ -1,9 +1,13 @@
use std::time::Duration;
use common::display::{shorten_pubkey, ReadableProfile};
use common::nip05::nip05_verify;
use global::constants::BOOTSTRAP_RELAYS;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Task, Window,
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::{shared_t, t};
@@ -14,6 +18,7 @@ use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::indicator::Indicator;
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
@@ -24,9 +29,10 @@ pub struct Screening {
profile: Profile,
verified: bool,
followed: bool,
dm_relays: bool,
mutual_contacts: usize,
_tasks: SmallVec<[Task<()>; 1]>,
dm_relays: Option<bool>,
active: Option<bool>,
mutual_contacts: Vec<Profile>,
_tasks: SmallVec<[Task<()>; 4]>,
}
impl Screening {
@@ -37,33 +43,69 @@ impl Screening {
let mut tasks = smallvec![];
let check_trust_score: Task<(bool, usize, bool)> = cx.background_spawn(async move {
let contact_check: Task<(bool, Vec<Profile>)> = cx.background_spawn(async move {
let client = nostr_client();
let follow = Filter::new()
.kind(Kind::ContactList)
.author(identity)
.pubkey(public_key)
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(identity).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
// Check mutual contacts
let contact_list = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(contact_list).await {
for event in events.into_iter().filter(|ev| ev.pubkey != identity) {
if let Ok(metadata) = client.database().metadata(event.pubkey).await {
let profile = Profile::new(event.pubkey, metadata.unwrap_or_default());
mutual_contacts.push(profile);
}
}
}
(followed, mutual_contacts)
});
let activity_check = cx.background_spawn(async move {
let client = nostr_client();
let mut activity = false;
let filter = Filter::new()
.author(public_key)
.since(Timestamp::now() - Duration::from_secs(172800))
.limit(1);
let contacts = Filter::new()
.kind(Kind::ContactList)
.pubkey(public_key)
.limit(1);
if let Ok(mut stream) = client
.stream_events_from(BOOTSTRAP_RELAYS, filter, Duration::from_secs(2))
.await
{
while stream.next().await.is_some() {
activity = true
}
}
let relays = Filter::new()
activity
});
let relay_check = cx.background_spawn(async move {
let client = nostr_client();
let mut relay = false;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let is_follow = client.database().count(follow).await.unwrap_or(0) >= 1;
let mutual_contacts = client.database().count(contacts).await.unwrap_or(0);
let dm_relays = client.database().count(relays).await.unwrap_or(0) >= 1;
if let Ok(mut stream) = client.stream_events(filter, Duration::from_secs(2)).await {
while stream.next().await.is_some() {
relay = true
}
}
(is_follow, mutual_contacts, dm_relays)
relay
});
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
let addr_check = if let Some(address) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
@@ -72,20 +114,49 @@ impl Screening {
};
tasks.push(
// Load all necessary data
// Run the contact check in the background
cx.spawn_in(window, async move |this, cx| {
let (followed, mutual_contacts, dm_relays) = check_trust_score.await;
let (followed, mutual_contacts) = contact_check.await;
this.update(cx, |this, cx| {
this.followed = followed;
this.mutual_contacts = mutual_contacts;
this.dm_relays = dm_relays;
cx.notify();
})
.ok();
}),
);
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
tasks.push(
// Run the activity check in the background
cx.spawn_in(window, async move |this, cx| {
let active = activity_check.await;
this.update(cx, |this, cx| {
this.active = Some(active);
cx.notify();
})
.ok();
}),
);
tasks.push(
// Run the relay check in the background
cx.spawn_in(window, async move |this, cx| {
let relay = relay_check.await;
this.update(cx, |this, cx| {
this.dm_relays = Some(relay);
cx.notify();
})
.ok();
}),
);
tasks.push(
// Run the NIP-05 verification in the background
cx.spawn_in(window, async move |this, cx| {
if let Some(task) = addr_check {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
@@ -101,8 +172,9 @@ impl Screening {
profile,
verified: false,
followed: false,
dm_relays: false,
mutual_contacts: 0,
dm_relays: None,
active: None,
mutual_contacts: vec![],
_tasks: tasks,
}
}
@@ -141,12 +213,54 @@ impl Screening {
})
.detach();
}
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone();
window.open_modal(cx, move |this, _window, _cx| {
let contacts = contacts.clone();
let total = contacts.len();
this.title(shared_t!("screening.mutual_label")).child(
v_flex().gap_1().pb_4().child(
uniform_list("contacts", total, move |range, _window, cx| {
let mut items = Vec::with_capacity(total);
for ix in range {
if let Some(contact) = contacts.get(ix) {
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(contact.avatar_url(true)).size(rems(1.75)),
)
.child(contact.display_name()),
);
}
}
items
})
.h(px(300.)),
),
)
});
}
}
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8);
let total_mutuals = self.mutual_contacts.len();
v_flex()
.gap_4()
@@ -168,12 +282,10 @@ impl Render for Screening {
h_flex()
.gap_3()
.child(
div()
h_flex()
.p_1()
.flex_1()
.h_7()
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(cx.theme().surface_background)
@@ -217,25 +329,54 @@ impl Render for Screening {
.items_start()
.gap_2()
.text_sm()
.child(status_badge(self.followed, cx))
.child(status_badge(Some(self.followed), cx))
.child(
v_flex()
.text_sm()
.child(shared_t!("screening.contact_label"))
.child(div().text_color(cx.theme().text_muted).child({
if self.followed {
shared_t!("screening.contact")
} else {
shared_t!("screening.not_contact")
}
})),
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
shared_t!("screening.contact")
} else {
shared_t!("screening.not_contact")
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(self.verified, cx))
.text_sm()
.child(status_badge(self.active, cx))
.child(
v_flex()
.text_sm()
.child(shared_t!("screening.active_label"))
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.active == Some(true) {
shared_t!("screening.active")
} else {
shared_t!("screening.no_active")
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_sm()
@@ -246,35 +387,61 @@ impl Render for Screening {
shared_t!("screening.nip05_label")
}
})
.child(div().text_color(cx.theme().text_muted).child({
if self.address(cx).is_some() {
if self.verified {
shared_t!("screening.nip05_ok")
} else {
shared_t!("screening.nip05_failed")
}
} else {
shared_t!("screening.nip05_empty")
}
})),
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.address(cx).is_some() {
if self.verified {
shared_t!("screening.nip05_ok")
} else {
shared_t!("screening.nip05_failed")
}
} else {
shared_t!("screening.nip05_empty")
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(self.mutual_contacts > 0, cx))
.child(status_badge(Some(total_mutuals > 0), cx))
.child(
v_flex()
.text_sm()
.child(shared_t!("screening.mutual_label"))
.child(div().text_color(cx.theme().text_muted).child({
if self.mutual_contacts > 0 {
shared_t!("screening.mutual", u = self.mutual_contacts)
} else {
shared_t!("screening.no_mutual")
}
})),
.child(
h_flex()
.gap_0p5()
.child(shared_t!("screening.mutual_label"))
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded(ButtonRounded::Full)
.on_click(cx.listener(
move |this, _, window, cx| {
this.mutual_contacts(window, cx);
},
)),
),
)
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if total_mutuals > 0 {
shared_t!("screening.mutual", u = total_mutuals)
} else {
shared_t!("screening.no_mutual")
}
}),
),
),
)
.child(
@@ -287,36 +454,43 @@ impl Render for Screening {
.w_full()
.text_sm()
.child({
if self.dm_relays {
if self.dm_relays == Some(true) {
shared_t!("screening.relay_found")
} else {
shared_t!("screening.relay_empty")
}
})
.child(div().w_full().text_color(cx.theme().text_muted).child(
{
if self.dm_relays {
shared_t!("screening.relay_found_desc")
} else {
shared_t!("screening.relay_empty_desc")
}
},
)),
.child(
div()
.w_full()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.dm_relays == Some(true) {
shared_t!("screening.relay_found_desc")
} else {
shared_t!("screening.relay_empty_desc")
}
}),
),
),
),
)
}
}
fn status_badge(status: bool, cx: &App) -> Div {
div()
.pt_1()
.flex_shrink_0()
.child(Icon::new(IconName::CheckCircleFill).small().text_color({
if status {
cx.theme().icon_accent
} else {
cx.theme().icon_muted
}
}))
fn status_badge(status: Option<bool>, cx: &App) -> Div {
div().pt_1().flex_shrink_0().map(|this| {
if let Some(status) = status {
this.child(Icon::new(IconName::CheckCircleFill).small().text_color({
if status {
cx.theme().icon_accent
} else {
cx.theme().icon_muted
}
}))
} else {
this.child(Indicator::new().xsmall())
}
})
}

View File

@@ -2,7 +2,7 @@ use std::time::Duration;
use anyhow::{anyhow, Error};
use global::constants::NIP17_RELAYS;
use global::nostr_client;
use global::{css, nostr_client};
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
@@ -19,8 +19,6 @@ use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt};
use crate::chatspace::ChatSpace;
pub fn init(kind: Kind, window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
cx.new(|cx| SetupRelay::new(kind, window, cx))
}
@@ -220,7 +218,16 @@ impl SetupRelay {
}
// Fetch gift wrap events
ChatSpace::fetch_gift_wrap(&relays, public_key).await;
let sub_id = css().gift_wrap_sub_id.clone();
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
if client
.subscribe_with_id_to(relays.clone(), sub_id, filter, None)
.await
.is_ok()
{
log::info!("Subscribed to messages in: {relays:?}");
};
Ok(())
});

View File

@@ -119,6 +119,7 @@ pub struct CoopSimpleStorage {
pub init_at: Timestamp,
pub gift_wrap_sub_id: SubscriptionId,
pub gift_wrap_processing: AtomicBool,
pub auto_close_opts: Option<SubscribeAutoCloseOptions>,
pub sent_ids: RwLock<HashSet<EventId>>,
pub resent_ids: RwLock<Vec<Output<EventId>>>,
pub resend_queue: RwLock<HashMap<EventId, RelayUrl>>,
@@ -136,6 +137,9 @@ impl CoopSimpleStorage {
init_at: Timestamp::now(),
gift_wrap_sub_id: SubscriptionId::new("inbox"),
gift_wrap_processing: AtomicBool::new(false),
auto_close_opts: Some(
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE),
),
sent_ids: RwLock::new(HashSet::new()),
resent_ids: RwLock::new(Vec::new()),
resend_queue: RwLock::new(HashMap::new()),