diff --git a/Cargo.lock b/Cargo.lock index a1b911528..efd2460fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3440,7 +3440,7 @@ dependencies = [ [[package]] name = "nostr-types" version = "0.8.0-unstable" -source = "git+https://github.com/mikedilger/nostr-types?rev=3613b32dcb501548525d1f01da04a131b6e18373#3613b32dcb501548525d1f01da04a131b6e18373" +source = "git+https://github.com/mikedilger/nostr-types?rev=a91f43081dd700fe2b47aa1ddfb533c9364dc553#a91f43081dd700fe2b47aa1ddfb533c9364dc553" dependencies = [ "aes", "base64 0.22.1", diff --git a/flake.lock b/flake.lock index 50ef22329..d4ced86db 100644 --- a/flake.lock +++ b/flake.lock @@ -77,11 +77,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1715322226, - "narHash": "sha256-ezoe/FwfJpA7sskLoLP2iwfwkYnscEFCP6Vk5kPwh9k=", + "lastModified": 1728973961, + "narHash": "sha256-Jkqaw9O7WXTf5SHrK7xr9HpVU/mEPVg0Sp6s3AENC90=", "owner": "nix-community", "repo": "fenix", - "rev": "297c756ba6249d483c1dafe42378560458842173", + "rev": "d6a9ff4d1e60c347a23bc96ccdb058d37a810541", "type": "github" }, "original": { @@ -95,11 +95,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", "owner": "numtide", "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", "type": "github" }, "original": { @@ -177,11 +177,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1715218190, - "narHash": "sha256-R98WOBHkk8wIi103JUVQF3ei3oui4HvoZcz9tYOAwlk=", + "lastModified": 1720535198, + "narHash": "sha256-zwVvxrdIzralnSbcpghA92tWu2DV2lwv89xZc8MTrbg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9a9960b98418f8c385f52de3b09a63f9c561427a", + "rev": "205fd4226592cc83fd4c0885a3e4c9c400efabb5", "type": "github" }, "original": { @@ -202,11 +202,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1715255944, - "narHash": "sha256-vLLgYpdtKBaGYTamNLg1rbRo1bPXp4Jgded/gnprPVw=", + "lastModified": 1728921748, + "narHash": "sha256-BOCZ5osPOMh2BPHnkK4sVdTGj7sn47rBn1nxjrzWe5U=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "5bf2f85c8054d80424899fa581db1b192230efb5", + "rev": "0319586ef2a2636f6d6b891690b7ebebf4337c85", "type": "github" }, "original": { diff --git a/gossip-bin/Cargo.toml b/gossip-bin/Cargo.toml index 4e6173e21..81f0ce309 100644 --- a/gossip-bin/Cargo.toml +++ b/gossip-bin/Cargo.toml @@ -30,7 +30,7 @@ humansize = "2.1" image = { version = "0.25", features = [ "png", "jpeg" ] } lazy_static = "1.5" memoize = "0.4" -nostr-types = { git = "https://github.com/mikedilger/nostr-types", rev = "3613b32dcb501548525d1f01da04a131b6e18373", features = [ "speedy" ] } +nostr-types = { git = "https://github.com/mikedilger/nostr-types", rev = "a91f43081dd700fe2b47aa1ddfb533c9364dc553", features = [ "speedy" ] } paste = "1.0" qrcode = "0.14" resvg = "0.35.0" diff --git a/gossip-bin/src/commands.rs b/gossip-bin/src/commands.rs index db4e2e515..74111f193 100644 --- a/gossip-bin/src/commands.rs +++ b/gossip-bin/src/commands.rs @@ -24,7 +24,7 @@ impl Command { } } -const COMMANDS: [Command; 44] = [ +const COMMANDS: [Command; 45] = [ Command { cmd: "oneshot", usage_params: "{depends}", @@ -85,6 +85,11 @@ const COMMANDS: [Command; 44] = [ usage_params: "", desc: "Set a relay rank to 0 so it will never connect, and also hide form thie list. This is better than delete because deleted relays quickly come back with default settings." }, + Command { + cmd: "dump_handlers", + usage_params: "", + desc: "print all the web-based event handlers", + }, Command { cmd: "events_of_kind", usage_params: "", @@ -275,6 +280,7 @@ pub fn handle_command(mut args: env::Args) -> Result { "delete_relay" => delete_relay(command, args)?, "dpi" => override_dpi(command, args)?, "disable_relay" => disable_relay(command, args)?, + "dump_handlers" => dump_handlers()?, "events_of_kind" => events_of_kind(command, args)?, "events_of_pubkey" => events_of_pubkey(command, args)?, "events_of_pubkey_and_kind" => events_of_pubkey_and_kind(command, args)?, @@ -741,6 +747,47 @@ pub fn disable_relay(cmd: Command, mut args: env::Args) -> Result<(), Error> { Ok(()) } +pub fn dump_handlers() -> Result<(), Error> { + use gossip_lib::HandlersTable; + + let mut last_kind = EventKind::Other(12345); + + for (kind, handler_key, enabled, recommended) in + GLOBALS.db().read_all_configured_handlers()?.iter() + { + if *kind != last_kind { + println!("KIND={:?}", *kind); + last_kind = *kind; + } + + if let Some(handler) = HandlersTable::read_record(handler_key.clone(), None)? { + let handler_url = if kind.is_parameterized_replaceable() { + if let Some(naddr_url) = &handler.naddr_url { + naddr_url.as_str() + } else { + "no web-naddr url provided" + } + } else { + if let Some(nevent_url) = &handler.nevent_url { + nevent_url.as_str() + } else { + "no web-nevent url provided" + } + }; + + println!( + " {:?} enabled={} recommended={} url={}", + handler.bestname(*kind), + *enabled, + *recommended, + handler_url + ); + } + } + + Ok(()) +} + pub fn import_encrypted_private_key(cmd: Command, mut args: env::Args) -> Result<(), Error> { let input = match args.next() { Some(input) => input, diff --git a/gossip-bin/src/notedata.rs b/gossip-bin/src/notedata.rs index 22b5258cc..9b82bf2bc 100644 --- a/gossip-bin/src/notedata.rs +++ b/gossip-bin/src/notedata.rs @@ -191,7 +191,6 @@ impl NoteData { EventKind::GiftWrap => ("".to_owned(), Some("DECRYPTION FAILED".to_owned())), EventKind::ChannelMessage => (event.content.clone(), None), EventKind::LiveChatMessage => (event.content.clone(), None), - EventKind::CommunityPost => (event.content.clone(), None), EventKind::DraftLongFormContent => (event.content.clone(), None), k => { if k.is_feed_displayable() { diff --git a/gossip-bin/src/ui/feed/note/mod.rs b/gossip-bin/src/ui/feed/note/mod.rs index 9769848f6..6ec69d67d 100644 --- a/gossip-bin/src/ui/feed/note/mod.rs +++ b/gossip-bin/src/ui/feed/note/mod.rs @@ -1463,10 +1463,13 @@ fn note_actions( ))); } // end Bookmark - // ---- Share ---- + // ---- Open with ---- if !note.event.kind.is_direct_message_related() { - items.push(MoreMenuItem::Button(MoreMenuButton::new( - "Share via web", + let mut my_items: Vec = Vec::new(); + + // njump.me + my_items.push(MoreMenuItem::Button(MoreMenuButton::new( + "njump.me", Box::new(|ui, _| { let nevent = NEvent { id: note.event.id, @@ -1474,12 +1477,53 @@ fn note_actions( author: None, kind: None, }; - ui.output_mut(|o| { - o.copied_text = format!("https://njump.me/{}", nevent.as_bech32_string()) + let url = format!("https://njump.me/{}", nevent.as_bech32_string()); + ui.ctx().open_url(egui::OpenUrl { + url: url.clone(), + new_tab: true, }); }), ))); - } // end Share + + if let Some(handlers) = GLOBALS.handlers.get(¬e.event.kind) { + for (label, url) in handlers.value().iter() { + let url = if note.event.kind.is_parameterized_replaceable() { + let param = match note.event.parameter() { + Some(p) => p, + None => "".to_owned(), + }; + let naddr = NAddr { + d: param, + relays: relays.clone(), + kind: note.event.kind, + author: note.event.pubkey, + }; + url.as_str().replace("", &naddr.as_bech32_string()) + } else { + let nevent = NEvent { + id: note.event.id, + relays: relays.clone(), + author: Some(note.event.pubkey), + kind: Some(note.event.kind), + }; + url.as_str().replace("", &nevent.as_bech32_string()) + }; + + my_items.push(MoreMenuItem::Button(MoreMenuButton::new( + label, + Box::new(move |ui, _app| { + ui.ctx().open_url(egui::OpenUrl { url, new_tab: true }); + }), + ))); + } + } + + items.push(MoreMenuItem::SubMenu(MoreMenuSubMenu::new( + "Open with", + my_items, + &menu, + ))) + } // Open with SubMenu // ---- Copy ID SubMenu ---- { diff --git a/gossip-bin/src/ui/handler.rs b/gossip-bin/src/ui/handler.rs new file mode 100644 index 000000000..4d94d0cc4 --- /dev/null +++ b/gossip-bin/src/ui/handler.rs @@ -0,0 +1,362 @@ +use core::f32; + +use super::{widgets, GossipUi}; +use eframe::egui::{self, vec2, RichText}; +use egui::{Context, Ui}; +use gossip_lib::comms::ToOverlordMessage; +use gossip_lib::{Handler, HandlerKey, HandlersTable, Table, GLOBALS}; +use nostr_types::{EventKind, NAddr, PublicKey}; + +pub struct Handlers { + /// Handler that is open for detailed view, if any + detail: Option, + /// Is the add-handler dialog open? + add_dialog: bool, + /// Entry text for add-dialog + add_naddr: String, + /// Any errors while entering naddr + add_err: Option, +} + +impl Default for Handlers { + fn default() -> Self { + Self { + detail: None, + add_dialog: false, + add_naddr: "".to_owned(), + add_err: None, + } + } +} + +fn add_dialog(ui: &mut Ui, app: &mut GossipUi) { + const DLG_SIZE: egui::Vec2 = vec2(400.0, 260.0); + let dlg_response = widgets::modal_popup(ui.ctx(), vec2(400.0, 0.0), DLG_SIZE, true, |ui| { + ui.heading("Import a handler via nevent"); + ui.add_space(8.0); + + ui.label("To add a new handler, paste its corresponding naddr here:"); + ui.add_space(12.0); + + let response = widgets::TextEdit::singleline(&app.theme, &mut app.handlers.add_naddr) + .desired_width(f32::INFINITY) + .hint_text("naddr1...") + .with_paste() + .show(ui); + let mut go: bool = false; + + ui.add_space(12.0); + + ui.horizontal(|ui| { + if let Some(err) = &app.handlers.add_err { + ui.label(RichText::new(err).color(app.theme.warning_marker_text_color())); + } else { + ui.label(""); + } + + ui.with_layout(egui::Layout::right_to_left(Default::default()), |ui| { + if widgets::Button::primary(&app.theme, "Import") + .show(ui) + .clicked() + { + go = true; + } + }); + }); + if response.response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + go = true; + } + if go { + if app.handlers.add_naddr.starts_with("nostr:") { + app.handlers.add_naddr = app.handlers.add_naddr[6..].to_owned(); + } + + match NAddr::try_from_bech32_string(&app.handlers.add_naddr) { + Ok(naddr) => { + let _ = GLOBALS + .to_overlord + .send(ToOverlordMessage::FetchNAddr(naddr)); + app.handlers.add_naddr = "".to_string(); + app.handlers.add_dialog = false; + app.handlers.add_err = None; + } + Err(_) => { + app.handlers.add_err = Some("Invalid naddr".to_owned()); + } + } + } + }); + + if dlg_response.inner.clicked() { + app.handlers.add_dialog = false; + app.handlers.add_err = None; + } +} + +pub(super) fn update_all_kinds(app: &mut GossipUi, ctx: &Context, ui: &mut Ui) { + // If we end up in this overview, we clear any detail view + app.handlers.detail.take(); + + if app.handlers.add_dialog { + add_dialog(ui, app); + } + + // render page + widgets::page_header(ui, "External Event Handlers", |ui| { + if widgets::Button::primary(&app.theme, "Import Handler") + .show(ui) + .clicked() + { + app.handlers.add_dialog = true; + } + }); + + app.vert_scroll_area().show(ui, |ui| { + let data = GLOBALS + .db() + .read_all_configured_handlers() + .unwrap_or(vec![]); + let mut kinds: Vec = data.iter().map(|(k, _, _, _)| *k).collect(); + kinds.dedup(); + + for kind in kinds.iter() { + let response = widgets::list_entry::clickable_frame( + ui, + app, + Some(app.theme.main_content_bgcolor()), + |ui, app| { + ui.set_min_width(ui.available_width()); + + let kind_name = format!("Kind {}", u32::from(*kind)); + + let handlers: Vec<(HandlerKey, bool, bool)> = GLOBALS + .db() + .read_configured_handlers(*kind) + .unwrap_or(vec![]); + + let all_count = handlers.len(); + let enabled_count = handlers.iter().filter_map(|f| f.1.then(|| {})).count(); + let recommended_count = handlers.iter().filter_map(|f| f.2.then(|| {})).count(); + + ui.horizontal(|ui| { + let kwidth = ui.label(egui::RichText::new(&kind_name)).rect.width(); + ui.add_space(200.0 - kwidth); + ui.label(egui::RichText::new(format!("{}", kind))); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::default()), |ui| { + if all_count == 0 { + ui.label(egui::RichText::new("No recommendations").weak()); + } else { + if recommended_count != all_count { + ui.label( + egui::RichText::new(format!( + "{} of {} apps", + enabled_count, all_count + )) + .color(app.theme.accent_color()), + ); + } else { + ui.label( + egui::RichText::new(format!("{} apps", all_count)) + .color(app.theme.accent_color()), + ); + } + } + }); + }) + }, + ); + + if response + .response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .interact(egui::Sense::click()) + .clicked() + { + app.set_page(ctx, super::Page::Handlers(*kind)); + } + } + }); +} + +pub(super) fn update_kind(app: &mut GossipUi, _ctx: &Context, ui: &mut Ui, kind: EventKind) { + widgets::page_header( + ui, + format!("Handler: {} ({})", kind, u32::from(kind)), + |ui| { + if widgets::Button::secondary(&app.theme, "Share recommendations") + .show(ui) + .clicked() + { + let _ = GLOBALS + .to_overlord + .send(ToOverlordMessage::ShareHandlerRecommendations(kind)); + } + }, + ); + + let handlers: Vec<(HandlerKey, bool, bool)> = GLOBALS + .db() + .read_configured_handlers(kind) + .unwrap_or(vec![]); + + app.vert_scroll_area().show(ui, |ui| { + for (key, mut enabled, mut recommended) in handlers.iter() { + if let Ok(Some(handler)) = HandlersTable::read_record(key.clone(), None) { + if !kind.is_parameterized_replaceable() && handler.nevent_url.is_none() { + continue; + } + if kind.is_parameterized_replaceable() && handler.naddr_url.is_none() { + continue; + } + + let name = match handler.bestname(kind) { + Some(n) => n, + None => continue, + }; + + let response = widgets::list_entry::clickable_frame( + ui, + app, + Some(app.theme.main_content_bgcolor()), + |ui, app| { + ui.set_min_width(ui.available_width()); + + ui.push_id(&name, |ui| { + handler_header( + ui, + app, + &handler, + &name, + kind, + &mut enabled, + &mut recommended, + ); + + if app.handlers.detail == Some(handler.key.pubkey) { + handler_detail(ui, app, &handler, kind); + } + + ui.interact_bg(egui::Sense::click()) + }) + .inner + }, + ); + + if response + .inner + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + if app.handlers.detail == Some(handler.key.pubkey) { + app.handlers.detail.take(); + } else { + app.handlers.detail = Some(handler.key.pubkey); + } + } + } + } + }); +} + +fn handler_header( + ui: &mut Ui, + app: &mut GossipUi, + handler: &Handler, + name: &String, + kind: EventKind, + enabled: &mut bool, + recommended: &mut bool, +) { + ui.horizontal(|ui| { + if widgets::Switch::small(&app.theme, enabled) + .show(ui) + .changed() + { + let _ = GLOBALS.db().write_configured_handler( + kind, + handler.key.clone(), + *enabled, + *recommended, + None, + ); + } + + ui.add_space(10.0); + let lresp = ui.link(name).on_hover_text("go to profile"); + if lresp.clicked() { + app.set_page(ui.ctx(), super::Page::Person(handler.key.pubkey)); + } + let lwidth = lresp.rect.width(); + + ui.add_space(200.0 - lwidth); + if let Some(metadata) = handler.metadata() { + if let Some(value) = metadata.other.get("website") { + match value { + serde_json::Value::String(url) => { + if ui + .link(url.to_string()) + .on_hover_text("open website in browser") + .clicked() + { + ui.output_mut(|o| { + o.open_url = Some(egui::OpenUrl { + url: url.to_string(), + new_tab: true, + }); + }); + } + } + _ => {} + } + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::default()), |ui| { + if widgets::Switch::small(&app.theme, recommended) + .with_label("recommend") + .show(ui) + .changed() + { + let _ = GLOBALS.db().write_configured_handler( + kind, + handler.key.clone(), + *enabled, + *recommended, + None, + ); + } + }); + }); +} + +fn handler_detail(ui: &mut Ui, app: &mut GossipUi, handler: &Handler, kind: EventKind) { + if let Some(metadata) = handler.metadata() { + ui.horizontal_wrapped(|ui| { + ui.label(format!( + "About: {}", + metadata.about.as_deref().unwrap_or("".into()) + )); + }); + } + + let recommended_by: Vec = GLOBALS + .db() + .who_recommended_handler(&handler.key, kind) + .unwrap_or(vec![]); + ui.horizontal(|ui| { + let count = recommended_by.len(); + if count > 0 { + ui.label("Recommended by: "); + for (i, pubkey) in recommended_by.iter().enumerate() { + let name = gossip_lib::names::best_name_from_pubkey_lookup(pubkey); + if ui.link(name).clicked() { + app.set_page(ui.ctx(), super::Page::Person(pubkey.to_owned())); + } + if (i + 1) < count { + ui.label("|"); + } + } + } + }); +} diff --git a/gossip-bin/src/ui/help/stats.rs b/gossip-bin/src/ui/help/stats.rs index 45869aecb..01267baee 100644 --- a/gossip-bin/src/ui/help/stats.rs +++ b/gossip-bin/src/ui/help/stats.rs @@ -1,7 +1,7 @@ use super::GossipUi; use eframe::egui; use egui::{Context, Ui}; -use gossip_lib::{FollowingsTable, PersonTable, Table, GLOBALS}; +use gossip_lib::{FollowingsTable, HandlersTable, PersonTable, Table, GLOBALS}; use humansize::{format_size, DECIMAL}; use std::sync::atomic::Ordering; @@ -153,5 +153,17 @@ pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Fr GLOBALS.db().get_fof_len().unwrap_or(0) )); ui.add_space(6.0); + + ui.label(format!( + "Handlers: {} records", + HandlersTable::num_records().unwrap_or(0) + )); + ui.add_space(6.0); + + ui.label(format!( + "Configured Handlers: {} records", + GLOBALS.db().get_configured_handlers_len().unwrap_or(0) + )); + ui.add_space(6.0); }); } diff --git a/gossip-bin/src/ui/mod.rs b/gossip-bin/src/ui/mod.rs index 3b5468c10..3e1002e5b 100644 --- a/gossip-bin/src/ui/mod.rs +++ b/gossip-bin/src/ui/mod.rs @@ -37,6 +37,7 @@ mod assets; mod dm_chat_list; mod emojis; mod feed; +mod handler; mod help; mod notifications; mod people; @@ -70,9 +71,10 @@ use gossip_lib::{ DmChannel, DmChannelData, Error, FeedKind, Person, PersonList, Private, RunState, ZapState, GLOBALS, }; +use handler::Handlers; use nostr_types::ContentSegment; use nostr_types::RelayUrl; -use nostr_types::{Id, Metadata, MilliSatoshi, Profile, PublicKey, UncheckedUrl, Url}; +use nostr_types::{EventKind, Id, Metadata, MilliSatoshi, Profile, PublicKey, UncheckedUrl, Url}; use widgets::ModalEntry; use std::collections::{HashMap, HashSet}; @@ -156,6 +158,8 @@ pub fn run() -> Result<(), Error> { enum Page { DmChatList, Feed(FeedKind), + HandlerKinds, + Handlers(EventKind), Notifications, PeopleLists, PeopleList(PersonList), @@ -194,6 +198,8 @@ impl Page { match self { Page::DmChatList => (SubMenu::Feeds.as_str(), "Private chats".into()), Page::Feed(feedkind) => ("Feed", feedkind.to_string()), + Page::HandlerKinds => ("Event Handlers", "Event Handlers".into()), + Page::Handlers(kind) => ("Event Handler", format!("{:?}", kind)), Page::Notifications => ("Notifications", "Notifications".into()), Page::PeopleLists => ("Lists", "Lists".into()), Page::PeopleList(list) => { @@ -439,6 +445,9 @@ struct GossipUi { // people::ListUi people_list: people::ListUi, + // Handlers Ui + handlers: Handlers, + // Post rendering render_raw: Option<(Id, String)>, render_qr: Option, @@ -703,6 +712,7 @@ impl GossipUi { notification_data: NotificationData::new(), relays: relays::RelayUi::new(), people_list: people::ListUi::new(), + handlers: Default::default(), render_raw: None, render_qr: None, approved: HashSet::new(), @@ -965,6 +975,7 @@ impl GossipUi { self.add_people_lists(ui, ctx); self.add_relays_submenu(ui, ctx); self.add_account_submenu(ui, ctx); + self.add_handlers(ui, ctx); self.add_settings(ui, ctx); self.add_help_submenu(ui, ctx); @@ -1163,6 +1174,19 @@ impl GossipUi { self.after_openable_menu(ui, &cstate); } + fn add_handlers(&mut self, ui: &mut Ui, ctx: &Context) { + if self + .add_selected_label( + ui, + self.page == Page::HandlerKinds || matches!(self.page, Page::Handlers(_)), + "Handlers", + ) + .clicked() + { + self.set_page(ctx, Page::HandlerKinds); + } + } + fn add_settings(&mut self, ui: &mut Ui, ctx: &Context) { if self .add_selected_label(ui, self.page == Page::Settings, "Settings") @@ -2239,6 +2263,8 @@ impl eframe::App for GossipUi { match self.page { Page::DmChatList => dm_chat_list::update(self, ctx, frame, ui), Page::Feed(_) => feed::update(self, ctx, ui), + Page::HandlerKinds => handler::update_all_kinds(self, ctx, ui), + Page::Handlers(kind) => handler::update_kind(self, ctx, ui, kind), Page::Notifications => notifications::update(self, ui), Page::PeopleLists | Page::PeopleList(_) | Page::Person(_) => { people::update(self, ctx, frame, ui) diff --git a/gossip-lib/Cargo.toml b/gossip-lib/Cargo.toml index b75b65f5d..e18530c24 100644 --- a/gossip-lib/Cargo.toml +++ b/gossip-lib/Cargo.toml @@ -55,7 +55,7 @@ kamadak-exif = "0.5" lazy_static = "1.5" linkify = "0.10" mime = "0.3" -nostr-types = { git = "https://github.com/mikedilger/nostr-types", rev = "3613b32dcb501548525d1f01da04a131b6e18373", features = [ "speedy" ] } +nostr-types = { git = "https://github.com/mikedilger/nostr-types", rev = "a91f43081dd700fe2b47aa1ddfb533c9364dc553", features = [ "speedy" ] } parking_lot = { version = "0.12", features = [ "arc_lock", "send_guard" ] } paste = "1.0" rand = "0.8" diff --git a/gossip-lib/src/comms.rs b/gossip-lib/src/comms.rs index 25d27717b..e1bbe7302 100644 --- a/gossip-lib/src/comms.rs +++ b/gossip-lib/src/comms.rs @@ -5,8 +5,8 @@ use crate::nostr_connect_server::{Approval, ParsedCommand}; use crate::people::PersonList; use crate::relay::Relay; use nostr_types::{ - Event, EventReference, Id, Metadata, MilliSatoshi, NAddr, Profile, PublicKey, RelayUrl, Tag, - UncheckedUrl, Unixtime, + Event, EventKind, EventReference, Id, Metadata, MilliSatoshi, NAddr, Profile, PublicKey, + RelayUrl, Tag, UncheckedUrl, Unixtime, }; use std::fmt; use std::hash::{Hash, Hasher}; @@ -191,6 +191,9 @@ pub enum ToOverlordMessage { author: Option, }, + /// Calls [share_handler_recommendations](crate::Overlord::share_handler_recommendations) + ShareHandlerRecommendations(EventKind), + /// Calls [start_long_lived_subscriptions](crate::Overlord::start_long_lived_subscriptions) StartLongLivedSubscriptions, diff --git a/gossip-lib/src/fetcher.rs b/gossip-lib/src/fetcher.rs index 702afbd09..1eb165ebd 100644 --- a/gossip-lib/src/fetcher.rs +++ b/gossip-lib/src/fetcher.rs @@ -415,7 +415,7 @@ impl Fetcher { FailOutcome::Requeue, "timeout", Some(e.into()), - low_exclusion, + med_exclusion, ); } else if e.is_request() { finish(FailOutcome::Fail, "request error", Some(e.into()), 0); @@ -439,7 +439,7 @@ impl Fetcher { FailOutcome::Requeue, "informational error", None, - low_exclusion, + med_exclusion, ); return; } else if status.is_redirection() { diff --git a/gossip-lib/src/filter_set.rs b/gossip-lib/src/filter_set.rs index d0c1a23a9..9fe8527fc 100644 --- a/gossip-lib/src/filter_set.rs +++ b/gossip-lib/src/filter_set.rs @@ -369,6 +369,7 @@ impl FilterSet { EventKind::Metadata, EventKind::RelayList, EventKind::DmRelayList, + EventKind::HandlerRecommendation, ], // FIXME: we could probably get a since-last-fetched-their-metadata here. // but relays should just return the latest of these. diff --git a/gossip-lib/src/globals.rs b/gossip-lib/src/globals.rs index 017f72b90..a3f6e61fc 100644 --- a/gossip-lib/src/globals.rs +++ b/gossip-lib/src/globals.rs @@ -15,10 +15,10 @@ use crate::relay_picker::RelayPicker; use crate::relay_test_results::RelayTestResults; use crate::seeker::Seeker; use crate::status::StatusQueue; -use crate::storage::Storage; +use crate::storage::{HandlersTable, Storage, Table}; use crate::RunState; use dashmap::DashMap; -use nostr_types::{Event, Id, Profile, PublicKey, RelayUrl}; +use nostr_types::{Event, EventKind, Id, Profile, PublicKey, RelayUrl, UncheckedUrl}; use parking_lot::RwLock as PRwLock; use regex::Regex; use rhai::{Engine, AST}; @@ -184,6 +184,9 @@ pub struct Globals { /// Relay tests pub relay_tests: DashMap>, + + /// Handlers + pub handlers: DashMap>, } lazy_static! { @@ -257,6 +260,7 @@ lazy_static! { recompute_current_bookmarks: Arc::new(Notify::new()), prune_status: PRwLock::new(None), relay_tests: DashMap::new(), + handlers: DashMap::new(), } }; } @@ -297,4 +301,40 @@ impl Globals { Some(profile) } + + pub fn update_handlers(&self) -> Result<(), Error> { + self.handlers.clear(); + + for (kind, handler_key, enabled, _recommended) in + self.db().read_all_configured_handlers()?.iter() + { + if !enabled { + continue; + } + + if let Some(handler) = HandlersTable::read_record(handler_key.clone(), None)? { + let url = match ( + kind.is_parameterized_replaceable(), + &handler.nevent_url, + &handler.naddr_url, + ) { + (true, _, Some(u)) => u.clone(), + (true, _, None) => continue, + (false, Some(u), _) => u.clone(), + (false, None, _) => continue, + }; + let name = match handler.bestname(*kind) { + Some(n) => n, + None => continue, + }; + let data = (name, url); + self.handlers + .entry(*kind) + .and_modify(|e| e.push(data.clone())) + .or_insert(vec![data.clone()]); + } + } + + Ok(()) + } } diff --git a/gossip-lib/src/lib.rs b/gossip-lib/src/lib.rs index afece2e5b..4cc243d8c 100644 --- a/gossip-lib/src/lib.rs +++ b/gossip-lib/src/lib.rs @@ -161,7 +161,7 @@ pub use status::StatusQueue; mod storage; pub use storage::types::*; -pub use storage::{FollowingsTable, PersonTable, Storage, Table}; +pub use storage::{FollowingsTable, HandlersTable, PersonTable, Storage, Table}; mod tasks; diff --git a/gossip-lib/src/overlord.rs b/gossip-lib/src/overlord.rs index fb6080c23..02af269c7 100644 --- a/gossip-lib/src/overlord.rs +++ b/gossip-lib/src/overlord.rs @@ -17,6 +17,7 @@ use crate::relay; use crate::relay::Relay; use crate::relay_picker::RelayAssignment; use crate::relay_test_results::{RelayTestResult, RelayTestResults}; +use crate::storage::types::HandlerKey; use crate::storage::{PersonTable, Table}; use crate::RunState; use heed::RwTxn; @@ -762,6 +763,9 @@ impl Overlord { } => { self.set_thread_feed(id, referenced_by, author)?; } + ToOverlordMessage::ShareHandlerRecommendations(kind) => { + self.share_handler_recommendations(kind).await?; + } ToOverlordMessage::StartLongLivedSubscriptions => { self.start_long_lived_subscriptions().await?; } @@ -2634,6 +2638,97 @@ impl Overlord { Ok(()) } + pub async fn share_handler_recommendations(&mut self, kind: EventKind) -> Result<(), Error> { + let public_key = match GLOBALS.identity.public_key() { + Some(pk) => pk, + None => { + tracing::warn!("No public key! Not posting"); + return Ok(()); + } + }; + + // Build the recommended handlers tags + let mut a_tags: Vec = vec![]; + let handlers: Vec<(HandlerKey, bool, bool)> = GLOBALS + .db() + .read_configured_handlers(kind) + .unwrap_or(vec![]); + for (handler_key, _enabled, recommended) in handlers { + if !recommended { + continue; + } + + // Find the 31990 event, and then find out which relay we saw it on + let url = { + let handler_event = { + let mut filter = Filter::new(); + filter.add_event_kind(EventKind::HandlerInformation); + filter.add_author(handler_key.pubkey); + filter.add_tag_value('d', handler_key.d.clone()); + let handler_events = GLOBALS.db().find_events_by_filter(&filter, |_| true)?; + if handler_events.is_empty() { + tracing::warn!("Handler event not found locally"); + return Ok(()); + } + handler_events[0].clone() + }; + + let mut seen_on = GLOBALS.db().get_event_seen_on_relay(handler_event.id)?; + if seen_on.is_empty() { + tracing::warn!("Cannot determine a relay where the handler was seen."); + return Ok(()); + } + + // Get the most recent seen_on + seen_on.sort_by(|a, b| a.1.cmp(&b.1)); + seen_on.pop().unwrap().0 + }; + + let naddr = NAddr { + d: handler_key.d, + relays: vec![url.to_unchecked_url()], + kind: EventKind::HandlerInformation, + author: handler_key.pubkey, + }; + + a_tags.push(Tag::new_address(&naddr, Some("web".to_owned()))); + } + + // Build the recommendation event + let event = { + let mut tags = vec![Tag::new_identifier(format!("{}", u32::from(kind)))]; + tags.extend(a_tags); + + let pre_event = PreEvent { + pubkey: public_key, + created_at: Unixtime::now(), + kind: EventKind::HandlerRecommendation, + tags, + content: "".to_string(), + }; + + GLOBALS.identity.sign_event(pre_event)? + }; + + // Process this event locally + crate::process::process_new_event(&event, None, None, false, false)?; + + // Post the event to our outboxes + let write_relays = relay::relays_to_post_to(&event)?; + manager::run_jobs_on_all_relays( + write_relays, + vec![RelayJob { + reason: RelayConnectionReason::PostEvent, + payload: ToMinionPayload { + job_id: rand::random::(), + detail: ToMinionPayloadDetail::PostEvents(vec![event.clone()]), + }, + }], + ); + + Ok(()) + } + /// This is done at startup and after the wizard. pub async fn start_long_lived_subscriptions(&mut self) -> Result<(), Error> { // Initialize the RelayPicker diff --git a/gossip-lib/src/process.rs b/gossip-lib/src/process.rs index b6076b64b..17eb2d7f0 100644 --- a/gossip-lib/src/process.rs +++ b/gossip-lib/src/process.rs @@ -5,7 +5,8 @@ use crate::globals::GLOBALS; use crate::misc::{Freshness, Private}; use crate::people::{People, PersonList, PersonListMetadata}; use crate::relationship::{RelationshipByAddr, RelationshipById}; -use crate::storage::{PersonTable, Table}; +use crate::storage::types::{Handler, HandlerKey}; +use crate::storage::{HandlersTable, PersonTable, Table}; use crate::Relay; use heed::RwTxn; use nostr_types::{ @@ -251,16 +252,40 @@ pub fn process_new_event( // Let seeker know about this event id (in case it was sought) GLOBALS.seeker.found(event)?; - // If metadata, update person if event.kind == EventKind::Metadata { + // If metadata, update person let metadata: Metadata = serde_json::from_str(&event.content)?; GLOBALS .people .update_metadata(&event.pubkey, metadata, event.created_at)?; - } + } else if event.kind == EventKind::HandlerRecommendation { + process_handler_recommendation(event)?; + } else if event.kind == EventKind::HandlerInformation { + // If event kind handler information, add to database + if let Some(mut handler) = Handler::from_31990(event) { + HandlersTable::write_record(&mut handler, None)?; + + // Also add entry to configured_handlers for each kind + for kind in handler.kinds { + // If we already have this handler, do not clobber the + // user's 'enabled' flag + let existing = GLOBALS.db().read_configured_handlers(kind)?; + if existing.iter().any(|(hk, _, _)| *hk == handler.key) { + continue; + } - if event.kind == EventKind::ContactList { + // Write configured handler, enabled by default + GLOBALS.db().write_configured_handler( + kind, + handler.key.clone(), + true, // enabled + false, // recommended + None, + )?; + } + } + } else if event.kind == EventKind::ContactList { if let Some(pubkey) = GLOBALS.identity.public_key() { if event.pubkey == pubkey { // Updates stamps and counts, does NOT change membership @@ -532,6 +557,79 @@ pub fn reprocess_relay_lists() -> Result<(usize, usize), Error> { Ok(counts) } +// Collect handler recommendations, then fetch the handler information +fn process_handler_recommendation(event: &Event) -> Result<(), Error> { + // NOTE: We don't care what 'd' kind is given, we collect these for all kinds. + + let mut naddrs: Vec = Vec::new(); + let mut d = "".to_owned(); + + for tag in &event.tags { + if tag.get_index(0) == "d" { + d = tag.get_index(1).to_owned(); + } + + let (naddr, marker) = match tag.parse_address() { + Ok(pair) => pair, + Err(_) => continue, + }; + let marker = match marker { + Some(m) => m, + None => continue, + }; + if marker != "web" { + continue; + } + + if naddr.kind != EventKind::HandlerInformation { + continue; + }; + + // We need a relay to load the handler from + if naddr.relays.is_empty() { + continue; + } + + naddrs.push(naddr); + } + + if naddrs.is_empty() { + return Ok(()); + } + + // If it is ours (e.g. from another client), update our local recommendation bits + if let Some(pk) = GLOBALS.identity.public_key() { + if event.pubkey == pk { + if let Ok(kindnum) = d.parse::() { + let kind: EventKind = kindnum.into(); + let configured_handlers: Vec<(HandlerKey, bool, bool)> = + GLOBALS.db().read_configured_handlers(kind)?; + for (key, enabled, recommended) in configured_handlers.iter() { + let event_recommended = + naddrs.iter().any(|naddr| *naddr == key.as_naddr(vec![])); + if event_recommended != *recommended { + GLOBALS.db().write_configured_handler( + kind, + key.clone(), + *enabled, + event_recommended, + None, + )?; + } + } + } + } + } + + for naddr in naddrs { + let _ = GLOBALS + .to_overlord + .send(ToOverlordMessage::FetchNAddr(naddr)); + } + + Ok(()) +} + /// Process relationships of an event. /// This returns IDs that should be UI invalidated (must be redrawn) pub(crate) fn process_relationships_of_event( diff --git a/gossip-lib/src/storage/configured_handlers.rs b/gossip-lib/src/storage/configured_handlers.rs new file mode 100644 index 000000000..7d912dfb6 --- /dev/null +++ b/gossip-lib/src/storage/configured_handlers.rs @@ -0,0 +1,171 @@ +use super::types::{ByteRep, HandlerKey}; +use crate::error::Error; +use crate::storage::{RawDatabase, Storage}; +use heed::types::Bytes; +use heed::RwTxn; +use nostr_types::{EventKind, Filter, PublicKey, Tag}; +use std::collections::BTreeSet; +use std::sync::Mutex; + +// (EventKind, HandlerKey) -> u8(flags) + +const ENABLED: u8 = 0x1; +const RECOMMENDED: u8 = 0x2; + +fn configured_handlers_key_to_bytes(kind: EventKind, hk: HandlerKey) -> Result, Error> { + let mut bytes: Vec = Vec::new(); + bytes.extend(u32::from(kind).to_be_bytes()); + bytes.extend(hk.to_bytes()?); + Ok(bytes) +} + +fn configured_handlers_bytes_to_key(bytes: &[u8]) -> Result<(EventKind, HandlerKey), Error> { + let key = u32::from_be_bytes(bytes[0..4].try_into().unwrap()).into(); + let hk = HandlerKey::from_bytes(&bytes[4..])?; + Ok((key, hk)) +} + +static CONFIGURED_HANDLERS_DB_CREATE_LOCK: Mutex<()> = Mutex::new(()); +static mut CONFIGURED_HANDLERS_DB: Option = None; + +impl Storage { + pub(super) fn db_configured_handlers(&self) -> Result { + unsafe { + if let Some(db) = CONFIGURED_HANDLERS_DB { + Ok(db) + } else { + // Lock. This drops when anything returns. + let _lock = CONFIGURED_HANDLERS_DB_CREATE_LOCK.lock(); + + // In case of a race, check again + if let Some(db) = CONFIGURED_HANDLERS_DB { + return Ok(db); + } + + // Create it. We know that nobody else is doing this and that + // it cannot happen twice. + let mut txn = self.env.write_txn()?; + let db = self + .env + .database_options() + .types::() + .name("configured_handlers_redux") // redux because was used on unstable + .create(&mut txn)?; + txn.commit()?; + CONFIGURED_HANDLERS_DB = Some(db); + Ok(db) + } + } + } + + /// Write a configured handler + pub fn write_configured_handler<'a>( + &'a self, + kind: EventKind, + handler_key: HandlerKey, + enabled: bool, + recommended: bool, + rw_txn: Option<&mut RwTxn<'a>>, + ) -> Result<(), Error> { + let mut local_txn = None; + let txn = maybe_local_txn!(self, rw_txn, local_txn); + + let mut flags: u8 = 0; + if enabled { + flags |= ENABLED; + } + if recommended { + flags |= RECOMMENDED; + } + + let key = configured_handlers_key_to_bytes(kind, handler_key)?; + let val = vec![flags]; + self.db_configured_handlers()?.put(txn, &key, &val)?; + + maybe_local_txn_commit!(local_txn); + Ok(()) + } + + /// Read a configured handler (key, enabled, recommended) + pub fn read_configured_handlers( + &self, + kind: EventKind, + ) -> Result, Error> { + let txn = self.get_read_txn()?; + + let mut output: Vec<(HandlerKey, bool, bool)> = Vec::new(); + let prefix: Vec = u32::from(kind).to_be_bytes().into(); + let iter = self.db_configured_handlers()?.prefix_iter(&txn, &prefix)?; + for result in iter { + let (key, val) = result?; + let (_kind, handler_key) = configured_handlers_bytes_to_key(key)?; + let (enabled, recommended) = if val.len() == 0 { + (false, false) + } else { + (val[0] & ENABLED != 0, val[0] & RECOMMENDED != 0) + }; + output.push((handler_key, enabled, recommended)); + } + + Ok(output) + } + + pub fn read_all_configured_handlers( + &self, + ) -> Result, Error> { + let txn = self.env.read_txn()?; + let mut output: Vec<(EventKind, HandlerKey, bool, bool)> = Vec::new(); + let iter = self.db_configured_handlers()?.iter(&txn)?; + for result in iter { + let (key, val) = result?; + let (kind, handler_key) = configured_handlers_bytes_to_key(key)?; + let (enabled, recommended) = if val.len() == 0 { + (false, false) + } else { + (val[0] & ENABLED != 0, val[0] & RECOMMENDED != 0) + }; + output.push((kind, handler_key, enabled, recommended)); + } + Ok(output) + } + + pub fn clear_configured_handlers<'a>( + &'a self, + rw_txn: Option<&mut RwTxn<'a>>, + ) -> Result<(), Error> { + let mut local_txn = None; + let txn = maybe_local_txn!(self, rw_txn, local_txn); + + self.db_configured_handlers()?.clear(txn)?; + + maybe_local_txn_commit!(local_txn); + Ok(()) + } + + pub fn who_recommended_handler( + &self, + key: &HandlerKey, + kind: EventKind, + ) -> Result, Error> { + let mut who: BTreeSet = BTreeSet::new(); + + let naddr = key.as_naddr(vec![]); + let atag = Tag::new_address(&naddr, None); + let mut filter = Filter::new(); + filter.add_event_kind(EventKind::HandlerRecommendation); + filter.add_tag_value('a', atag.get_index(1).to_string()); + let events = self.find_events_by_filter(&filter, |_| true)?; + for event in events.iter() { + if let Some(d) = event.parameter() { + if let Ok(kindnum) = d.parse::() { + let event_kind: EventKind = kindnum.into(); + if event_kind == kind { + who.insert(event.pubkey); + } + } + } + } + + Ok(who.iter().map(|k| *k).collect()) + } +} diff --git a/gossip-lib/src/storage/handlers_table.rs b/gossip-lib/src/storage/handlers_table.rs new file mode 100644 index 000000000..691075d69 --- /dev/null +++ b/gossip-lib/src/storage/handlers_table.rs @@ -0,0 +1,50 @@ +use super::types::Handler; +use super::Table; +use crate::error::Error; +use crate::globals::GLOBALS; +use heed::types::Bytes; +use heed::Database; +use std::sync::Mutex; + +static HANDLERS_DB_CREATE_LOCK: Mutex<()> = Mutex::new(()); +static mut HANDLERS_DB: Option> = None; + +pub struct HandlersTable {} + +impl Table for HandlersTable { + type Item = Handler; + + fn lmdb_name() -> &'static str { + "handlers" + } + + fn db() -> Result, Error> { + unsafe { + if let Some(db) = HANDLERS_DB { + Ok(db) + } else { + // Lock. This drops when anything returns. + let _lock = HANDLERS_DB_CREATE_LOCK.lock(); + + // In case of a race, check again + if let Some(db) = HANDLERS_DB { + return Ok(db); + } + + // Create it. We know that nobody else is doing this and that + // it cannot happen twice. + let mut txn = GLOBALS.db().env.write_txn()?; + let db = GLOBALS + .db() + .env + .database_options() + .types::() + .name(Self::lmdb_name()) + .create(&mut txn)?; + txn.commit()?; + HANDLERS_DB = Some(db); + Ok(db) + } + } + } +} diff --git a/gossip-lib/src/storage/migrations/m43.rs b/gossip-lib/src/storage/migrations/m43.rs new file mode 100644 index 000000000..aed09b1e6 --- /dev/null +++ b/gossip-lib/src/storage/migrations/m43.rs @@ -0,0 +1,72 @@ +use super::Storage; +use crate::error::Error; +use crate::storage::types::{Handler, HandlerKey}; +use crate::storage::{HandlersTable, Table}; +use heed::RwTxn; +use nostr_types::{EventKind, Filter}; + +impl Storage { + pub(super) fn m43_trigger(&self) -> Result<(), Error> { + let _ = self.db_configured_handlers()?; + let _ = HandlersTable::db()?; + Ok(()) + } + + pub(super) fn m43_migrate<'a>( + &'a self, + prefix: &str, + txn: &mut RwTxn<'a>, + ) -> Result<(), Error> { + // Info message + tracing::info!("{prefix}: reimporting event handlers..."); + + // Load all configured handlers into memory + let configured_handlers: Vec<(EventKind, HandlerKey, bool, bool)> = + self.read_all_configured_handlers()?; + + // Delete all handlers + HandlersTable::clear(Some(txn))?; + + // Delete all configured handlers + self.clear_configured_handlers(Some(txn))?; + + // Load all 31990 events + // This is a scrape (no index for this) + let mut filter = Filter::new(); + filter.add_event_kind(EventKind::HandlerInformation); + let events = self.find_events_by_filter(&filter, |_| true)?; + + // Convert them into Handlers and save them + for event in events.iter() { + if let Some(mut handler) = Handler::from_31990(event) { + HandlersTable::write_record(&mut handler, Some(txn))?; + + // And also save their per-kind configuration data + for kind in handler.kinds { + let mut enabled: bool = true; + let mut recommended: bool = false; + + // If we already had it configured, use that data + if let Some((_, _, was_enabled, was_recommended)) = configured_handlers + .iter() + .find(|c| c.0 == kind && c.1 == handler.key) + { + enabled = *was_enabled; + recommended = *was_recommended; + } + + // Write configured handler, enabled by default + self.write_configured_handler( + kind, + handler.key.clone(), + enabled, + recommended, + Some(txn), + )?; + } + } + } + + Ok(()) + } +} diff --git a/gossip-lib/src/storage/migrations/mod.rs b/gossip-lib/src/storage/migrations/mod.rs index 1a9874e7a..c00296fa4 100644 --- a/gossip-lib/src/storage/migrations/mod.rs +++ b/gossip-lib/src/storage/migrations/mod.rs @@ -26,6 +26,7 @@ mod m39; mod m40; mod m41; mod m42; +mod m43; use super::Storage; use crate::error::{Error, ErrorKind}; @@ -33,7 +34,7 @@ use heed::RwTxn; impl Storage { const MIN_MIGRATION_LEVEL: u32 = 23; - const MAX_MIGRATION_LEVEL: u32 = 42; + const MAX_MIGRATION_LEVEL: u32 = 43; /// Initialize the database from empty pub(super) fn init_from_empty(&self) -> Result<(), Error> { @@ -128,6 +129,7 @@ impl Storage { 40 => self.m40_trigger()?, 41 => self.m41_trigger()?, 42 => self.m42_trigger()?, + 43 => self.m43_trigger()?, _ => panic!("Unreachable migration level"), } @@ -161,6 +163,7 @@ impl Storage { 40 => self.m40_migrate(&prefix, txn)?, 41 => self.m41_migrate(&prefix, txn)?, 42 => self.m42_migrate(&prefix, txn)?, + 43 => self.m43_migrate(&prefix, txn)?, _ => panic!("Unreachable migration level"), }; diff --git a/gossip-lib/src/storage/mod.rs b/gossip-lib/src/storage/mod.rs index f40704c90..cae875c56 100644 --- a/gossip-lib/src/storage/mod.rs +++ b/gossip-lib/src/storage/mod.rs @@ -20,13 +20,15 @@ pub use person4_table::Person4Table; pub type PersonTable = Person4Table; pub mod followings_table; pub use followings_table::FollowingsTable; +pub mod handlers_table; +pub use handlers_table::HandlersTable; // database implementations +mod configured_handlers; mod event_akci_index; use event_akci_index::AkciKey; mod event_kci_index; use event_kci_index::KciKey; - mod event_ek_c_index1; mod event_ek_pk_index1; mod event_seen_on_relay1; @@ -223,8 +225,10 @@ impl Storage { let _ = self.db_person_lists()?; let _ = self.db_person_lists_metadata()?; let _ = self.db_fof()?; + let _ = self.db_configured_handlers()?; let _ = PersonTable::db()?; let _ = FollowingsTable::db()?; + let _ = HandlersTable::db()?; // Do migrations match self.read_migration_level()? { @@ -419,6 +423,11 @@ impl Storage { Ok(self.db_fof()?.len(&txn)?) } + pub fn get_configured_handlers_len(&self) -> Result { + let txn = self.env.read_txn()?; + Ok(self.db_configured_handlers()?.len(&txn)?) + } + // General key-value functions -------------------------------------------------- pub fn force_migration_level(&self, level: u32) -> Result<(), Error> { diff --git a/gossip-lib/src/storage/types/handler.rs b/gossip-lib/src/storage/types/handler.rs new file mode 100644 index 000000000..f7ccbdccf --- /dev/null +++ b/gossip-lib/src/storage/types/handler.rs @@ -0,0 +1,205 @@ +use super::{ByteRep, Record}; +use crate::error::Error; +use nostr_types::{Event, EventKind, Metadata, NAddr, PublicKey, UncheckedUrl}; +use serde::{Deserialize, Serialize}; +use speedy::{Readable, Writable}; +use std::sync::OnceLock; + +// THIS IS HISTORICAL FOR MIGRATIONS AND THE STRUCTURES SHOULD NOT BE EDITED + +/// This is a key into the Handler table identifying the app and the 'd' tag on their +/// handler event (an app can have multiple handler events with different 'd' tags) +#[derive(Debug, Clone, Readable, Writable, Serialize, Deserialize, PartialEq)] +pub struct HandlerKey { + /// Public key + pub pubkey: PublicKey, + + /// d tag + pub d: String, +} + +impl HandlerKey { + pub fn as_naddr(&self, relays: Vec) -> NAddr { + NAddr { + d: self.d.clone(), + relays, + kind: EventKind::HandlerInformation, + author: self.pubkey, + } + } +} + +impl ByteRep for HandlerKey { + fn to_bytes(&self) -> Result, Error> { + Ok(self.write_to_vec()?) + } + + fn from_bytes(bytes: &[u8]) -> Result { + Ok(Self::read_from_buffer(bytes)?) + } +} + +/// A handler record +#[derive(Debug, Clone, Readable, Writable, Serialize, Deserialize)] +pub struct Handler { + /// Handler key + pub key: HandlerKey, + + /// Metadata serialized as JSON + pub(in crate::storage) metadata_json: String, + + // We deserialize metadata on first access + #[serde(skip)] + #[speedy(skip)] + pub(in crate::storage) deserialized_metadata: OnceLock>, + + /// Event kinds handled + pub kinds: Vec, + + /// URL handling nevent (web only) + pub nevent_url: Option, + + /// URL handling naddr (web only) + pub naddr_url: Option, +} + +impl Handler { + pub fn from_31990(event: &Event) -> Option { + if event.kind != EventKind::HandlerInformation { + return None; + } + + let mut d = "".to_owned(); + let mut nevent_url = None; + let mut naddr_url = None; + let mut kinds = Vec::new(); + + for tag in &event.tags { + if tag.get_index(0) == "d" { + d = tag.get_index(1).to_owned(); + } else if tag.get_index(0) == "k" { + if let Ok(kindnum) = tag.get_index(1).parse::() { + let kind: EventKind = kindnum.into(); + kinds.push(kind); + } + } else if tag.get_index(0) == "web" { + let u = tag.get_index(1); + if Self::check_url(u) { + if tag.get_index(2) == "nevent" { + nevent_url = Some(UncheckedUrl::from_str(u)); + } else if tag.get_index(2) == "naddr" { + naddr_url = Some(UncheckedUrl::from_str(u)); + } + } + } + } + + // Must have at least one supported URL + if nevent_url.is_none() && naddr_url.is_none() { + return None; + } + + // Remove kinds that don't have web URLs for them + kinds.retain(|k| { + (!k.is_parameterized_replaceable() && nevent_url.is_some()) + || (k.is_parameterized_replaceable() && naddr_url.is_some()) + }); + + // Must support at least one kind with the supported URLs + if kinds.is_empty() { + return None; + } + + Some(Handler { + key: HandlerKey { + pubkey: event.pubkey, + d, + }, + metadata_json: event.content.clone(), + deserialized_metadata: OnceLock::new(), + kinds, + nevent_url, + naddr_url, + }) + } + + fn check_url(u: &str) -> bool { + // Verify it parses as a URI with a host + if let Ok(uri) = u.replace("", "x").parse::() { + if uri.host().is_some() { + return true; + } + } + + false + } + + pub fn metadata(&self) -> &Option { + self.deserialized_metadata + .get_or_init(|| serde_json::from_str::(&self.metadata_json).ok()) + } + + pub fn bestname(&self, kind: EventKind) -> Option { + match self.metaname() { + Some(n) => Some(n), + None => self.hostname(kind), + } + } + + pub fn metaname(&self) -> Option { + // Try metadata + if let Some(m) = self.metadata() { + if let Some(n) = &m.name { + return Some(n.to_owned()); + } + } + + None + } + + pub fn hostname(&self, kind: EventKind) -> Option { + if kind.is_parameterized_replaceable() { + if let Some(url) = &self.naddr_url { + if let Ok(uri) = url.as_str().replace("", "x").parse::() { + if let Some(host) = uri.host() { + return Some(host.to_owned()); + } + } + } + } else { + if let Some(url) = &self.nevent_url { + if let Ok(uri) = url.as_str().replace("", "x").parse::() { + if let Some(host) = uri.host() { + return Some(host.to_owned()); + } + } + } + } + + None + } +} + +impl ByteRep for Handler { + fn to_bytes(&self) -> Result, Error> { + Ok(self.write_to_vec()?) + } + + fn from_bytes(bytes: &[u8]) -> Result { + Ok(Self::read_from_buffer(bytes)?) + } +} + +impl Record for Handler { + type Key = HandlerKey; + + /// Create a new default record if possible + fn new(_k: Self::Key) -> Option { + None + } + + /// Get the key of a record + fn key(&self) -> Self::Key { + self.key.clone() + } +} diff --git a/gossip-lib/src/storage/types/mod.rs b/gossip-lib/src/storage/types/mod.rs index f2a25298a..5fa375bc1 100644 --- a/gossip-lib/src/storage/types/mod.rs +++ b/gossip-lib/src/storage/types/mod.rs @@ -1,3 +1,6 @@ +mod handler; +pub use handler::{Handler, HandlerKey}; + mod person2; pub use person2::Person2; diff --git a/gossip-lib/src/tasks.rs b/gossip-lib/src/tasks.rs index 1017f99e3..f728aefc3 100644 --- a/gossip-lib/src/tasks.rs +++ b/gossip-lib/src/tasks.rs @@ -83,4 +83,7 @@ async fn do_general_tasks(tick: usize) { GLOBALS.unread_dms.store(unread, Ordering::Relaxed); } } + + // Update handlers for quick menu rendering + let _ = GLOBALS.update_handlers(); }