diff --git a/src/db/person.rs b/src/db/person.rs index 9a6d7cfa..39f41581 100644 --- a/src/db/person.rs +++ b/src/db/person.rs @@ -164,6 +164,34 @@ impl DbPerson { Ok(()) } + pub async fn upsert_valid_nip05( + pubkey: PublicKeyHex, + dns_id: String, + dns_id_last_checked: u64, + ) -> Result<(), Error> { + let sql = "INSERT INTO person (pubkey, dns_id, dns_id_valid, dns_id_last_checked, followed) \ + values (?, ?, 1, ?, 1) \ + ON CONFLICT(pubkey) DO UPDATE SET dns_id=?, dns_id_valid=1, dns_id_last_checked=?, followed=1"; + + spawn_blocking(move || { + let maybe_db = GLOBALS.db.blocking_lock(); + let db = maybe_db.as_ref().unwrap(); + + let mut stmt = db.prepare(sql)?; + stmt.execute(( + &pubkey.0, + &dns_id, + &dns_id_last_checked, + &dns_id, + &dns_id_last_checked, + ))?; + Ok::<(), Error>(()) + }) + .await??; + + Ok(()) + } + #[allow(dead_code)] pub async fn delete(criteria: &str) -> Result<(), Error> { let sql = format!("DELETE FROM person WHERE {}", criteria); diff --git a/src/db/person_relay.rs b/src/db/person_relay.rs index 17d3ca50..c69b757c 100644 --- a/src/db/person_relay.rs +++ b/src/db/person_relay.rs @@ -213,6 +213,33 @@ impl DbPersonRelay { Ok(()) } + pub async fn upsert_last_suggested_nip35( + person: PublicKeyHex, + relay: String, + last_suggested_nip35: u64, + ) -> Result<(), Error> { + let sql = "INSERT INTO person_relay (person, relay, last_suggested_nip35) \ + VALUES (?, ?, ?) \ + ON CONFLICT(person, relay) DO UPDATE SET last_suggested_nip35=?"; + + spawn_blocking(move || { + let maybe_db = GLOBALS.db.blocking_lock(); + let db = maybe_db.as_ref().unwrap(); + + let mut stmt = db.prepare(sql)?; + stmt.execute(( + &person.0, + &relay, + &last_suggested_nip35, + &last_suggested_nip35, + ))?; + Ok::<(), Error>(()) + }) + .await??; + + Ok(()) + } + #[allow(dead_code)] pub async fn delete(criteria: &str) -> Result<(), Error> { let sql = format!("DELETE FROM person_relay WHERE {}", criteria); diff --git a/src/error.rs b/src/error.rs index 19b31e2b..e3de79dc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,12 @@ pub enum Error { #[error("Error sending mpsc: {0}")] MpscSend(#[from] tokio::sync::mpsc::error::SendError), + #[error("NIP-05 public key not found")] + Nip05NotFound, + + #[error("NIP-35 relays not found")] + Nip35NotFound, + #[error("Nostr: {0}")] Nostr(#[from] nostr_types::Error), @@ -30,12 +36,18 @@ pub enum Error { #[error("I/O Error: {0}")] Io(#[from] std::io::Error), + #[error("Invalid DNS ID (nip-05 / nip-35), should be user@domain")] + InvalidDnsId, + #[error("Invalid URI: {0}")] InvalidUri(#[from] http::uri::InvalidUri), #[error("Bad integer: {0}")] ParseInt(#[from] std::num::ParseIntError), + #[error("HTTP (reqwest) error: {0}")] + ReqwestHttpError(#[from] reqwest::Error), + #[error("SerdeJson Error: {0}")] SerdeJson(#[from] serde_json::Error), diff --git a/src/overlord/mod.rs b/src/overlord/mod.rs index 9992a5a5..cd3d58c0 100644 --- a/src/overlord/mod.rs +++ b/src/overlord/mod.rs @@ -7,7 +7,7 @@ use crate::error::Error; use crate::globals::{Globals, GLOBALS}; use crate::settings::Settings; use minion::Minion; -use nostr_types::{Event, PrivateKey, PublicKey, PublicKeyHex, Unixtime, Url}; +use nostr_types::{Event, Nip05, PrivateKey, PublicKey, PublicKeyHex, Unixtime, Url}; use relay_picker::{BestRelay, RelayPicker}; use std::collections::HashMap; use tokio::sync::broadcast::Sender; @@ -349,6 +349,14 @@ impl Overlord { "get_missing_events" => { self.get_missing_events().await?; } + "follow_nip35" => { + let dns_id: String = serde_json::from_str(&bus_message.json_payload)?; + let _ = tokio::spawn(async move { + if let Err(e) = Overlord::get_and_follow_nip35(dns_id).await { + error!("{}", e); + } + }); + } _ => {} }, _ => {} @@ -399,4 +407,61 @@ impl Overlord { Ok(()) } + + async fn get_and_follow_nip35(nip35: String) -> Result<(), Error> { + let mut parts: Vec<&str> = nip35.split('@').collect(); + if parts.len() != 2 { + return Err(Error::InvalidDnsId); + } + + let domain = parts.pop().unwrap(); + let user = parts.pop().unwrap(); + let nip05_future = reqwest::Client::new() + .get(format!( + "https://{}/.well-known/nostr.json?name={}", + domain, user + )) + .header("Host", domain) + .send(); + let timeout_future = tokio::time::timeout(std::time::Duration::new(15, 0), nip05_future); + let response = timeout_future.await??; + let nip05 = response.json::().await?; + Overlord::follow_nip35(nip05, user.to_string(), domain.to_string()).await?; + Ok(()) + } + + async fn follow_nip35(nip05: Nip05, user: String, domain: String) -> Result<(), Error> { + let dns_id = format!("{}@{}", user, domain); + + let pubkey = match nip05.names.get(&user) { + Some(pk) => pk, + None => return Err(Error::Nip05NotFound), + }; + + let relays = match nip05.relays.get(pubkey) { + Some(relays) => relays, + None => return Err(Error::Nip35NotFound), + }; + + // Save person + DbPerson::upsert_valid_nip05((*pubkey).into(), dns_id, Unixtime::now().unwrap().0 as u64) + .await?; + + for relay in relays.iter() { + // Save relay + let db_relay = DbRelay::new(relay.to_string())?; + DbRelay::insert(db_relay).await?; + + DbPersonRelay::upsert_last_suggested_nip35( + (*pubkey).into(), + relay.0.clone(), + Unixtime::now().unwrap().0 as u64, + ) + .await?; + } + + info!("Followed {}@{} at {} relays", user, domain, relays.len()); + + Ok(()) + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ae4e2f6f..a49ce43e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -47,7 +47,8 @@ pub fn run() -> Result<(), Error> { #[derive(PartialEq)] enum Page { Feed, - People, + PeopleFollow, + PeopleList, You, Relays, Settings, @@ -62,6 +63,7 @@ struct GossipUi { placeholder_avatar: TextureHandle, draft: String, settings: Settings, + nip35follow: String, } impl GossipUi { @@ -113,6 +115,7 @@ impl GossipUi { placeholder_avatar: placeholder_avatar_texture_handle, draft: "".to_owned(), settings, + nip35follow: "".to_owned(), } } } @@ -132,7 +135,7 @@ impl eframe::App for GossipUi { ui.horizontal(|ui| { ui.selectable_value(&mut self.page, Page::Feed, "Feed"); ui.separator(); - ui.selectable_value(&mut self.page, Page::People, "People"); + ui.selectable_value(&mut self.page, Page::PeopleList, "People"); ui.separator(); ui.selectable_value(&mut self.page, Page::You, "You"); ui.separator(); @@ -149,7 +152,8 @@ impl eframe::App for GossipUi { egui::CentralPanel::default().show(ctx, |ui| match self.page { Page::Feed => feed::update(self, ctx, frame, ui), - Page::People => people::update(self, ctx, frame, ui), + Page::PeopleList => people::update(self, ctx, frame, ui), + Page::PeopleFollow => people::update(self, ctx, frame, ui), Page::You => you::update(self, ctx, frame, ui), Page::Relays => relays::update(self, ctx, frame, ui), Page::Settings => settings::update(self, ctx, frame, ui, darkmode), diff --git a/src/ui/people.rs b/src/ui/people.rs index 29495dee..4b617fbd 100644 --- a/src/ui/people.rs +++ b/src/ui/people.rs @@ -1,52 +1,88 @@ -use super::GossipUi; +use super::{GossipUi, Page}; +use crate::comms::BusMessage; use crate::globals::GLOBALS; use eframe::egui; -use egui::{Context, RichText, ScrollArea, TextStyle, Ui, Vec2}; +use egui::{Context, RichText, ScrollArea, TextStyle, TopBottomPanel, Ui, Vec2}; -pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) { - ui.add_space(8.0); - ui.heading("People Followed"); - ui.add_space(18.0); +pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) { + TopBottomPanel::top("people_menu").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.selectable_value(&mut app.page, Page::PeopleList, "Followed"); + ui.separator(); + ui.selectable_value(&mut app.page, Page::PeopleFollow, "Follow Someone New"); + ui.separator(); + }); + }); - let people = GLOBALS.people.blocking_lock().clone(); + if app.page == Page::PeopleFollow { + ui.add_space(24.0); - ScrollArea::vertical().show(ui, |ui| { - for (_, person) in people.iter() { - if person.followed != 1 { - continue; + ui.add_space(8.0); + ui.heading("Follow someone"); + ui.add_space(18.0); + + ui.separator(); + + ui.horizontal(|ui| { + ui.label("Enter user@domain"); + ui.text_edit_singleline(&mut app.nip35follow); + if ui.button("follow").clicked() { + let tx = GLOBALS.to_overlord.clone(); + let _ = tx.send(BusMessage { + target: "overlord".to_string(), + kind: "follow_nip35".to_string(), + json_payload: serde_json::to_string(&app.nip35follow).unwrap(), + }); + app.nip35follow = "".to_owned(); } + }); + } else if app.page == Page::PeopleList { + ui.add_space(24.0); - ui.horizontal(|ui| { - // Avatar first - ui.image(&app.placeholder_avatar, Vec2 { x: 36.0, y: 36.0 }); + ui.add_space(8.0); + ui.heading("People Followed"); + ui.add_space(18.0); - ui.vertical(|ui| { - ui.label(RichText::new(GossipUi::hex_pubkey_short(&person.pubkey)).weak()); + let people = GLOBALS.people.blocking_lock().clone(); - ui.horizontal(|ui| { - ui.label( - RichText::new(person.name.as_deref().unwrap_or("")) - .text_style(TextStyle::Name("Bold".into())), - ); + ScrollArea::vertical().show(ui, |ui| { + for (_, person) in people.iter() { + if person.followed != 1 { + continue; + } - ui.add_space(24.0); + ui.horizontal(|ui| { + // Avatar first + ui.image(&app.placeholder_avatar, Vec2 { x: 36.0, y: 36.0 }); - if let Some(dns_id) = person.dns_id.as_deref() { - ui.label(dns_id); - } + ui.vertical(|ui| { + ui.label(RichText::new(GossipUi::hex_pubkey_short(&person.pubkey)).weak()); + + ui.horizontal(|ui| { + ui.label( + RichText::new(person.name.as_deref().unwrap_or("")) + .text_style(TextStyle::Name("Bold".into())), + ); + + ui.add_space(24.0); + + if let Some(dns_id) = person.dns_id.as_deref() { + ui.label(dns_id); + } + }); }); }); - }); - ui.add_space(12.0); + ui.add_space(12.0); - if let Some(about) = person.about.as_deref() { - ui.label(about); + if let Some(about) = person.about.as_deref() { + ui.label(about); + } + + ui.add_space(12.0); + + ui.separator(); } - - ui.add_space(12.0); - - ui.separator(); - } - }); + }); + } }