Compare commits

...

10 Commits

Author SHA1 Message Date
e66e7e938e
fix: weird placeholder images 2025-01-20 11:40:23 +00:00
62a7933de0
feat: resize images 2025-01-14 11:17:37 +00:00
7e9e61d14c
feat: profile page 2025-01-13 12:13:37 +00:00
73d03ca0f1
feat: zap modal 2025-01-12 11:40:34 +00:00
93e7ee18c1
chore: add readme 2025-01-09 15:00:48 +00:00
785e8dbd22
chore: cleanup (again) 2025-01-09 14:54:06 +00:00
85ef186048
cleanup 2025-01-09 14:30:44 +00:00
dc7fee4151
wip 2025-01-09 14:26:47 +00:00
f7021094bc
wip 2025-01-07 14:13:41 +00:00
0e19c1a8f3
wip notedeck 2025-01-07 11:23:34 +00:00
45 changed files with 2461 additions and 2290 deletions

1548
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,33 +8,33 @@ crate-type = ["lib", "cdylib"]
[features] [features]
default = [] default = []
notedeck = ["dep:notedeck", "dep:notedeck-chrome"]
[dependencies] [dependencies]
tokio = { version = "1.40.0", features = ["fs", "rt-multi-thread", "rt"] } tokio = { version = "1.40.0", features = ["fs", "rt-multi-thread", "rt"] }
egui = { version = "0.29.1", default-features = false, features = [] } egui = { version = "0.29.1", default-features = false, features = [] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "3deb94aef3f436469158c4424650d81be26f9315" }
nostr-sdk = { version = "0.37", features = ["all-nips"] }
log = "0.4.22" log = "0.4.22"
pretty_env_logger = "0.5.0" pretty_env_logger = "0.5.0"
egui_inbox = "0.6.0"
bech32 = "0.11.0" bech32 = "0.11.0"
libc = "0.2.158"
uuid = { version = "1.11.0", features = ["v4"] }
chrono = "0.4.38"
anyhow = "^1.0.91" anyhow = "^1.0.91"
async-trait = "0.1.83" itertools = "0.14.0"
sha2 = "0.10.8"
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls-native-roots"] }
itertools = "0.13.0"
lru = "0.12.5"
resvg = { version = "0.44.0", default-features = false }
serde = { version = "1.0.214", features = ["derive"] } serde = { version = "1.0.214", features = ["derive"] }
serde_with = { version = "3.11.0", features = ["hex"] } directories = "6.0.0"
directories = "5.0.1" egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "11db7d0c30070529a36bfb050844cdb75c32902b" }
egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "d2ea3b4db21eb870a207db19e4cd21c7d1d24836" } egui_qr = { git = "https://git.v0l.io/Kieran/egui_qr.git", rev = "f9cf52b7eae353fa9e59ed0358151211d48824d1" }
notedeck-chrome = { git = "https://git.v0l.io/nostr/notedeck.git", branch = "master", package = "notedeck_chrome", optional = true }
notedeck = { git = "https://git.v0l.io/nostr/notedeck.git", branch = "master", package = "notedeck", optional = true } # notedeck stuff
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49", "nip57"] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "2111948b078b24a1659d0bd5d8570f370269c99b" }
notedeck-chrome = { git = "https://github.com/damus-io/notedeck", rev = "06417ff69e772f24ffd7fb2b025f879463d8c51f", package = "notedeck_chrome" }
notedeck = { git = "https://github.com/damus-io/notedeck", rev = "06417ff69e772f24ffd7fb2b025f879463d8c51f", package = "notedeck" }
enostr = { git = "https://github.com/damus-io/notedeck", rev = "06417ff69e772f24ffd7fb2b025f879463d8c51f", package = "enostr" }
poll-promise = "0.3.0"
ehttp = "0.5.0"
egui-modal = "0.5.0"
icu = "1.5.0"
icu_decimal = "1.5.0"
fixed_decimal = "0.5.6"
lnurl-rs = { version = "0.9.0", default-features = false }
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
eframe = { version = "0.29.1" } eframe = { version = "0.29.1" }

9
README.md Normal file
View File

@ -0,0 +1,9 @@
# zap.stream
A native [zap.stream](https://zap.stream) app built with [notedeck](https://github.com/damus-io/notedeck).
This is **ALPHA** stage software, use at your own risk!
![preview](https://nostr.download/d3ceb5b379d7faddd9d6c536292bf46e097b35fbcaf431ff0a1cbd3e3acaf052.webp)
![preview_mobile](https://nostr.download/7d303cac1b4c923e76f386b3008de5551c0a249d9ceeddafb424dfe4a34ae533.webp)

View File

@ -1,4 +1,3 @@
- [Login] Proper key storage
- [NDB] Handle PRE's - [NDB] Handle PRE's
- [UX] Render non-ascii chars with better font - [UX] Render non-ascii chars with better font
- [egui-video] Handle mobile DPI for video player - [egui-video] Handle mobile DPI for video player

View File

@ -1,9 +1,6 @@
use crate::app::{NativeLayerOps, ZapStreamApp}; use crate::app::ZapStreamApp;
use eframe::Renderer; use eframe::Renderer;
use egui::{Margin, ViewportBuilder}; use egui::ViewportBuilder;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::ops::Div;
use winit::platform::android::activity::AndroidApp; use winit::platform::android::activity::AndroidApp;
use winit::platform::android::EventLoopBuilderExtAndroid; use winit::platform::android::EventLoopBuilderExtAndroid;
@ -34,57 +31,17 @@ pub fn start_android(app: AndroidApp) {
if let Err(e) = eframe::run_native( if let Err(e) = eframe::run_native(
"zap.stream", "zap.stream",
options, options,
Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, data_path, app)))), Box::new(move |cc| {
let args: Vec<String> = std::env::args().collect();
let mut notedeck =
notedeck_chrome::Notedeck::new(&cc.egui_ctx, data_path.clone(), &args);
let zs_app = ZapStreamApp::new(cc, app.clone());
notedeck.add_app(zs_app);
Ok(Box::new(notedeck))
}),
) { ) {
eprintln!("{}", e); eprintln!("{}", e);
} }
} }
impl NativeLayerOps for AndroidApp {
fn frame_margin(&self) -> Margin {
if let Some(wd) = self.native_window() {
let (w, h) = (wd.width(), wd.height());
let c_rect = self.content_rect();
let dpi = self.config().density().unwrap_or(160);
let dpi_scale = dpi as f32 / 160.0;
// TODO: this calc is weird but seems to work on my phone
Margin {
bottom: (h - c_rect.bottom) as f32,
left: c_rect.left as f32,
right: (w - c_rect.right) as f32,
top: (c_rect.top - (h - c_rect.bottom)) as f32,
}
.div(dpi_scale)
} else {
Margin::ZERO
}
}
fn show_keyboard(&self) {
self.show_soft_input(true);
}
fn hide_keyboard(&self) {
self.hide_soft_input(true);
}
fn get(&self, k: &str) -> Option<String> {
None
}
fn set(&mut self, k: &str, v: &str) -> bool {
false
}
fn remove(&mut self, k: &str) -> bool {
false
}
fn get_obj<T: DeserializeOwned>(&self, k: &str) -> Option<T> {
None
}
fn set_obj<T: Serialize>(&mut self, k: &str, v: &T) -> bool {
false
}
}

View File

@ -1,67 +1,34 @@
use crate::route::Router; use crate::profiles::ProfileLoader;
use eframe::epaint::FontFamily; use crate::route::{page, RouteAction, RouteServices, RouteType};
use crate::theme::MARGIN_DEFAULT;
use crate::widgets::{Header, NostrWidget};
use eframe::epaint::{FontFamily, Margin};
use eframe::CreationContext; use eframe::CreationContext;
use egui::{Color32, FontData, FontDefinitions, Margin}; use egui::{Color32, FontData, FontDefinitions, Theme, Ui, Visuals};
use nostr_sdk::prelude::MemoryDatabase; use enostr::{PoolEvent, RelayEvent, RelayMessage};
use nostr_sdk::Client; use log::{error, info, warn};
use nostrdb::{Config, Ndb}; use nostrdb::{Filter, Transaction};
use notedeck::AppContext; use notedeck::AppContext;
use std::path::PathBuf; use poll_promise::Promise;
use std::collections::HashMap;
use std::sync::mpsc;
pub struct ZapStreamApp<T: NativeLayerOps> { pub struct ZapStreamApp {
client: Client, current: RouteType,
router: Router<T>, routes_rx: mpsc::Receiver<RouteType>,
native_layer: T, routes_tx: mpsc::Sender<RouteType>,
#[cfg(target_os = "android")]
app: android_activity::AndroidApp,
widget: Box<dyn NostrWidget>,
profiles: ProfileLoader,
fetch: HashMap<String, Promise<ehttp::Result<ehttp::Response>>>,
} }
pub trait NativeLayerOps { #[cfg(target_os = "android")]
/// Get any display layout margins impl ZapStreamApp {
fn frame_margin(&self) -> Margin; pub fn new(cc: &CreationContext, app: android_activity::AndroidApp) -> Self {
/// Show the keyboard on the screen
fn show_keyboard(&self);
/// Hide on screen keyboard
fn hide_keyboard(&self);
fn get(&self, k: &str) -> Option<String>;
fn set(&mut self, k: &str, v: &str) -> bool;
fn remove(&mut self, k: &str) -> bool;
fn get_obj<T: serde::de::DeserializeOwned>(&self, k: &str) -> Option<T>;
fn set_obj<T: serde::Serialize>(&mut self, k: &str, v: &T) -> bool;
}
impl<T> ZapStreamApp<T>
where
T: NativeLayerOps + Clone,
{
pub fn new(cc: &CreationContext, data_path: PathBuf, config: T) -> Self {
let client = Client::builder()
.database(MemoryDatabase::with_opts(Default::default()))
.build();
let client_clone = client.clone();
tokio::spawn(async move {
client_clone
.add_relay("wss://nos.lol")
.await
.expect("Failed to add relay");
client_clone
.add_relay("wss://relay.damus.io")
.await
.expect("Failed to add relay");
client_clone
.add_relay("wss://relay.snort.social")
.await
.expect("Failed to add relay");
client_clone.connect().await;
});
let ndb_path = data_path.join("ndb");
std::fs::create_dir_all(&ndb_path).expect("Failed to create ndb directory");
let mut ndb_config = Config::default();
ndb_config.set_ingester_threads(4);
let ndb = Ndb::new(ndb_path.to_str().unwrap(), &ndb_config).unwrap();
let mut fd = FontDefinitions::default(); let mut fd = FontDefinitions::default();
fd.font_data.insert( fd.font_data.insert(
"Outfit".to_string(), "Outfit".to_string(),
@ -71,63 +38,169 @@ where
.insert(FontFamily::Proportional, vec!["Outfit".to_string()]); .insert(FontFamily::Proportional, vec!["Outfit".to_string()]);
cc.egui_ctx.set_fonts(fd); cc.egui_ctx.set_fonts(fd);
let cfg = config.clone(); let (tx, rx) = mpsc::channel();
Self { Self {
client: client.clone(), current: RouteType::HomePage,
router: Router::new( widget: Box::new(page::HomePage::new()),
data_path, profiles: ProfileLoader::new(),
cc.egui_ctx.clone(), routes_tx: tx,
client.clone(), routes_rx: rx,
ndb.clone(), app,
cfg,
),
native_layer: config,
} }
} }
} }
#[cfg(not(feature = "notedeck"))] #[cfg(not(target_os = "android"))]
impl<T> App for ZapStreamApp<T> impl ZapStreamApp {
where pub fn new(cc: &CreationContext) -> Self {
T: NativeLayerOps, let mut fd = FontDefinitions::default();
{ fd.font_data.insert(
fn update(&mut self, ctx: &Context, frame: &mut Frame) { "Outfit".to_string(),
let mut app_frame = egui::containers::Frame::default(); FontData::from_static(include_bytes!("../assets/Outfit-Light.ttf")),
let margin = self.native_layer.frame_margin(); );
fd.families
.insert(FontFamily::Proportional, vec!["Outfit".to_string()]);
cc.egui_ctx.set_fonts(fd);
app_frame.inner_margin = margin; // ffmpeg log redirect
app_frame.stroke.color = Color32::BLACK; unsafe {
egui_video::ffmpeg_sys_the_third::av_log_set_callback(Some(
//ctx.set_debug_on_hover(true); egui_video::ffmpeg_rs_raw::av_log_redirect,
));
egui::CentralPanel::default() }
.frame(app_frame) let (tx, rx) = mpsc::channel();
.show(ctx, |ui| { Self {
ui.visuals_mut().override_text_color = Some(Color32::WHITE); current: RouteType::HomePage,
self.router.show(ui); widget: Box::new(page::HomePage::new()),
}); profiles: ProfileLoader::new(),
routes_tx: tx,
routes_rx: rx,
fetch: HashMap::new(),
}
} }
} }
#[cfg(feature = "notedeck")] impl notedeck::App for ZapStreamApp {
impl<T> notedeck::App for ZapStreamApp<T> fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut Ui) {
where ctx.accounts.update(ctx.ndb, ctx.pool, ui.ctx());
T: NativeLayerOps, while let Some(PoolEvent { event, relay }) = ctx.pool.try_recv() {
{ if let RelayEvent::Message(msg) = (&event).into() {
fn update(&mut self, ctx: &mut AppContext<'_>) { match msg {
let mut app_frame = egui::containers::Frame::default(); RelayMessage::OK(_) => {}
let margin = self.native_layer.frame_margin(); RelayMessage::Eose(_) => {}
RelayMessage::Event(_sub, ev) => {
if let Err(e) = ctx.ndb.process_event(ev) {
error!("Error processing event: {:?}", e);
}
ui.ctx().request_repaint();
}
RelayMessage::Notice(m) => warn!("Notice from {}: {}", relay, m),
}
}
}
app_frame.inner_margin = margin; // reset theme
app_frame.stroke.color = Color32::BLACK; ui.ctx().set_visuals_of(
Theme::Dark,
Visuals {
panel_fill: Color32::BLACK,
override_text_color: Some(Color32::WHITE),
..Default::default()
},
);
//ctx.set_debug_on_hover(true); //ui.ctx().set_debug_on_hover(true);
let app_frame = egui::containers::Frame::default().outer_margin(self.frame_margin());
// handle app state changes
while let Ok(r) = self.routes_rx.try_recv() {
if let RouteType::Action(a) = r {
match a {
RouteAction::DemandProfile(p) => {
self.profiles.demand(p);
}
_ => info!("Not implemented"),
}
} else {
self.current = r;
match &self.current {
RouteType::HomePage => {
self.widget = Box::new(page::HomePage::new());
}
RouteType::EventPage { link, .. } => {
self.widget = Box::new(page::StreamPage::new_from_link(link.clone()));
}
RouteType::LoginPage => {
self.widget = Box::new(page::LoginPage::new());
}
RouteType::ProfilePage { link } => {
self.widget = Box::new(page::ProfilePage::new(
link.id.as_bytes().try_into().unwrap(),
));
}
RouteType::Action { .. } => panic!("Actions!"),
_ => panic!("Not implemented"),
}
}
}
egui::CentralPanel::default() egui::CentralPanel::default()
.frame(app_frame) .frame(app_frame)
.show(ctx.egui, |ui| { .show(ui.ctx(), |ui| {
ui.visuals_mut().override_text_color = Some(Color32::WHITE); let tx = Transaction::new(ctx.ndb).expect("transaction");
self.router.show(ui); // display app
ui.vertical(|ui| {
let mut svc = RouteServices::new(
ui.ctx().clone(),
&tx,
ctx,
self.routes_tx.clone(),
&mut self.fetch,
);
Header::new().render(ui, &mut svc, &tx);
if let Err(e) = self.widget.update(&mut svc) {
error!("{}", e);
}
self.widget.render(ui, &mut svc);
})
.response
}); });
let profiles = self.profiles.next();
if !profiles.is_empty() {
info!("Profiles: {:?}", profiles);
ctx.pool.subscribe(
"profiles".to_string(),
vec![Filter::new().kinds([0]).authors(&profiles).build()],
);
}
}
}
#[cfg(not(target_os = "android"))]
impl ZapStreamApp {
fn frame_margin(&self) -> Margin {
Margin::ZERO
}
}
#[cfg(target_os = "android")]
impl ZapStreamApp {
fn frame_margin(&self) -> Margin {
if let Some(wd) = self.app.native_window() {
let (w, h) = (wd.width(), wd.height());
let c_rect = self.app.content_rect();
let dpi = self.app.config().density().unwrap_or(160);
let dpi_scale = dpi as f32 / 160.0;
// TODO: this calc is weird but seems to work on my phone
Margin {
bottom: (h - c_rect.bottom) as f32,
left: c_rect.left as f32,
right: (w - c_rect.right) as f32,
top: (c_rect.top - (h - c_rect.bottom)) as f32,
} / dpi_scale
} else {
Margin::ZERO
}
} }
} }

View File

@ -1,17 +1,9 @@
use anyhow::Result; use anyhow::Result;
use directories::ProjectDirs; use directories::ProjectDirs;
use eframe::Renderer; use eframe::Renderer;
use egui::{Margin, Vec2, ViewportBuilder}; use egui::{Vec2, ViewportBuilder};
use log::error; use log::{error, info};
use nostr_sdk::serde_json; use zap_stream_app::app::ZapStreamApp;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::ops::Deref;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use zap_stream_app::app::{NativeLayerOps, ZapStreamApp};
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@ -21,13 +13,12 @@ async fn main() -> Result<()> {
options.viewport = ViewportBuilder::default().with_inner_size(Vec2::new(1300., 900.)); options.viewport = ViewportBuilder::default().with_inner_size(Vec2::new(1300., 900.));
options.renderer = Renderer::Glow; options.renderer = Renderer::Glow;
let data_path = ProjectDirs::from("stream", "zap", "app") let data_path = ProjectDirs::from("stream", "zap", "zap_stream_app")
.unwrap() .unwrap()
.config_dir() .data_dir()
.to_path_buf(); .to_path_buf();
let config = DesktopApp::new(data_path.clone()); info!("Data path: {}", data_path.display());
#[cfg(feature = "notedeck")]
if let Err(e) = eframe::run_native( if let Err(e) = eframe::run_native(
"zap.stream", "zap.stream",
options, options,
@ -36,7 +27,7 @@ async fn main() -> Result<()> {
let mut notedeck = let mut notedeck =
notedeck_chrome::Notedeck::new(&cc.egui_ctx, data_path.clone(), &args); notedeck_chrome::Notedeck::new(&cc.egui_ctx, data_path.clone(), &args);
let app = ZapStreamApp::new(cc, data_path, config); let app = ZapStreamApp::new(cc);
notedeck.add_app(app); notedeck.add_app(app);
Ok(Box::new(notedeck)) Ok(Box::new(notedeck))
@ -44,93 +35,5 @@ async fn main() -> Result<()> {
) { ) {
error!("{}", e); error!("{}", e);
} }
#[cfg(not(feature = "notedeck"))]
if let Err(e) = eframe::run_native("zap.stream", options, Box::new(move |cc| Ok(Box::new()))) {
error!("{}", e);
}
Ok(()) Ok(())
} }
#[derive(Clone)]
pub struct DesktopApp {
data_path: PathBuf,
data: Arc<RwLock<HashMap<String, String>>>,
}
impl DesktopApp {
pub fn new(data_path: PathBuf) -> Self {
let mut r = Self {
data_path,
data: Arc::new(RwLock::new(HashMap::new())),
};
r.load();
r
}
fn storage_file_path(&self) -> PathBuf {
self.data_path.join("kv.json")
}
fn load(&mut self) {
let path = self.storage_file_path();
if path.exists() {
let mut file = std::fs::File::open(path).unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
if let Ok(d) = serde_json::from_slice(data.as_slice()) {
self.data = Arc::new(RwLock::new(d));
}
}
}
fn save(&self) {
let path = self.storage_file_path();
let mut file = std::fs::File::create(path).unwrap();
let json = serde_json::to_string_pretty(self.data.read().unwrap().deref()).unwrap();
file.write_all(json.as_bytes()).unwrap();
}
}
impl NativeLayerOps for DesktopApp {
fn frame_margin(&self) -> Margin {
Margin::ZERO
}
fn show_keyboard(&self) {
// nothing to do
}
fn hide_keyboard(&self) {
// nothing to do
}
fn get(&self, k: &str) -> Option<String> {
self.data.read().unwrap().get(k).cloned()
}
fn set(&mut self, k: &str, v: &str) -> bool {
self.data
.write()
.unwrap()
.insert(k.to_owned(), v.to_owned())
.is_none()
}
fn remove(&mut self, k: &str) -> bool {
self.data.write().unwrap().remove(k).is_some()
}
fn get_obj<T: DeserializeOwned>(&self, k: &str) -> Option<T> {
serde_json::from_str(self.get(k)?.as_str()).ok()
}
fn set_obj<T: Serialize>(&mut self, k: &str, v: &T) -> bool {
self.set(k, serde_json::to_string(v).unwrap().as_str())
}
}
impl Drop for DesktopApp {
fn drop(&mut self) {
self.save();
}
}

View File

@ -2,21 +2,21 @@
mod android; mod android;
pub mod app; pub mod app;
mod link; mod link;
mod login; mod note_ref;
mod note_store;
mod note_util; mod note_util;
mod note_view;
mod profiles;
mod route; mod route;
mod services; mod services;
mod stream_info; mod stream_info;
mod sub;
mod theme; mod theme;
mod widgets; mod widgets;
mod zap;
#[cfg(target_os = "android")]
use android_activity::AndroidApp;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[no_mangle] #[no_mangle]
#[tokio::main] #[tokio::main]
pub async fn android_main(app: AndroidApp) { pub async fn android_main(app: android_activity::AndroidApp) {
android::start_android(app); android::start_android(app);
} }

View File

@ -1,11 +1,11 @@
use crate::note_util::NoteUtil; use crate::note_util::NoteUtil;
use bech32::{Hrp, NoChecksum}; use bech32::{Hrp, NoChecksum};
use egui::TextBuffer; use nostr::prelude::{hex, Coordinate};
use nostr_sdk::util::hex; use nostr::{Kind, PublicKey};
use nostrdb::{Filter, Note}; use nostrdb::{Filter, NdbStrVariant, Note};
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
#[derive(Clone, Eq, PartialEq)] #[derive(Clone, Eq, PartialEq, Hash)]
pub struct NostrLink { pub struct NostrLink {
pub hrp: NostrLinkType, pub hrp: NostrLinkType,
pub id: IdOrStr, pub id: IdOrStr,
@ -14,12 +14,21 @@ pub struct NostrLink {
pub relays: Vec<String>, pub relays: Vec<String>,
} }
#[derive(Clone, Eq, PartialEq)] #[derive(Clone, Eq, PartialEq, Hash)]
pub enum IdOrStr { pub enum IdOrStr {
Id([u8; 32]), Id([u8; 32]),
Str(String), Str(String),
} }
impl IdOrStr {
pub fn as_bytes(&self) -> &[u8] {
match self {
IdOrStr::Id(i) => i,
IdOrStr::Str(s) => s.as_bytes(),
}
}
}
impl Display for IdOrStr { impl Display for IdOrStr {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self { match self {
@ -29,7 +38,7 @@ impl Display for IdOrStr {
} }
} }
#[derive(Clone, Eq, PartialEq)] #[derive(Clone, Eq, PartialEq, Hash)]
pub enum NostrLinkType { pub enum NostrLinkType {
Note, Note,
PublicKey, PublicKey,
@ -59,22 +68,16 @@ impl NostrLink {
} }
pub fn from_note(note: &Note<'_>) -> Self { pub fn from_note(note: &Note<'_>) -> Self {
if note.kind() >= 30_000 if note.kind() >= 30_000 && note.kind() < 40_000 {
&& note.kind() < 40_000
&& note
.get_tag_value("d")
.and_then(|v| v.variant().str())
.is_some()
{
Self { Self {
hrp: NostrLinkType::Coordinate, hrp: NostrLinkType::Coordinate,
id: IdOrStr::Str( id: IdOrStr::Str(
note.get_tag_value("d") note.get_tag_value("d")
.unwrap() .map(|t| match t.variant() {
.variant() NdbStrVariant::Id(s) => hex::encode(s),
.str() NdbStrVariant::Str(s) => s.to_owned(),
.unwrap() })
.to_string(), .unwrap_or(String::from("")),
), ),
kind: Some(note.kind()), kind: Some(note.kind()),
author: Some(*note.pubkey()), author: Some(*note.pubkey()),
@ -91,6 +94,16 @@ impl NostrLink {
} }
} }
pub fn profile(pubkey: &[u8; 32]) -> Self {
Self {
hrp: NostrLinkType::Profile,
id: IdOrStr::Id(*pubkey),
kind: None,
author: None,
relays: vec![],
}
}
pub fn to_tag(&self) -> Vec<String> { pub fn to_tag(&self) -> Vec<String> {
if self.hrp == NostrLinkType::Coordinate { if self.hrp == NostrLinkType::Coordinate {
vec!["a".to_string(), self.to_tag_value()] vec!["a".to_string(), self.to_tag_value()]
@ -172,3 +185,18 @@ impl Display for NostrLink {
} }
} }
} }
impl TryInto<Coordinate> for NostrLink {
type Error = ();
fn try_into(self) -> Result<Coordinate, Self::Error> {
match self.hrp {
NostrLinkType::Coordinate => Ok(Coordinate::new(
Kind::from_u16(self.kind.unwrap() as u16),
PublicKey::from_slice(&self.author.unwrap()).unwrap(),
)
.identifier(self.id.to_string())),
_ => Err(()),
}
}
}

View File

@ -1,89 +0,0 @@
use crate::app::NativeLayerOps;
use crate::link::NostrLink;
use anyhow::Error;
use nostr_sdk::secp256k1::{Keypair, XOnlyPublicKey};
use nostr_sdk::{Event, EventBuilder, Keys, Kind, SecretKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use std::ops::Deref;
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum LoginKind {
PublicKey {
#[serde_as(as = "serde_with::hex::Hex")]
key: [u8; 32],
},
PrivateKey {
#[serde_as(as = "serde_with::hex::Hex")]
key: [u8; 32],
},
LoggedOut,
}
pub struct Login {
kind: LoginKind,
}
impl Login {
pub fn new() -> Self {
Self {
kind: LoginKind::LoggedOut,
}
}
pub fn load<T: NativeLayerOps>(&mut self, storage: &T) {
if let Some(k) = storage.get_obj("login") {
self.kind = k;
}
}
pub fn save<T: NativeLayerOps>(&mut self, storage: &mut T) {
storage.set_obj("login", &self.kind);
}
pub fn login(&mut self, kind: LoginKind) {
self.kind = kind;
}
pub fn is_logged_in(&self) -> bool {
!matches!(self.kind, LoginKind::LoggedOut)
}
pub fn public_key(&self) -> Option<[u8; 32]> {
match self.kind {
LoginKind::PublicKey { key } => Some(key),
LoginKind::PrivateKey { key } => {
// TODO: wow this is annoying
let sk = Keypair::from_seckey_slice(nostr_sdk::SECP256K1.deref(), key.as_slice())
.unwrap();
Some(XOnlyPublicKey::from_keypair(&sk).0.serialize())
}
_ => None,
}
}
fn secret_key(&self) -> Result<Keys, Error> {
if let LoginKind::PrivateKey { key } = self.kind {
Ok(Keys::new(SecretKey::from_slice(key.as_slice())?))
} else {
anyhow::bail!("No private key");
}
}
pub fn sign_event(&self, ev: UnsignedEvent) -> Result<Event, Error> {
let secret = self.secret_key()?;
ev.sign_with_keys(&secret).map_err(Error::new)
}
pub fn write_live_chat_msg(&self, link: &NostrLink, msg: &str) -> Result<Event, Error> {
if msg.len() == 0 {
return Err(anyhow::anyhow!("Empty message"));
}
let secret = self.secret_key()?;
EventBuilder::new(Kind::LiveEventMessage, msg)
.tag(Tag::parse(&link.to_tag())?)
.sign_with_keys(&secret)
.map_err(Error::new)
}
}

43
src/note_ref.rs Normal file
View File

@ -0,0 +1,43 @@
use nostrdb::{Note, NoteKey, QueryResult};
use std::cmp::Ordering;
#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash)]
pub struct NoteRef {
pub key: NoteKey,
pub created_at: u64,
}
impl NoteRef {
pub fn new(key: NoteKey, created_at: u64) -> Self {
NoteRef { key, created_at }
}
pub fn from_note(note: &Note<'_>) -> Self {
let created_at = note.created_at();
let key = note.key().expect("todo: implement NoteBuf");
NoteRef::new(key, created_at)
}
pub fn from_query_result(qr: QueryResult<'_>) -> Self {
NoteRef {
key: qr.note_key,
created_at: qr.note.created_at(),
}
}
}
impl Ord for NoteRef {
fn cmp(&self, other: &Self) -> Ordering {
match self.created_at.cmp(&other.created_at) {
Ordering::Equal => self.key.cmp(&other.key),
Ordering::Less => Ordering::Greater,
Ordering::Greater => Ordering::Less,
}
}
}
impl PartialOrd for NoteRef {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}

View File

@ -1,4 +1,4 @@
use nostr_sdk::util::hex; use nostr::prelude::hex;
use nostrdb::{NdbStr, Note, Tag}; use nostrdb::{NdbStr, Note, Tag};
pub trait NoteUtil { pub trait NoteUtil {
@ -9,7 +9,7 @@ pub trait NoteUtil {
F: Fn(Vec<NdbStr>) -> bool; F: Fn(Vec<NdbStr>) -> bool;
} }
impl<'a> NoteUtil for Note<'a> { impl NoteUtil for Note<'_> {
fn id_hex(&self) -> String { fn id_hex(&self) -> String {
hex::encode(self.id()) hex::encode(self.id())
} }
@ -64,6 +64,3 @@ impl<'a> Iterator for TagIterBorrow<'a> {
} }
} }
} }
#[derive(Eq, PartialEq)]
pub struct OwnedNote(pub u64);

View File

@ -2,11 +2,11 @@ use crate::link::NostrLink;
use nostrdb::Note; use nostrdb::Note;
use std::collections::HashMap; use std::collections::HashMap;
pub struct NoteStore<'a> { pub struct NotesView<'a> {
events: HashMap<String, &'a Note<'a>>, events: HashMap<String, &'a Note<'a>>,
} }
impl<'a> NoteStore<'a> { impl<'a> NotesView<'a> {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
events: HashMap::new(), events: HashMap::new(),
@ -26,7 +26,7 @@ impl<'a> NoteStore<'a> {
} }
pub fn add(&mut self, note: &'a Note<'a>) -> Option<&'a Note<'a>> { pub fn add(&mut self, note: &'a Note<'a>) -> Option<&'a Note<'a>> {
let k = Self::key(&note); let k = Self::key(note);
if let Some(v) = self.events.get(&k) { if let Some(v) = self.events.get(&k) {
if v.created_at() < note.created_at() { if v.created_at() < note.created_at() {
return self.events.insert(k, note); return self.events.insert(k, note);

30
src/profiles.rs Normal file
View File

@ -0,0 +1,30 @@
use std::collections::HashSet;
pub struct ProfileLoader {
queue: HashSet<[u8; 32]>,
fetched: HashSet<[u8; 32]>,
}
impl ProfileLoader {
pub fn new() -> Self {
Self {
queue: HashSet::new(),
fetched: HashSet::new(),
}
}
pub fn demand(&mut self, pubkey: [u8; 32]) {
if self.fetched.contains(&pubkey) {
return;
}
self.queue.insert(pubkey);
}
pub fn next(&mut self) -> Vec<[u8; 32]> {
let ret: Vec<[u8; 32]> = self.queue.drain().collect();
for p in ret.iter() {
self.fetched.insert(*p);
}
ret
}
}

View File

@ -1,13 +1,4 @@
<svg width="33" height="24" viewBox="0 0 33 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="33" height="24" viewBox="0 0 33 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4303_1419)"> <path d="M32.7877 2.41234C32.3558 1.36818 31.3439 0.691406 30.216 0.691406H10.6802C10.6738 0.691406 10.6673 0.691406 10.6609 0.691406C10.6545 0.691406 10.648 0.691406 10.6416 0.691406C6.54235 0.691406 3.21012 4.02369 3.21012 8.11654C3.21012 10.2693 4.13825 12.2738 5.70446 13.6661L0.812466 18.5581C0.0132476 19.3574 -0.225229 20.5498 0.206607 21.5875C0.638443 22.6316 1.65036 23.3084 2.77829 23.3084H22.314C22.3205 23.3084 22.3269 23.3084 22.3398 23.3084C22.3463 23.3084 22.3527 23.3084 22.3656 23.3084C26.4584 23.3084 29.7906 19.9761 29.7906 15.8833C29.7906 13.7305 28.8625 11.726 27.2963 10.3338L32.1882 5.44169C32.981 4.64245 33.2195 3.45005 32.7877 2.41234ZM2.71383 20.5498C2.6945 20.5047 2.70739 20.4918 2.73317 20.466L8.10856 15.0905L22.3914 20.1437C22.4043 20.1502 22.4236 20.1566 22.4365 20.1566C22.4558 20.163 22.4752 20.1695 22.4945 20.1824C22.5267 20.2017 22.5525 20.2339 22.5718 20.2726C22.5783 20.2791 22.5783 20.2855 22.5847 20.292C22.5912 20.3048 22.5912 20.3177 22.5912 20.3306C22.5912 20.3435 22.5976 20.3564 22.5976 20.3693C22.5976 20.4853 22.4687 20.5884 22.3269 20.5884H2.78473C2.75251 20.6013 2.73317 20.6013 2.71383 20.5498ZM25.208 19.6474C25.208 19.641 25.2015 19.6281 25.2015 19.6216C25.1757 19.5185 25.1435 19.4218 25.1048 19.3251C25.0984 19.3122 25.0984 19.2994 25.092 19.2865C25.0533 19.1833 25.0017 19.0867 24.9502 18.99C24.9437 18.9771 24.9308 18.9577 24.9244 18.9449C24.8148 18.7515 24.6859 18.571 24.5377 18.4034C24.5248 18.3905 24.5119 18.3777 24.499 18.3648C24.4216 18.2874 24.3443 18.2101 24.2605 18.1392C24.2476 18.1327 24.2347 18.1198 24.2283 18.1134C24.1509 18.0489 24.0672 17.9909 23.9769 17.9329C23.964 17.9265 23.9511 17.9136 23.9382 17.9071C23.848 17.8491 23.7513 17.7976 23.6547 17.746C23.6353 17.7331 23.6095 17.7267 23.5902 17.7138C23.4935 17.6687 23.3904 17.6235 23.2808 17.5913L16.6744 15.2516L9.03668 12.551L9.0109 12.5445C9.00446 12.5445 9.00446 12.5445 8.99801 12.5381C8.8111 12.4672 8.62418 12.3834 8.43727 12.2867C6.88395 11.4682 5.9236 9.86969 5.9236 8.11654C5.9236 6.58253 6.65836 5.2161 7.79918 4.35241C7.79918 4.35885 7.80563 4.37175 7.80563 4.37819C7.83141 4.47487 7.86364 4.57155 7.90231 4.66823C7.9152 4.70046 7.92809 4.73269 7.94098 4.76492C7.97321 4.83582 8.00543 4.90027 8.03766 4.97117C8.05055 5.0034 8.06989 5.03562 8.08278 5.06141C8.1279 5.1452 8.17946 5.22254 8.23747 5.29989C8.26325 5.33211 8.28258 5.36434 8.30836 5.39657C8.35348 5.45458 8.3986 5.51259 8.45016 5.56415C8.4695 5.58993 8.48883 5.61571 8.51462 5.63505C8.57907 5.70595 8.65641 5.7704 8.72731 5.83486C8.75309 5.86064 8.77887 5.87998 8.8111 5.89931C8.882 5.95732 8.9529 6.00888 9.03024 6.06045C9.04313 6.07334 9.05602 6.07978 9.07536 6.09267C9.16559 6.15068 9.26227 6.20225 9.35895 6.24736C9.38473 6.26026 9.41051 6.27315 9.43629 6.28604C9.53942 6.33116 9.64254 6.37627 9.74567 6.4085L24.0091 11.4553C24.0156 11.4553 24.022 11.4617 24.022 11.4617C24.209 11.5326 24.3894 11.61 24.5634 11.7066C26.1168 12.5252 27.0771 14.1237 27.0771 15.8768C27.0836 17.4173 26.3423 18.7837 25.208 19.6474ZM30.2675 3.52739L24.8922 8.90288C24.8793 8.89643 24.8599 8.88999 24.847 8.88354L10.6222 3.85611C10.6029 3.84967 10.5836 3.84322 10.5707 3.83677H10.5642C10.5449 3.83033 10.532 3.82388 10.5127 3.81099C10.5062 3.80455 10.4998 3.7981 10.4933 3.79166C10.4804 3.78521 10.474 3.77232 10.4675 3.76588C10.4611 3.75943 10.4547 3.74654 10.4547 3.74009C10.4482 3.7272 10.4418 3.71431 10.4353 3.70142C10.4353 3.69498 10.4289 3.68208 10.4289 3.66919C10.4289 3.6563 10.4224 3.63697 10.4224 3.61763C10.4224 3.60474 10.4289 3.59829 10.4289 3.5854C10.4482 3.48872 10.5642 3.40493 10.6867 3.40493H30.2289C30.2611 3.40493 30.2804 3.40493 30.2998 3.45005C30.3062 3.48872 30.2933 3.50806 30.2675 3.52739Z"
<path d="M4.45977 2.75527L4.44727 2.74902C4.44727 2.74902 4.45352 2.74902 4.45977 2.75527Z" fill="white"/> fill="white"/>
<path d="M8.97119 1.70361L9.00244 1.71611C8.98994 1.70986 8.98369 1.70361 8.97119 1.70361Z" fill="white"/>
<path d="M9.01006 1.71762C9.00381 1.71137 9.00381 1.71762 9.01006 1.71762V1.71762Z" fill="white"/>
<path d="M32.7877 2.41234C32.3558 1.36818 31.3439 0.691406 30.216 0.691406H10.6802C10.6738 0.691406 10.6673 0.691406 10.6609 0.691406C10.6545 0.691406 10.648 0.691406 10.6416 0.691406C6.54235 0.691406 3.21012 4.02369 3.21012 8.11654C3.21012 10.2693 4.13825 12.2738 5.70446 13.6661L0.812466 18.5581C0.0132476 19.3574 -0.225229 20.5498 0.206607 21.5875C0.638443 22.6316 1.65036 23.3084 2.77829 23.3084H22.314C22.3205 23.3084 22.3269 23.3084 22.3398 23.3084C22.3463 23.3084 22.3527 23.3084 22.3656 23.3084C26.4584 23.3084 29.7906 19.9761 29.7906 15.8833C29.7906 13.7305 28.8625 11.726 27.2963 10.3338L32.1882 5.44169C32.981 4.64245 33.2195 3.45005 32.7877 2.41234ZM2.71383 20.5498C2.6945 20.5047 2.70739 20.4918 2.73317 20.466L8.10856 15.0905L22.3914 20.1437C22.4043 20.1502 22.4236 20.1566 22.4365 20.1566C22.4558 20.163 22.4752 20.1695 22.4945 20.1824C22.5267 20.2017 22.5525 20.2339 22.5718 20.2726C22.5783 20.2791 22.5783 20.2855 22.5847 20.292C22.5912 20.3048 22.5912 20.3177 22.5912 20.3306C22.5912 20.3435 22.5976 20.3564 22.5976 20.3693C22.5976 20.4853 22.4687 20.5884 22.3269 20.5884H2.78473C2.75251 20.6013 2.73317 20.6013 2.71383 20.5498ZM25.208 19.6474C25.208 19.641 25.2015 19.6281 25.2015 19.6216C25.1757 19.5185 25.1435 19.4218 25.1048 19.3251C25.0984 19.3122 25.0984 19.2994 25.092 19.2865C25.0533 19.1833 25.0017 19.0867 24.9502 18.99C24.9437 18.9771 24.9308 18.9577 24.9244 18.9449C24.8148 18.7515 24.6859 18.571 24.5377 18.4034C24.5248 18.3905 24.5119 18.3777 24.499 18.3648C24.4216 18.2874 24.3443 18.2101 24.2605 18.1392C24.2476 18.1327 24.2347 18.1198 24.2283 18.1134C24.1509 18.0489 24.0672 17.9909 23.9769 17.9329C23.964 17.9265 23.9511 17.9136 23.9382 17.9071C23.848 17.8491 23.7513 17.7976 23.6547 17.746C23.6353 17.7331 23.6095 17.7267 23.5902 17.7138C23.4935 17.6687 23.3904 17.6235 23.2808 17.5913L16.6744 15.2516L9.03668 12.551L9.0109 12.5445C9.00446 12.5445 9.00446 12.5445 8.99801 12.5381C8.8111 12.4672 8.62418 12.3834 8.43727 12.2867C6.88395 11.4682 5.9236 9.86969 5.9236 8.11654C5.9236 6.58253 6.65836 5.2161 7.79918 4.35241C7.79918 4.35885 7.80563 4.37175 7.80563 4.37819C7.83141 4.47487 7.86364 4.57155 7.90231 4.66823C7.9152 4.70046 7.92809 4.73269 7.94098 4.76492C7.97321 4.83582 8.00543 4.90027 8.03766 4.97117C8.05055 5.0034 8.06989 5.03562 8.08278 5.06141C8.1279 5.1452 8.17946 5.22254 8.23747 5.29989C8.26325 5.33211 8.28258 5.36434 8.30836 5.39657C8.35348 5.45458 8.3986 5.51259 8.45016 5.56415C8.4695 5.58993 8.48883 5.61571 8.51462 5.63505C8.57907 5.70595 8.65641 5.7704 8.72731 5.83486C8.75309 5.86064 8.77887 5.87998 8.8111 5.89931C8.882 5.95732 8.9529 6.00888 9.03024 6.06045C9.04313 6.07334 9.05602 6.07978 9.07536 6.09267C9.16559 6.15068 9.26227 6.20225 9.35895 6.24736C9.38473 6.26026 9.41051 6.27315 9.43629 6.28604C9.53942 6.33116 9.64254 6.37627 9.74567 6.4085L24.0091 11.4553C24.0156 11.4553 24.022 11.4617 24.022 11.4617C24.209 11.5326 24.3894 11.61 24.5634 11.7066C26.1168 12.5252 27.0771 14.1237 27.0771 15.8768C27.0836 17.4173 26.3423 18.7837 25.208 19.6474ZM30.2675 3.52739L24.8922 8.90288C24.8793 8.89643 24.8599 8.88999 24.847 8.88354L10.6222 3.85611C10.6029 3.84967 10.5836 3.84322 10.5707 3.83677H10.5642C10.5449 3.83033 10.532 3.82388 10.5127 3.81099C10.5062 3.80455 10.4998 3.7981 10.4933 3.79166C10.4804 3.78521 10.474 3.77232 10.4675 3.76588C10.4611 3.75943 10.4547 3.74654 10.4547 3.74009C10.4482 3.7272 10.4418 3.71431 10.4353 3.70142C10.4353 3.69498 10.4289 3.68208 10.4289 3.66919C10.4289 3.6563 10.4224 3.63697 10.4224 3.61763C10.4224 3.60474 10.4289 3.59829 10.4289 3.5854C10.4482 3.48872 10.5642 3.40493 10.6867 3.40493H30.2289C30.2611 3.40493 30.2804 3.40493 30.2998 3.45005C30.3062 3.48872 30.2933 3.50806 30.2675 3.52739Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_4303_1419">
<rect width="33" height="22.617" fill="white" transform="translate(0 0.691406)"/>
</clipPath>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -1,50 +1,44 @@
use crate::note_store::NoteStore; use crate::note_ref::NoteRef;
use crate::note_util::OwnedNote; use crate::note_view::NotesView;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper};
use crate::stream_info::{StreamInfo, StreamStatus}; use crate::stream_info::{StreamInfo, StreamStatus};
use crate::sub::SubRef;
use crate::widgets; use crate::widgets;
use crate::widgets::NostrWidget; use crate::widgets::{sub_or_poll, NostrWidget};
use egui::{Id, Response, RichText, ScrollArea, Ui, Widget}; use egui::{Id, Response, RichText, ScrollArea, Ui};
use nostrdb::{Filter, Note, NoteKey, Transaction}; use nostrdb::{Filter, Note};
use std::collections::HashSet;
pub struct HomePage { pub struct HomePage {
sub: SubWrapper, events: HashSet<NoteRef>,
events: Vec<OwnedNote>, sub: Option<SubRef>,
} }
impl HomePage { impl HomePage {
pub fn new(ndb: &NDBWrapper, tx: &Transaction) -> Self { pub fn new() -> Self {
let filter = [Filter::new().kinds([30_311]).limit(100).build()];
let (sub, events) = ndb.subscribe_with_results("home-page", &filter, tx, 1000);
Self { Self {
sub, events: HashSet::new(),
events: events sub: None,
.iter()
.map(|e| OwnedNote(e.note_key.as_u64()))
.collect(),
} }
} }
fn get_filters() -> Vec<Filter> {
vec![Filter::new().kinds([30_311, 30_313]).limit(100).build()]
}
} }
impl NostrWidget for HomePage { impl NostrWidget for HomePage {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
let new_notes = services.ndb.poll(&self.sub, 100);
new_notes
.iter()
.for_each(|n| self.events.push(OwnedNote(n.as_u64())));
let events: Vec<Note<'_>> = self
.events
.iter()
.map(|n| services.ndb.get_note_by_key(services.tx, NoteKey::new(n.0)))
.map_while(|f| f.ok())
.filter(|f| f.can_play())
.collect();
ScrollArea::vertical() ScrollArea::vertical()
.show(ui, |ui| { .show(ui, |ui| {
let events_live = NoteStore::from_vec( let events: Vec<Note> = self
.events
.iter()
.filter_map(|n| services.ctx.ndb.get_note_by_key(services.tx, n.key).ok())
.filter(|e| e.can_play())
.collect();
let events_live = NotesView::from_vec(
events events
.iter() .iter()
.filter(|r| matches!(r.status(), StreamStatus::Live)) .filter(|r| matches!(r.status(), StreamStatus::Live))
@ -53,13 +47,12 @@ impl NostrWidget for HomePage {
if events_live.len() > 0 { if events_live.len() > 0 {
widgets::StreamList::new( widgets::StreamList::new(
Id::new("live-streams"), Id::new("live-streams"),
&events_live, events_live,
services,
Some(RichText::new("Live").size(32.0)), Some(RichText::new("Live").size(32.0)),
) )
.ui(ui); .render(ui, services);
} }
let events_planned = NoteStore::from_vec( let events_planned = NotesView::from_vec(
events events
.iter() .iter()
.filter(|r| matches!(r.status(), StreamStatus::Planned)) .filter(|r| matches!(r.status(), StreamStatus::Planned))
@ -68,29 +61,45 @@ impl NostrWidget for HomePage {
if events_planned.len() > 0 { if events_planned.len() > 0 {
widgets::StreamList::new( widgets::StreamList::new(
Id::new("planned-streams"), Id::new("planned-streams"),
&events_planned, events_planned,
services,
Some(RichText::new("Planned").size(32.0)), Some(RichText::new("Planned").size(32.0)),
) )
.ui(ui); .render(ui, services);
} }
let events_ended = NoteStore::from_vec( let events_ended = NotesView::from_vec(
events events
.iter() .iter()
.filter(|r| matches!(r.status(), StreamStatus::Ended)) .filter(|r| {
matches!(r.status(), StreamStatus::Ended)
&& if let Some(r) = r.recording() {
r.len() > 0
} else {
false
}
})
.collect(), .collect(),
); );
if events_ended.len() > 0 { if events_ended.len() > 0 {
widgets::StreamList::new( widgets::StreamList::new(
Id::new("ended-streams"), Id::new("ended-streams"),
&events_ended, events_ended,
services,
Some(RichText::new("Ended").size(32.0)), Some(RichText::new("Ended").size(32.0)),
) )
.ui(ui); .render(ui, services);
} }
ui.response() ui.response()
}) })
.inner .inner
} }
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
sub_or_poll(
services.ctx.ndb,
services.tx,
services.ctx.pool,
&mut self.events,
&mut self.sub,
Self::get_filters(),
)
}
} }

View File

@ -1,8 +1,8 @@
use crate::login::LoginKind; use crate::route::{RouteServices, RouteType};
use crate::route::{RouteServices, Routes};
use crate::widgets::{Button, NativeTextInput, NostrWidget}; use crate::widgets::{Button, NativeTextInput, NostrWidget};
use egui::{Color32, Frame, Margin, Response, RichText, Ui}; use egui::{Color32, Frame, Margin, Response, RichText, Ui};
use nostr_sdk::util::hex; use nostr::prelude::hex;
use nostr::SecretKey;
pub struct LoginPage { pub struct LoginPage {
key: String, key: String,
@ -19,7 +19,7 @@ impl LoginPage {
} }
impl NostrWidget for LoginPage { impl NostrWidget for LoginPage {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
Frame::none() Frame::none()
.inner_margin(Margin::same(12.)) .inner_margin(Margin::same(12.))
.show(ui, |ui| { .show(ui, |ui| {
@ -27,30 +27,53 @@ impl NostrWidget for LoginPage {
ui.spacing_mut().item_spacing.y = 8.; ui.spacing_mut().item_spacing.y = 8.;
ui.label(RichText::new("Login").size(32.)); ui.label(RichText::new("Login").size(32.));
let mut input = NativeTextInput::new(&mut self.key).with_hint_text("npub/nsec"); let input = NativeTextInput::new(&mut self.key).with_hint_text("npub/nsec");
input.render(ui, services); ui.add(input);
if Button::new().show(ui, |ui| ui.label("Login")).clicked() { if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
if let Ok((hrp, key)) = bech32::decode(&self.key) { if let Ok((hrp, key)) = bech32::decode(&self.key) {
match hrp.to_lowercase().as_str() { match hrp.to_lowercase().as_str() {
"nsec" => { "nsec" => {
services.login.login(LoginKind::PrivateKey { let mut ids = services.ctx.accounts.add_account(
key: key.as_slice().try_into().unwrap(), enostr::Keypair::from_secret(
}); SecretKey::from_slice(key.as_slice()).unwrap(),
services.navigate(Routes::HomePage); ),
);
ids.process_action(
services.ctx.unknown_ids,
services.ctx.ndb,
services.tx,
);
services.ctx.accounts.select_account(0);
services.navigate(RouteType::HomePage);
} }
"npub" | "nprofile" => { "npub" | "nprofile" => {
services.login.login(LoginKind::PublicKey { let mut ids =
key: key.as_slice().try_into().unwrap(), services.ctx.accounts.add_account(enostr::Keypair::new(
}); enostr::Pubkey::new(key.as_slice().try_into().unwrap()),
services.navigate(Routes::HomePage); None,
));
ids.process_action(
services.ctx.unknown_ids,
services.ctx.ndb,
services.tx,
);
services.ctx.accounts.select_account(0);
services.navigate(RouteType::HomePage);
} }
_ => {} _ => {}
} }
} else if let Ok(pk) = hex::decode(&self.key) { } else if let Ok(pk) = hex::decode(&self.key) {
if let Ok(pk) = pk.as_slice().try_into() { if let Ok(pk) = pk.as_slice().try_into() {
services.login.login(LoginKind::PublicKey { key: pk }); let mut ids = services.ctx.accounts.add_account(
services.navigate(Routes::HomePage); enostr::Keypair::new(enostr::Pubkey::new(pk), None),
);
ids.process_action(
services.ctx.unknown_ids,
services.ctx.ndb,
services.tx,
);
services.navigate(RouteType::HomePage);
return; return;
} }
} }
@ -64,4 +87,8 @@ impl NostrWidget for LoginPage {
}) })
.inner .inner
} }
fn update(&mut self, _services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
Ok(())
}
} }

View File

@ -1,34 +1,46 @@
use crate::app::NativeLayerOps;
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::login::Login; use crate::services::ffmpeg_loader::FfmpegLoader;
use crate::note_util::OwnedNote; use crate::widgets::PlaceholderRect;
use crate::route::home::HomePage; use anyhow::{anyhow, bail};
use crate::route::login::LoginPage; use egui::load::SizedTexture;
use crate::route::stream::StreamPage; use egui::{vec2, Context, Id, Image, ImageSource, TextureHandle, Ui, Vec2};
use crate::services::image_cache::ImageCache; use egui_video::ffmpeg_rs_raw::Transcoder;
use crate::services::ndb_wrapper::NDBWrapper; use ehttp::Response;
use crate::widgets::{Header, NostrWidget}; use enostr::EventClientMessage;
use egui::{Context, Response, Ui}; use lnurl::lightning_address::LightningAddress;
use egui_inbox::{UiInbox, UiInboxSender}; use lnurl::pay::PayResponse;
use lnurl::LnUrlResponse;
use log::{info, warn}; use log::{info, warn};
use nostr_sdk::{Client, Event, JsonUtil}; use nostr::{serde_json, Event, EventBuilder, JsonUtil, Keys, Kind, SecretKey, Tag};
use nostrdb::{Ndb, Transaction}; use nostrdb::{NdbProfile, NoteKey, Transaction};
use std::path::PathBuf; use notedeck::{AppContext, ImageCache};
use poll_promise::Promise;
use std::collections::HashMap;
use std::path::Path;
use std::sync::mpsc;
use std::task::Poll;
mod home; mod home;
mod login; mod login;
mod profile;
mod stream; mod stream;
pub mod page {
pub use super::home::HomePage;
pub use super::login::LoginPage;
pub use super::profile::ProfilePage;
pub use super::stream::StreamPage;
}
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum Routes { pub enum RouteType {
HomePage, HomePage,
EventPage { EventPage {
link: NostrLink, link: NostrLink,
event: Option<OwnedNote>, event: Option<NoteKey>,
}, },
ProfilePage { ProfilePage {
link: NostrLink, link: NostrLink,
profile: Option<OwnedNote>,
}, },
LoginPage, LoginPage,
@ -38,155 +50,223 @@ pub enum Routes {
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum RouteAction { pub enum RouteAction {
ShowKeyboard, DemandProfile([u8; 32]),
HideKeyboard,
} }
pub struct Router<T: NativeLayerOps> { pub struct RouteServices<'a, 'ctx> {
current: Routes, pub egui: Context,
current_widget: Option<Box<dyn NostrWidget>>, pub tx: &'a Transaction,
router: UiInbox<Routes>, pub ctx: &'a mut AppContext<'ctx>,
ctx: Context, router: mpsc::Sender<RouteType>,
ndb: NDBWrapper, fetch: &'a mut HashMap<String, Promise<ehttp::Result<Response>>>,
login: Login,
client: Client,
image_cache: ImageCache,
native_layer: T,
} }
impl<T: NativeLayerOps> Drop for Router<T> { impl<'a, 'ctx> RouteServices<'a, 'ctx> {
fn drop(&mut self) {
self.login.save(&mut self.native_layer)
}
}
impl<T: NativeLayerOps> Router<T> {
pub fn new( pub fn new(
data_path: PathBuf, egui: Context,
ctx: Context, tx: &'a Transaction,
client: Client, ctx: &'a mut AppContext<'ctx>,
ndb: Ndb, router: mpsc::Sender<RouteType>,
native_layer: T, fetch: &'a mut HashMap<String, Promise<ehttp::Result<Response>>>,
) -> Self { ) -> Self {
let mut login = Login::new();
login.load(&native_layer);
Self { Self {
current: Routes::HomePage, egui,
current_widget: None, tx,
router: UiInbox::new(), ctx,
ctx: ctx.clone(), router,
ndb: NDBWrapper::new(ctx.clone(), ndb.clone(), client.clone()), fetch,
client,
login,
image_cache: ImageCache::new(data_path, ctx.clone()),
native_layer,
} }
} }
fn load_widget(&mut self, route: Routes, tx: &Transaction) { pub fn navigate(&self, route: RouteType) {
match &route { self.router.send(route).expect("route send failed");
Routes::HomePage => { self.egui.request_repaint();
let w = HomePage::new(&self.ndb, tx);
self.current_widget = Some(Box::new(w));
}
Routes::EventPage { link, .. } => {
let w = StreamPage::new_from_link(&self.ndb, tx, link.clone());
self.current_widget = Some(Box::new(w));
}
Routes::LoginPage => {
let w = LoginPage::new();
self.current_widget = Some(Box::new(w));
}
_ => warn!("Not implemented"),
}
self.current = route;
}
pub fn show(&mut self, ui: &mut Ui) -> Response {
let tx = self.ndb.start_transaction();
// handle app state changes
let q = self.router.read(ui);
for r in q {
if let Routes::Action(a) = r {
match a {
RouteAction::ShowKeyboard => self.native_layer.show_keyboard(),
RouteAction::HideKeyboard => self.native_layer.hide_keyboard(),
_ => info!("Not implemented"),
}
} else {
self.load_widget(r, &tx);
}
}
// load homepage on start
if self.current_widget.is_none() {
self.load_widget(Routes::HomePage, &tx);
}
let mut svc = RouteServices {
context: self.ctx.clone(),
router: self.router.sender(),
client: self.client.clone(),
ndb: &self.ndb,
tx: &tx,
login: &mut self.login,
img_cache: &self.image_cache,
};
// display app
ui.vertical(|ui| {
Header::new().render(ui, &mut svc);
if let Some(w) = self.current_widget.as_mut() {
w.render(ui, &mut svc)
} else {
ui.label("No widget")
}
})
.response
}
}
pub struct RouteServices<'a> {
pub context: Context, //cloned
pub router: UiInboxSender<Routes>, //cloned
pub client: Client,
pub ndb: &'a NDBWrapper, //ref
pub tx: &'a Transaction, //ref
pub login: &'a mut Login, //ref
pub img_cache: &'a ImageCache, //ref
}
impl<'a> RouteServices<'a> {
pub fn navigate(&self, route: Routes) {
if let Err(e) = self.router.send(route) {
warn!("Failed to navigate");
}
} }
pub fn action(&self, route: RouteAction) { pub fn action(&self, route: RouteAction) {
if let Err(e) = self.router.send(Routes::Action(route)) { self.router
warn!("Failed to navigate"); .send(RouteType::Action(route))
} .expect("route send failed");
self.egui.request_repaint();
} }
pub fn broadcast_event(&self, event: Event) { pub fn broadcast_event(&mut self, event: Event) {
let client = self.client.clone();
let ev_json = event.as_json(); let ev_json = event.as_json();
if let Err(e) = self.ndb.submit_event(&ev_json) { if let Err(e) = self.ctx.ndb.process_event(&ev_json) {
warn!("Failed to submit event {}", e); warn!("Failed to submit event {}", e);
} }
tokio::spawn(async move { self.ctx
match client.send_event(event).await { .pool
Ok(e) => { .send(&enostr::ClientMessage::Event(EventClientMessage {
info!("Broadcast event: {:?}", e) note_json: ev_json,
}));
}
/// Load/Fetch profiles
pub fn profile(&self, pk: &[u8; 32]) -> Option<NdbProfile<'a>> {
let p = self
.ctx
.ndb
.get_profile_by_pubkey(self.tx, pk)
.map(|p| p.record().profile())
.ok()
.flatten();
if p.is_none() {
self.action(RouteAction::DemandProfile(*pk));
}
p
}
/// Create a poll_promise fetch
pub fn fetch(&mut self, url: &str) -> Poll<&ehttp::Result<Response>> {
if !self.fetch.contains_key(url) {
let (sender, promise) = Promise::new();
let request = ehttp::Request::get(url);
let ctx = self.egui.clone();
ehttp::fetch(request, move |response| {
sender.send(response);
ctx.request_repaint();
});
info!("Fetching {}", url);
self.fetch.insert(url.to_string(), promise);
}
self.fetch.get(url).expect("fetch").poll()
}
pub fn fetch_lnurlp(&mut self, pubkey: &[u8; 32]) -> anyhow::Result<Poll<PayResponse>> {
let target = self
.profile(pubkey)
.and_then(|p| p.lud16())
.ok_or(anyhow!("No lightning address found"))?;
let addr = LightningAddress::new(target)?;
match self.fetch(&addr.lnurlp_url()) {
Poll::Ready(Ok(r)) => {
if r.ok {
let rsp: PayResponse = serde_json::from_slice(&r.bytes)?;
Ok(Poll::Ready(rsp))
} else {
bail!("Invalid response code {}", r.status);
} }
Err(e) => warn!("Failed to broadcast event: {:?}", e),
} }
}); Poll::Ready(Err(e)) => Err(anyhow!("{}", e)),
Poll::Pending => Ok(Poll::Pending),
}
}
pub fn write_live_chat_msg(&self, link: &NostrLink, msg: &str) -> Option<Event> {
if msg.is_empty() {
return None;
}
if let Some(key) = self.current_account_keys() {
EventBuilder::new(Kind::LiveEventMessage, msg)
.tag(Tag::parse(link.to_tag()).unwrap())
.sign_with_keys(&key)
.ok()
} else {
None
}
}
pub fn current_account_keys(&self) -> Option<Keys> {
self.ctx
.accounts
.get_selected_account()
.and_then(|acc| acc.secret_key.as_ref().map(|k| Keys::new(k.clone())))
}
/// Simple wrapper around egui temp data
pub fn get<T: Clone + 'static>(&self, k: &str) -> Option<T> {
let id = Id::new(k);
self.egui.data(|d| d.get_temp(id))
}
/// Simple wrapper around egui temp data
pub fn set<T: Clone + Send + Sync + 'static>(&mut self, k: &str, v: T) {
self.egui.data_mut(|d| d.insert_temp(Id::new(k), v));
} }
} }
const LOGO_BYTES: &[u8] = include_bytes!("../resources/logo.svg");
pub fn image_from_cache<'a>(
img_cache: &mut ImageCache,
ui: &Ui,
url: &str,
size: Option<Vec2>,
) -> Option<Image<'a>> {
if url.len() == 0 {
return None;
}
let cache_key = if let Some(s) = size {
format!("{}:{}", url, s)
} else {
url.to_string()
};
if let Some(promise) = img_cache.map().get(&cache_key) {
match promise.poll() {
Poll::Ready(Ok(t)) => Some(Image::new(SizedTexture::from_handle(t))),
_ => None,
}
} else {
let fetch = fetch_img(img_cache, ui.ctx(), url, size);
img_cache.map_mut().insert(cache_key.clone(), fetch);
None
}
}
fn fetch_img(
img_cache: &ImageCache,
ctx: &Context,
url: &str,
size: Option<Vec2>,
) -> Promise<notedeck::Result<TextureHandle>> {
let name = ImageCache::key(url);
let dst_path = img_cache.cache_dir.join(&name);
if dst_path.exists() {
let ctx = ctx.clone();
Promise::spawn_thread("load_from_disk", move || {
info!("Loading image from disk: {}", dst_path.display());
match FfmpegLoader::new().load_image(dst_path, size) {
Ok(img) => {
ctx.request_repaint();
ctx.forget_image(&name);
Ok(ctx.load_texture(&name, img, Default::default()))
}
Err(e) => Err(notedeck::Error::Generic(e.to_string())),
}
})
} else {
let url = url.to_string();
let ctx = ctx.clone();
Promise::spawn_thread("load_from_net", move || {
let img = match fetch_img_from_net(&url).block_and_take() {
Ok(img) => img,
Err(e) => return Err(notedeck::Error::Generic(e.to_string())),
};
std::fs::create_dir_all(&dst_path.parent().unwrap()).unwrap();
std::fs::write(&dst_path, &img.bytes).unwrap();
info!("Loading image from net: {}", &url);
match FfmpegLoader::new().load_image(dst_path, size) {
Ok(img) => {
ctx.request_repaint();
ctx.forget_image(&name);
Ok(ctx.load_texture(&name, img, Default::default()))
}
Err(e) => Err(notedeck::Error::Generic(e.to_string())),
}
})
}
}
fn fetch_img_from_net(url: &str) -> Promise<ehttp::Result<Response>> {
let (sender, promise) = Promise::new();
let request = ehttp::Request::get(url);
info!("Downloaded image: {}", url);
ehttp::fetch(request, move |response| {
sender.send(response);
});
promise
}

91
src/route/profile.rs Normal file
View File

@ -0,0 +1,91 @@
use crate::note_ref::NoteRef;
use crate::note_view::NotesView;
use crate::route::{image_from_cache, RouteServices};
use crate::sub::SubRef;
use crate::theme::{MARGIN_DEFAULT, ROUNDING_DEFAULT};
use crate::widgets::{sub_or_poll, NostrWidget, PlaceholderRect, Profile, StreamList};
use egui::{vec2, Frame, Id, Response, ScrollArea, Ui, Widget};
use nostrdb::{Filter, Note};
use std::collections::HashSet;
pub struct ProfilePage {
pubkey: [u8; 32],
events: HashSet<NoteRef>,
sub: Option<SubRef>,
}
impl ProfilePage {
pub fn new(pubkey: [u8; 32]) -> Self {
Self {
pubkey,
events: HashSet::new(),
sub: None,
}
}
}
impl NostrWidget for ProfilePage {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
let profile = services.profile(&self.pubkey);
ScrollArea::vertical().show(ui, |ui| {
Frame::default()
.inner_margin(MARGIN_DEFAULT)
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
if let Some(banner) = profile.map(|p| p.banner()).flatten() {
if let Some(img) = image_from_cache(
&mut services.ctx.img_cache,
ui,
banner,
Some(vec2(ui.available_width(), 360.0)),
) {
img.rounding(ROUNDING_DEFAULT).ui(ui);
} else {
PlaceholderRect.ui(ui);
}
} else {
ui.add(PlaceholderRect);
}
Profile::from_profile(&self.pubkey, &profile)
.size(88.0)
.render(ui, services);
});
let events: Vec<Note> = self
.events
.iter()
.filter_map(|e| services.ctx.ndb.get_note_by_key(services.tx, e.key).ok())
.collect();
StreamList::new(
Id::from("profile-streams"),
NotesView::from_vec(events.iter().collect()),
Some("Past Streams"),
)
.render(ui, services);
});
ui.response()
}
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
sub_or_poll(
services.ctx.ndb,
services.tx,
services.ctx.pool,
&mut self.events,
&mut self.sub,
vec![
Filter::new()
.kinds([30_311])
.authors(&[self.pubkey])
.build(),
Filter::new()
.kinds([30_311])
.pubkeys(&[self.pubkey])
.build(),
],
)
}
}

View File

@ -1,42 +1,50 @@
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::note_util::OwnedNote;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper};
use crate::stream_info::StreamInfo;
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_800, ROUNDING_DEFAULT}; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_800, ROUNDING_DEFAULT};
use crate::widgets::{Chat, NostrWidget, PlaceholderRect, StreamPlayer, StreamTitle, WriteChat}; use crate::widgets::{
use egui::{vec2, Align, Frame, Layout, Response, Stroke, Ui, Vec2, Widget}; sub_or_poll, Chat, NostrWidget, PlaceholderRect, StreamPlayer, StreamTitle, WriteChat,
use nostrdb::{Filter, Note, NoteKey, Transaction}; };
use egui::{vec2, Align, Frame, Layout, Response, ScrollArea, Stroke, Ui, Vec2, Widget};
use nostrdb::{Filter, Note};
use crate::note_ref::NoteRef;
use crate::stream_info::StreamInfo;
use crate::sub::SubRef;
use std::borrow::Borrow; use std::borrow::Borrow;
use std::collections::HashSet;
pub struct StreamPage { pub struct StreamPage {
link: NostrLink, link: NostrLink,
event: Option<OwnedNote>,
player: Option<StreamPlayer>, player: Option<StreamPlayer>,
chat: Option<Chat>, chat: Option<Chat>,
sub: SubWrapper,
new_msg: WriteChat, new_msg: WriteChat,
events: HashSet<NoteRef>,
sub: Option<SubRef>,
} }
impl StreamPage { impl StreamPage {
pub fn new_from_link(ndb: &NDBWrapper, tx: &Transaction, link: NostrLink) -> Self { pub fn new_from_link(link: NostrLink) -> Self {
let f: Filter = link.borrow().try_into().unwrap();
let f = [f.limit_mut(1)];
let (sub, events) = ndb.subscribe_with_results("streams", &f, tx, 1);
Self { Self {
link: link.clone(), new_msg: WriteChat::new(link.clone()),
sub, link,
event: events.first().map(|n| OwnedNote(n.note_key.as_u64())),
chat: None, chat: None,
player: None, player: None,
new_msg: WriteChat::new(link), events: HashSet::new(),
sub: None,
} }
} }
fn get_filters(&self) -> Vec<Filter> {
let f: Filter = self.link.borrow().try_into().unwrap();
vec![f.limit_mut(1)]
}
fn render_mobile( fn render_mobile(
&mut self, &mut self,
event: &Note<'_>, event: &Note<'_>,
ui: &mut Ui, ui: &mut Ui,
services: &mut RouteServices<'_>, services: &mut RouteServices<'_, '_>,
) -> Response { ) -> Response {
let chat_h = 60.0; let chat_h = 60.0;
let w = ui.available_width(); let w = ui.available_width();
@ -56,7 +64,7 @@ impl StreamPage {
ui.add(PlaceholderRect) ui.add(PlaceholderRect)
} }
}); });
StreamTitle::new(&event).render(ui, services); StreamTitle::new(event).render(ui, services);
if let Some(c) = self.chat.as_mut() { if let Some(c) = self.chat.as_mut() {
ui.allocate_ui( ui.allocate_ui(
@ -80,97 +88,113 @@ impl StreamPage {
&mut self, &mut self,
event: &Note<'_>, event: &Note<'_>,
ui: &mut Ui, ui: &mut Ui,
services: &mut RouteServices<'_>, services: &mut RouteServices<'_, '_>,
) -> Response { ) -> Response {
let max_h = ui.available_height(); let max_h = ui.available_height();
let chat_w = 450.0; let chat_w = 450.0;
let video_width = ui.available_width() - chat_w; let video_width = ui.available_width() - chat_w;
let video_height = max_h.min((video_width / 16.0) * 9.0); let video_height = max_h.min((video_width / 16.0) * 9.0);
ui.with_layout( ui.horizontal(|ui| {
Layout::left_to_right(Align::TOP).with_main_justify(true), ui.allocate_ui_with_layout(
|ui| { vec2(video_width, max_h),
ui.vertical(|ui| { Layout::top_down_justified(Align::Min),
ui.allocate_ui(vec2(video_width, video_height), |ui| { |ui| {
ScrollArea::vertical().show(ui, |ui| {
if let Some(player) = &mut self.player { if let Some(player) = &mut self.player {
player.ui(ui) ui.add_sized(vec2(video_width, video_height), player);
} else { } else {
ui.add(PlaceholderRect) ui.add_sized(vec2(video_width, video_height), PlaceholderRect);
} }
ui.add_space(10.);
StreamTitle::new(event).render(ui, services);
}); });
ui.add_space(10.); },
StreamTitle::new(&event).render(ui, services); );
}); ui.allocate_ui_with_layout(
ui.allocate_ui_with_layout( vec2(chat_w, max_h),
vec2(chat_w, max_h), Layout::top_down_justified(Align::Min),
Layout::top_down_justified(Align::Min), |ui| {
|ui| { Frame::none()
Frame::none() .stroke(Stroke::new(1.0, NEUTRAL_800))
.stroke(Stroke::new(1.0, NEUTRAL_800)) .outer_margin(MARGIN_DEFAULT)
.outer_margin(MARGIN_DEFAULT) .rounding(ROUNDING_DEFAULT)
.rounding(ROUNDING_DEFAULT) .show(ui, |ui| {
.show(ui, |ui| { let chat_h = 60.0;
let chat_h = 60.0; if let Some(c) = self.chat.as_mut() {
if let Some(c) = self.chat.as_mut() { ui.allocate_ui(
ui.allocate_ui( vec2(ui.available_width(), ui.available_height() - chat_h),
vec2(ui.available_width(), ui.available_height() - chat_h), |ui| {
|ui| { c.render(ui, services);
c.render(ui, services); },
}, );
); } else {
} else { ui.label("Loading..");
ui.label("Loading.."); }
} if ui.available_height().is_finite() {
if ui.available_height().is_finite() { ui.add_space(ui.available_height() - chat_h);
ui.add_space(ui.available_height() - chat_h); }
} self.new_msg.render(ui, services);
self.new_msg.render(ui, services); });
}); },
}, );
); });
},
);
ui.response() ui.response()
} }
} }
impl NostrWidget for StreamPage { impl NostrWidget for StreamPage {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
let poll = services.ndb.poll(&self.sub, 1); let events: Vec<Note> = self
if let Some(k) = poll.first() { .events
self.event = Some(OwnedNote(k.as_u64())) .iter()
} .map_while(|e| services.ctx.ndb.get_note_by_key(services.tx, e.key).ok())
.collect();
let event = if let Some(k) = &self.event { if let Some(event) = events.first() {
services if self.player.is_none() {
.ndb match event.kind() {
.get_note_by_key(services.tx, NoteKey::new(k.0)) 30_311 => {
.ok() if let Some(u) = event.streaming().or(event.recording()) {
} else { let p = StreamPlayer::new(ui.ctx(), &u.to_string());
None self.player = Some(p);
}; }
if let Some(event) = event { }
if let Some(stream) = event.stream() { _ => {}
if self.player.is_none() { };
let p = StreamPlayer::new(ui.ctx(), &stream.to_string());
self.player = Some(p);
}
} }
if self.chat.is_none() { if self.chat.is_none() {
let ok = OwnedNote(event.key().unwrap().as_u64()); let ok = event.key().unwrap();
let chat = Chat::new(self.link.clone(), ok, services.ndb, services.tx); let chat = Chat::new(self.link.clone(), ok);
self.chat = Some(chat); self.chat = Some(chat);
} }
if ui.available_width() < 720.0 { if ui.available_width() < 720.0 {
self.render_mobile(&event, ui, services) self.render_mobile(event, ui, services)
} else { } else {
self.render_desktop(&event, ui, services) self.render_desktop(event, ui, services)
} }
} else { } else {
ui.label("Loading..") ui.label("Loading..")
} }
} }
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
let filters = self.get_filters();
sub_or_poll(
services.ctx.ndb,
services.tx,
services.ctx.pool,
&mut self.events,
&mut self.sub,
filters,
)?;
if let Some(c) = self.chat.as_mut() {
c.update(services)?;
}
Ok(())
}
} }

View File

@ -1,5 +1,5 @@
use anyhow::Error; use anyhow::Error;
use egui::ColorImage; use egui::{ColorImage, Vec2};
use egui_video::ffmpeg_rs_raw::{get_frame_from_hw, Decoder, Demuxer, Scaler}; use egui_video::ffmpeg_rs_raw::{get_frame_from_hw, Decoder, Demuxer, Scaler};
use egui_video::ffmpeg_sys_the_third::{av_frame_free, av_packet_free, AVPixelFormat}; use egui_video::ffmpeg_sys_the_third::{av_frame_free, av_packet_free, AVPixelFormat};
use egui_video::media_player::video_frame_to_image; use egui_video::media_player::video_frame_to_image;
@ -12,21 +12,25 @@ impl FfmpegLoader {
Self {} Self {}
} }
pub fn load_image(&self, path: PathBuf) -> Result<ColorImage, Error> { pub fn load_image(&self, path: PathBuf, size: Option<Vec2>) -> Result<ColorImage, Error> {
let demux = Demuxer::new(path.to_str().unwrap())?; let demux = Demuxer::new(path.to_str().unwrap())?;
Self::load_image_from_demuxer(demux) Self::load_image_from_demuxer(demux, size)
} }
pub fn load_image_bytes<'a>( pub fn load_image_bytes(
&self, &self,
key: &str, key: &str,
data: &'static [u8], data: &'static [u8],
size: Option<Vec2>,
) -> Result<ColorImage, Error> { ) -> Result<ColorImage, Error> {
let demux = Demuxer::new_custom_io(data, Some(key.to_string()))?; let demux = Demuxer::new_custom_io(data, Some(key.to_string()))?;
Self::load_image_from_demuxer(demux) Self::load_image_from_demuxer(demux, size)
} }
fn load_image_from_demuxer(mut demuxer: Demuxer) -> Result<ColorImage, Error> { fn load_image_from_demuxer(
mut demuxer: Demuxer,
size: Option<Vec2>,
) -> Result<ColorImage, Error> {
unsafe { unsafe {
let info = demuxer.probe_input()?; let info = demuxer.probe_input()?;
@ -50,13 +54,14 @@ impl FfmpegLoader {
let mut frame = get_frame_from_hw(*frame)?; let mut frame = get_frame_from_hw(*frame)?;
let frame_rgb = scaler.process_frame( let frame_rgb = scaler.process_frame(
frame, frame,
(*frame).width as u16, size.map(|s| s.x as u16).unwrap_or((*frame).width as u16),
(*frame).height as u16, size.map(|s| s.y as u16).unwrap_or((*frame).height as u16),
rgb, rgb,
)?; )?;
av_frame_free(&mut frame); av_frame_free(&mut frame);
let image = video_frame_to_image(frame_rgb); let image = video_frame_to_image(frame_rgb);
av_packet_free(&mut pkt);
return Ok(image); return Ok(image);
} }
} }

View File

@ -1,186 +0,0 @@
use crate::services::ffmpeg_loader::FfmpegLoader;
use crate::theme::NEUTRAL_800;
use anyhow::{Error, Result};
use egui::{ColorImage, Context, Image, ImageData, TextureHandle, TextureOptions};
use itertools::Itertools;
use log::{info, warn};
use lru::LruCache;
use nostr_sdk::util::hex;
use resvg::usvg::Transform;
use sha2::{Digest, Sha256};
use std::collections::VecDeque;
use std::fs;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
type ImageCacheStore = Arc<Mutex<LruCache<String, TextureHandle>>>;
#[derive(PartialEq, Eq, Hash, Clone)]
struct LoadRequest(String);
pub struct ImageCache {
ctx: Context,
dir: PathBuf,
placeholder: TextureHandle,
cache: ImageCacheStore,
fetch_queue: Arc<Mutex<VecDeque<LoadRequest>>>,
}
impl ImageCache {
pub fn new(data_path: PathBuf, ctx: Context) -> Self {
let out = data_path.join("cache/images");
fs::create_dir_all(&out).unwrap();
let placeholder = ctx.load_texture(
"placeholder",
ImageData::from(ColorImage::new([1, 1], NEUTRAL_800)),
TextureOptions::default(),
);
let cache = Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1_000).unwrap())));
let fetch_queue = Arc::new(Mutex::new(VecDeque::<LoadRequest>::new()));
let cc = cache.clone();
let fq = fetch_queue.clone();
let out_dir = out.clone();
let ctx_clone = ctx.clone();
let placeholder_clone = placeholder.clone();
tokio::spawn(async move {
loop {
let next = fq.lock().unwrap().pop_front();
if let Some(next) = next {
let path = Self::find(&out_dir, &next.0);
if path.exists() {
let th = Self::load_image_texture(&ctx_clone, path, &next.0)
.unwrap_or(placeholder_clone.clone());
cc.lock().unwrap().put(next.0, th);
ctx_clone.request_repaint();
} else {
match Self::download_image_to_disk(&path, &next.0).await {
Ok(()) => {
let th = Self::load_image_texture(&ctx_clone, path, &next.0)
.unwrap_or(placeholder_clone.clone());
cc.lock().unwrap().put(next.0, th);
ctx_clone.request_repaint();
}
Err(e) => {
warn!("Failed to download image {}: {}", next.0, e);
cc.lock().unwrap().put(next.0, placeholder_clone.clone());
ctx_clone.request_repaint();
}
}
}
} else {
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
}
});
Self {
ctx,
dir: out,
placeholder,
cache,
fetch_queue,
}
}
pub fn find<U>(dir: &PathBuf, url: U) -> PathBuf
where
U: Into<String>,
{
let mut sha = Sha256::new();
sha2::digest::Update::update(&mut sha, url.into().as_bytes());
let hash = hex::encode(sha.finalize());
dir.join(PathBuf::from(hash[0..2].to_string()))
.join(PathBuf::from(hash))
}
fn load_bytes_impl(url: &str, bytes: &'static [u8]) -> Result<ColorImage, Error> {
if url.ends_with(".svg") {
Self::load_svg(bytes)
} else {
let loader = FfmpegLoader::new();
loader.load_image_bytes(url, bytes)
}
}
pub fn load_bytes<'a, U>(&self, url: U, bytes: &'static [u8]) -> Image<'a>
where
U: Into<String>,
{
let url = url.into();
match Self::load_bytes_impl(&url, bytes) {
Ok(i) => {
let tex = self
.ctx
.load_texture(url, ImageData::from(i), TextureOptions::default());
Image::from_texture(&tex)
}
Err(e) => {
panic!("Failed to load image: {}", e);
}
}
}
pub fn load<'a, U>(&self, url: U) -> Image<'a>
where
U: Into<String>,
{
let u = url.into();
if let Ok(mut c) = self.cache.lock() {
if let Some(i) = c.get(&u) {
return Image::from_texture(i);
}
}
if let Ok(mut ql) = self.fetch_queue.lock() {
let lr = LoadRequest(u.clone());
if !ql.contains(&lr) {
ql.push_back(lr);
}
}
Image::from_texture(&self.placeholder)
}
/// Download an image to disk
async fn download_image_to_disk(dst: &PathBuf, u: &str) -> Result<()> {
info!("Fetching image: {}", &u);
tokio::fs::create_dir_all(dst.parent().unwrap()).await?;
let data = reqwest::get(u).await?;
let img_data = data.bytes().await?;
tokio::fs::write(dst, img_data).await?;
Ok(())
}
/// Load an image from disk into an egui texture handle
fn load_image_texture(ctx: &Context, path: PathBuf, key: &str) -> Option<TextureHandle> {
let loader = FfmpegLoader::new();
match loader.load_image(path) {
Ok(i) => Some(ctx.load_texture(key, ImageData::from(i), TextureOptions::default())),
Err(e) => {
println!("Failed to load image: {}", e);
None
}
}
}
fn load_svg(svg: &[u8]) -> Result<ColorImage, Error> {
use resvg::tiny_skia::Pixmap;
use resvg::usvg::{Options, Tree};
let opt = Options::default();
let rtree = Tree::from_data(svg, &opt)
.map_err(|err| err.to_string())
.map_err(|e| Error::msg(e))?;
let size = rtree.size().to_int_size();
let (w, h) = (size.width(), size.height());
let mut pixmap = Pixmap::new(w, h)
.ok_or_else(|| Error::msg(format!("Failed to create SVG Pixmap of size {w}x{h}")))?;
resvg::render(&rtree, Transform::default(), &mut pixmap.as_mut());
let image = ColorImage::from_rgba_unmultiplied([w as _, h as _], pixmap.data());
Ok(image)
}
}

View File

@ -1,5 +1 @@
pub mod image_cache; pub mod ffmpeg_loader;
pub mod ndb_wrapper;
pub mod query;
mod ffmpeg_loader;

View File

@ -1,161 +0,0 @@
use crate::services::query::QueryManager;
use log::warn;
use nostr_sdk::{nostr, Client, JsonUtil, Kind, PublicKey, RelayPoolNotification};
use nostrdb::{
Error, Filter, Ndb, NdbProfile, Note, NoteKey, ProfileRecord, QueryResult, Subscription,
Transaction,
};
use std::collections::HashSet;
use std::sync::Mutex;
pub struct NDBWrapper {
ctx: egui::Context,
ndb: Ndb,
client: Client,
query_manager: QueryManager<Client>,
profiles: Mutex<HashSet<[u8; 32]>>,
}
/// Automatic cleanup for subscriptions
pub struct SubWrapper {
ndb: Ndb,
subscription: Subscription,
}
impl SubWrapper {
pub fn new(ndb: Ndb, subscription: Subscription) -> Self {
Self { ndb, subscription }
}
}
impl From<&SubWrapper> for u64 {
fn from(val: &SubWrapper) -> Self {
val.subscription.id()
}
}
impl Drop for SubWrapper {
fn drop(&mut self) {
self.ndb.unsubscribe(self.subscription).unwrap()
}
}
impl NDBWrapper {
pub fn new(ctx: egui::Context, ndb: Ndb, client: Client) -> Self {
let client_clone = client.clone();
let ndb_clone = ndb.clone();
let ctx_clone = ctx.clone();
tokio::spawn(async move {
let mut notifications = client_clone.notifications();
while let Ok(e) = notifications.recv().await {
match e {
RelayPoolNotification::Event { event, .. } => {
if let Err(e) = ndb_clone.process_event(event.as_json().as_str()) {
warn!("Failed to process event: {:?}", e);
} else {
ctx_clone.request_repaint();
}
}
_ => {
// dont care
}
}
}
});
let qm = QueryManager::new(client.clone());
Self {
ctx,
ndb,
client,
query_manager: qm,
profiles: Mutex::new(HashSet::new()),
}
}
pub fn start_transaction(&self) -> Transaction {
Transaction::new(&self.ndb).unwrap()
}
pub fn subscribe(&self, id: &str, filters: &[Filter]) -> SubWrapper {
let sub = self.ndb.subscribe(filters).unwrap();
// very lazy conversion
let filters: Vec<nostr_sdk::Filter> = filters
.iter()
.map(|f| nostr_sdk::Filter::from_json(f.json().unwrap()).unwrap())
.collect();
self.query_manager.queue_query(id, filters);
SubWrapper::new(self.ndb.clone(), sub)
}
pub fn unsubscribe(&self, sub: &SubWrapper) {
self.ndb.unsubscribe(sub.subscription).unwrap()
}
pub fn subscribe_with_results<'a>(
&self,
id: &str,
filters: &[Filter],
tx: &'a Transaction,
max_results: i32,
) -> (SubWrapper, Vec<QueryResult<'a>>) {
let sub = self.subscribe(id, filters);
let q = self.query(tx, filters, max_results);
(sub, q)
}
pub fn query<'a>(
&self,
tx: &'a Transaction,
filters: &[Filter],
max_results: i32,
) -> Vec<QueryResult<'a>> {
self.ndb.query(tx, filters, max_results).unwrap()
}
pub fn poll(&self, sub: &SubWrapper, max_results: u32) -> Vec<NoteKey> {
self.ndb.poll_for_notes(sub.subscription, max_results)
}
pub fn get_note_by_key<'a>(
&self,
tx: &'a Transaction,
key: NoteKey,
) -> Result<Note<'a>, Error> {
self.ndb.get_note_by_key(tx, key)
}
pub fn get_profile_by_pubkey<'a>(
&self,
tx: &'a Transaction,
pubkey: &[u8; 32],
) -> Result<ProfileRecord<'a>, Error> {
self.ndb.get_profile_by_pubkey(tx, pubkey)
}
pub fn fetch_profile<'a>(
&self,
tx: &'a Transaction,
pubkey: &[u8; 32],
) -> (Option<NdbProfile<'a>>, Option<SubWrapper>) {
let p = self
.get_profile_by_pubkey(tx, pubkey)
.map_or(None, |p| p.record().profile());
// TODO: fix this shit
if p.is_none() && self.profiles.lock().unwrap().insert(*pubkey) {
self.query_manager.queue_query(
"profile",
&[nostr::Filter::new()
.kinds([Kind::Metadata])
.authors([PublicKey::from_slice(pubkey).unwrap()])],
)
}
let sub = None;
(p, sub)
}
pub fn submit_event(&self, ev: &str) -> Result<(), Error> {
self.ndb.process_event(ev)
}
}

View File

@ -1,192 +0,0 @@
use anyhow::Error;
use chrono::Utc;
use log::{error, info};
use nostr_sdk::prelude::StreamExt;
use nostr_sdk::Kind::Metadata;
use nostr_sdk::{Client, Filter, SubscribeAutoCloseOptions, SubscriptionId};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use uuid::Uuid;
#[async_trait::async_trait]
pub trait QueryClient {
async fn subscribe(&self, id: &str, filters: &[QueryFilter]) -> Result<(), Error>;
}
pub type QueryFilter = Filter;
pub struct Query {
pub id: String,
queue: HashSet<QueryFilter>,
traces: HashSet<QueryTrace>,
}
#[derive(Hash, Eq, PartialEq, Debug)]
pub struct QueryTrace {
/// Subscription id on the relay
pub id: Uuid,
/// Filters associated with this subscription
pub filters: Vec<QueryFilter>,
/// When the query was created
pub queued: u64,
/// When the query was sent to the relay
pub sent: Option<u64>,
/// When EOSE was received
pub eose: Option<u64>,
}
impl Query {
pub fn new(id: &str) -> Self {
Self {
id: id.to_string(),
queue: HashSet::new(),
traces: HashSet::new(),
}
}
/// Add filters to query
pub fn add(&mut self, filter: Vec<QueryFilter>) {
for f in filter {
self.queue.insert(f);
}
}
/// Return next query batch
pub fn next(&mut self) -> Option<QueryTrace> {
let mut next: Vec<QueryFilter> = self.queue.drain().collect();
if next.is_empty() {
return None;
}
let now = Utc::now();
let id = Uuid::new_v4();
// remove filters already sent
next.retain(|f| {
self.traces.is_empty() || !self.traces.iter().all(|y| y.filters.iter().any(|z| z == f))
});
// force profile queries into single filter
if next.iter().all(|f| {
if let Some(k) = &f.kinds {
k.len() == 1 && k.first().unwrap().as_u16() == 0
} else {
false
}
}) {
next = vec![Filter::new().kinds([Metadata]).authors(
next.iter()
.flat_map(|f| f.authors.as_ref().unwrap().clone()),
)]
}
if next.is_empty() {
return None;
}
Some(QueryTrace {
id,
filters: next,
queued: now.timestamp() as u64,
sent: None,
eose: None,
})
}
}
struct QueueDefer {
id: String,
filters: Vec<QueryFilter>,
}
pub struct QueryManager<C> {
client: C,
queries: Arc<RwLock<HashMap<String, Query>>>,
queue_into_queries: UnboundedSender<QueueDefer>,
sender: JoinHandle<()>,
}
impl<C> QueryManager<C>
where
C: QueryClient + Clone + Send + Sync + 'static,
{
pub(crate) fn new(client: C) -> Self {
let queries = Arc::new(RwLock::new(HashMap::new()));
let (tx, mut rx) = unbounded_channel::<QueueDefer>();
Self {
client: client.clone(),
queries: queries.clone(),
queue_into_queries: tx,
sender: tokio::spawn(async move {
loop {
{
let mut q = queries.write().await;
while let Ok(x) = rx.try_recv() {
Self::push_filters(&mut q, &x.id, x.filters);
}
for (k, v) in q.iter_mut() {
if let Some(qt) = v.next() {
info!("Sending trace: {:?}", qt);
match client
.subscribe(&qt.id.to_string(), qt.filters.as_slice())
.await
{
Ok(_) => {}
Err(e) => {
error!("Failed to subscribe to query filters: {}", e);
}
}
}
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}),
}
}
pub async fn query<F>(&mut self, id: &str, filters: F)
where
F: Into<Vec<QueryFilter>>,
{
let mut qq = self.queries.write().await;
Self::push_filters(&mut qq, id, filters.into());
}
fn push_filters(qq: &mut HashMap<String, Query>, id: &str, filters: Vec<QueryFilter>) {
if let Some(q) = qq.get_mut(id) {
q.add(filters);
} else {
let mut q = Query::new(id);
q.add(filters);
qq.insert(id.to_string(), q);
}
}
pub fn queue_query<F>(&self, id: &str, filters: F)
where
F: Into<Vec<QueryFilter>>,
{
self.queue_into_queries
.send(QueueDefer {
id: id.to_string(),
filters: filters.into(),
})
.unwrap()
}
}
#[async_trait::async_trait]
impl QueryClient for Client {
async fn subscribe(&self, id: &str, filters: &[QueryFilter]) -> Result<(), Error> {
self.subscribe_with_id(
SubscriptionId::new(id),
filters.into(),
Some(SubscribeAutoCloseOptions::default()),
)
.await?;
Ok(())
}
}

View File

@ -26,7 +26,10 @@ pub trait StreamInfo {
fn host(&self) -> &[u8; 32]; fn host(&self) -> &[u8; 32];
fn stream(&self) -> Option<&str>; fn streaming(&self) -> Option<&str>;
fn recording(&self) -> Option<&str>;
/// Is the stream playable by this app /// Is the stream playable by this app
fn can_play(&self) -> bool; fn can_play(&self) -> bool;
@ -39,7 +42,7 @@ pub trait StreamInfo {
fn viewers(&self) -> Option<u32>; fn viewers(&self) -> Option<u32>;
} }
impl<'a> StreamInfo for Note<'a> { impl StreamInfo for Note<'_> {
fn title(&self) -> Option<&str> { fn title(&self) -> Option<&str> {
if let Some(s) = self.get_tag_value("title") { if let Some(s) = self.get_tag_value("title") {
s.variant().str() s.variant().str()
@ -68,7 +71,7 @@ impl<'a> StreamInfo for Note<'a> {
} }
} }
fn stream(&self) -> Option<&str> { fn streaming(&self) -> Option<&str> {
if let Some(s) = self.get_tag_value("streaming") { if let Some(s) = self.get_tag_value("streaming") {
s.variant().str() s.variant().str()
} else { } else {
@ -76,9 +79,20 @@ impl<'a> StreamInfo for Note<'a> {
} }
} }
fn recording(&self) -> Option<&str> {
if let Some(s) = self.get_tag_value("recording") {
s.variant().str()
} else {
None
}
}
/// Is the stream playable by this app /// Is the stream playable by this app
fn can_play(&self) -> bool { fn can_play(&self) -> bool {
if let Some(stream) = self.stream() { if self.kind() == 30_313 {
return true; // n94-stream can always be played
}
if let Some(stream) = self.streaming() {
stream.contains(".m3u8") stream.contains(".m3u8")
} else { } else {
false false

21
src/sub.rs Normal file
View File

@ -0,0 +1,21 @@
use log::info;
use nostrdb::{Ndb, Subscription};
pub struct SubRef {
pub sub: Subscription,
ndb: Ndb,
}
impl SubRef {
pub fn new(sub: Subscription, ndb: Ndb) -> Self {
info!("Creating sub: {}", sub.id());
SubRef { sub, ndb }
}
}
impl Drop for SubRef {
fn drop(&mut self) {
self.ndb.unsubscribe(self.sub).expect("unsubscribe failed");
info!("Closing sub: {}", self.sub.id());
}
}

View File

@ -1,9 +1,13 @@
use egui::{Color32, Margin}; use egui::{Color32, Margin};
pub const FONT_SIZE: f32 = 13.0; pub const FONT_SIZE: f32 = 13.0;
pub const FONT_SIZE_SM: f32 = FONT_SIZE * 0.8;
pub const FONT_SIZE_LG: f32 = FONT_SIZE * 1.5;
pub const ROUNDING_DEFAULT: f32 = 12.0; pub const ROUNDING_DEFAULT: f32 = 12.0;
pub const MARGIN_DEFAULT: Margin = Margin::symmetric(12., 6.); pub const MARGIN_DEFAULT: Margin = Margin::symmetric(12., 6.);
pub const PRIMARY: Color32 = Color32::from_rgb(248, 56, 217); pub const PRIMARY: Color32 = Color32::from_rgb(248, 56, 217);
pub const NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115); pub const NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115);
pub const NEUTRAL_700: Color32 = Color32::from_rgb(64, 64, 64);
pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38); pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38);
pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23); pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23);
pub const ZAP: Color32 = Color32::from_rgb(255, 141, 43);

View File

@ -1,42 +1,38 @@
use crate::route::RouteServices; use crate::route::image_from_cache;
use crate::services::ndb_wrapper::SubWrapper; use crate::theme::NEUTRAL_800;
use egui::{vec2, Color32, Pos2, Response, Rounding, Sense, Ui, Vec2, Widget}; use egui::{vec2, Response, Rounding, Sense, Ui, Vec2, Widget};
use nostrdb::NdbProfile; use nostrdb::{Ndb, NdbProfile, Transaction};
use notedeck::ImageCache;
pub struct Avatar<'a> { pub struct Avatar {
image: Option<&'a str>, image: Option<String>,
sub: Option<SubWrapper>,
size: Option<f32>, size: Option<f32>,
services: &'a RouteServices<'a>,
} }
impl<'a> Avatar<'a> { impl Avatar {
pub fn new_optional(img: Option<&'a str>, services: &'a RouteServices<'a>) -> Self { pub fn new_optional(img: Option<&str>) -> Self {
Self { Self {
image: img, image: img.map(String::from),
sub: None,
size: None, size: None,
services,
} }
} }
pub fn from_profile(p: &'a Option<NdbProfile<'a>>, services: &'a RouteServices<'a>) -> Self { pub fn pubkey(pk: &[u8; 32], ndb: &Ndb, tx: &Transaction) -> Self {
let picture = ndb
.get_profile_by_pubkey(tx, pk)
.map(|p| p.record().profile().map(|p| p.picture()).unwrap_or(None))
.unwrap_or(None);
Self {
image: picture.map(|s| s.to_string()),
size: None,
}
}
pub fn from_profile(p: &Option<NdbProfile<'_>>) -> Self {
let img = p.map(|f| f.picture()).unwrap_or(None); let img = p.map(|f| f.picture()).unwrap_or(None);
Self { Self {
image: img, image: img.map(String::from),
sub: None,
size: None, size: None,
services,
}
}
pub fn pubkey(pk: &[u8; 32], services: &'a RouteServices<'a>) -> Self {
let (p, sub) = services.ndb.fetch_profile(services.tx, pk);
Self {
image: p.map(|f| f.picture()).unwrap_or(None),
sub,
size: None,
services,
} }
} }
@ -47,30 +43,24 @@ impl<'a> Avatar<'a> {
fn placeholder(ui: &mut Ui, size: f32) -> Response { fn placeholder(ui: &mut Ui, size: f32) -> Response {
let (response, painter) = ui.allocate_painter(vec2(size, size), Sense::click()); let (response, painter) = ui.allocate_painter(vec2(size, size), Sense::click());
painter.circle_filled( let pos = response.rect.min + vec2(size / 2., size / 2.);
Pos2::new(size / 2., size / 2.), painter.circle_filled(pos, size / 2., NEUTRAL_800);
size / 2.,
Color32::from_rgb(200, 200, 200),
);
response response
} }
}
impl<'a> Widget for Avatar<'a> { pub fn render(self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response {
fn ui(self, ui: &mut Ui) -> Response {
let size_v = self.size.unwrap_or(40.); let size_v = self.size.unwrap_or(40.);
let size = Vec2::new(size_v, size_v); let size = Vec2::new(size_v, size_v);
if !ui.is_visible() { if !ui.is_rect_visible(ui.cursor()) {
return Self::placeholder(ui, size_v); return Self::placeholder(ui, size_v);
} }
match self match self
.image .image
.as_ref() .and_then(|i| image_from_cache(img_cache, ui, &i, Some(size)))
.map(|i| self.services.img_cache.load(*i))
{ {
Some(img) => img Some(img) => img
.fit_to_exact_size(size)
.rounding(Rounding::same(size_v)) .rounding(Rounding::same(size_v))
.sense(Sense::click())
.ui(ui), .ui(ui),
None => Self::placeholder(ui, size_v), None => Self::placeholder(ui, size_v),
} }

View File

@ -1,13 +1,35 @@
use crate::theme::{NEUTRAL_800, ROUNDING_DEFAULT}; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_800, ROUNDING_DEFAULT};
use egui::{Color32, CursorIcon, Frame, Margin, Response, Sense, Ui}; use egui::{Color32, CursorIcon, Frame, Response, Sense, Ui, WidgetText};
pub struct Button { pub struct Button {
color: Color32, color: Color32,
disabled: bool,
} }
impl Button { impl Button {
pub fn new() -> Self { pub fn new() -> Self {
Self { color: NEUTRAL_800 } Self {
color: NEUTRAL_800,
disabled: false,
}
}
pub fn with_color(mut self, color: impl Into<Color32>) -> Self {
self.color = color.into();
self
}
pub fn disabled(mut self, v: bool) -> Self {
self.disabled = v;
self
}
pub fn simple(ui: &mut Ui, content: &str) -> Response {
Button::new().show(ui, |ui| ui.label(content))
}
pub fn text(self, ui: &mut Ui, text: impl Into<WidgetText>) -> Response {
self.show(ui, |ui| ui.label(text))
} }
pub fn show<F>(self, ui: &mut Ui, add_contents: F) -> Response pub fn show<F>(self, ui: &mut Ui, add_contents: F) -> Response
@ -15,15 +37,20 @@ impl Button {
F: FnOnce(&mut Ui) -> Response, F: FnOnce(&mut Ui) -> Response,
{ {
let r = Frame::none() let r = Frame::none()
.inner_margin(Margin::symmetric(12., 8.)) .inner_margin(MARGIN_DEFAULT)
.fill(self.color) .fill(self.color)
.rounding(ROUNDING_DEFAULT) .rounding(ROUNDING_DEFAULT)
.multiply_with_opacity(if self.disabled { 0.5 } else { 1.0 })
.show(ui, add_contents); .show(ui, add_contents);
let id = r.response.id; let id = r.response.id;
ui.interact( ui.interact(
r.response r.response
.on_hover_and_drag_cursor(CursorIcon::PointingHand) .on_hover_and_drag_cursor(if self.disabled {
CursorIcon::NotAllowed
} else {
CursorIcon::PointingHand
})
.rect, .rect,
id, id,
Sense::click(), Sense::click(),

View File

@ -1,62 +1,47 @@
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::note_util::OwnedNote; use crate::note_ref::NoteRef;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper}; use crate::sub::SubRef;
use crate::widgets::chat_message::ChatMessage; use crate::widgets::chat_message::ChatMessage;
use crate::widgets::NostrWidget; use crate::widgets::chat_zap::ChatZap;
use crate::widgets::{sub_or_poll, NostrWidget};
use crate::zap::Zap;
use egui::{Frame, Margin, Response, ScrollArea, Ui}; use egui::{Frame, Margin, Response, ScrollArea, Ui};
use itertools::Itertools; use itertools::Itertools;
use nostrdb::{Filter, Note, NoteKey, Transaction}; use nostrdb::{Filter, NoteKey};
use std::collections::HashSet;
pub struct Chat { pub struct Chat {
link: NostrLink, link: NostrLink,
stream: OwnedNote, stream: NoteKey,
events: Vec<OwnedNote>, events: HashSet<NoteRef>,
sub: SubWrapper, sub: Option<SubRef>,
} }
impl Chat { impl Chat {
pub fn new(link: NostrLink, stream: OwnedNote, ndb: &NDBWrapper, tx: &Transaction) -> Self { pub fn new<'a>(link: NostrLink, stream: NoteKey) -> Self {
let filter = Filter::new()
.kinds([1_311])
.tags([link.to_tag_value()], 'a')
.build();
let filter = [filter];
let (sub, events) = ndb.subscribe_with_results("live-chat", &filter, tx, 500);
Self { Self {
link, link,
sub,
stream, stream,
events: events events: HashSet::new(),
.iter() sub: None,
.map(|n| OwnedNote(n.note_key.as_u64()))
.collect(),
} }
} }
pub fn get_filter(&self) -> Filter {
Filter::new()
.kinds([1_311, 9_735])
.tags([self.link.to_tag_value()], 'a')
.build()
}
} }
impl NostrWidget for Chat { impl NostrWidget for Chat {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
let poll = services.ndb.poll(&self.sub, 500);
poll.iter()
.for_each(|n| self.events.push(OwnedNote(n.as_u64())));
let events: Vec<Note> = self
.events
.iter()
.map_while(|n| {
services
.ndb
.get_note_by_key(services.tx, NoteKey::new(n.0))
.ok()
})
.collect();
let stream = services let stream = services
.ctx
.ndb .ndb
.get_note_by_key(services.tx, NoteKey::new(self.stream.0)) .get_note_by_key(services.tx, self.stream)
.unwrap(); .unwrap();
ScrollArea::vertical() ScrollArea::vertical()
@ -67,12 +52,30 @@ impl NostrWidget for Chat {
.show(ui, |ui| { .show(ui, |ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 8.0; ui.spacing_mut().item_spacing.y = 8.0;
for ev in events for ev in self
.into_iter() .events
.sorted_by(|a, b| a.created_at().cmp(&b.created_at())) .iter()
.sorted_by(|a, b| a.created_at.cmp(&b.created_at))
{ {
let c = ChatMessage::new(&stream, &ev, services); if let Ok(ev) =
ui.add(c); services.ctx.ndb.get_note_by_key(services.tx, ev.key)
{
match ev.kind() {
1311 => {
let profile = services.profile(ev.pubkey());
ChatMessage::new(&stream, &ev, &profile)
.render(ui, services);
}
9735 => {
if let Ok(zap) = Zap::from_receipt(ev) {
let profile = services.profile(&zap.sender);
ChatZap::new(&zap, &profile)
.render(ui, services.ctx.img_cache);
}
}
_ => {}
}
}
} }
}) })
}) })
@ -80,4 +83,16 @@ impl NostrWidget for Chat {
}) })
.inner .inner
} }
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
let filters = vec![self.get_filter()];
sub_or_poll(
services.ctx.ndb,
services.tx,
services.ctx.pool,
&mut self.events,
&mut self.sub,
filters,
)
}
} }

View File

@ -1,50 +1,42 @@
use crate::route::RouteServices; use crate::link::NostrLink;
use crate::services::ndb_wrapper::SubWrapper; use crate::route::{RouteServices, RouteType};
use crate::stream_info::StreamInfo; use crate::stream_info::StreamInfo;
use crate::theme::{NEUTRAL_500, PRIMARY}; use crate::theme::{NEUTRAL_500, PRIMARY};
use crate::widgets::Avatar; use crate::widgets::Avatar;
use eframe::epaint::text::TextWrapMode; use eframe::epaint::text::TextWrapMode;
use egui::text::LayoutJob; use egui::text::LayoutJob;
use egui::{Align, Color32, Label, Response, TextFormat, Ui, Widget}; use egui::{Align, Color32, Label, Response, TextFormat, Ui};
use nostrdb::{NdbProfile, Note}; use nostrdb::{NdbProfile, Note};
pub struct ChatMessage<'a> { pub struct ChatMessage<'a> {
stream: &'a Note<'a>, stream: &'a Note<'a>,
ev: &'a Note<'a>, ev: &'a Note<'a>,
services: &'a RouteServices<'a>, profile: &'a Option<NdbProfile<'a>>,
profile: (Option<NdbProfile<'a>>, Option<SubWrapper>),
} }
impl<'a> ChatMessage<'a> { impl<'a> ChatMessage<'a> {
pub fn new( pub fn new(
stream: &'a Note<'a>, stream: &'a Note<'a>,
ev: &'a Note<'a>, ev: &'a Note<'a>,
services: &'a RouteServices<'a>, profile: &'a Option<NdbProfile<'a>>,
) -> ChatMessage<'a> { ) -> ChatMessage<'a> {
ChatMessage { ChatMessage {
stream, stream,
ev, ev,
services, profile,
profile: services.ndb.fetch_profile(services.tx, ev.pubkey()),
} }
} }
}
impl<'a> Widget for ChatMessage<'a> { pub fn render(self, ui: &mut Ui, services: &mut RouteServices) -> Response {
fn ui(self, ui: &mut Ui) -> Response {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
let mut job = LayoutJob::default(); let mut job = LayoutJob::default();
// TODO: avoid this somehow // TODO: avoid this somehow
job.wrap.break_anywhere = true; job.wrap.break_anywhere = true;
let is_host = self.stream.host().eq(self.ev.pubkey()); let is_host = self.stream.host().eq(self.ev.pubkey());
let profile = self let name = self
.services .profile
.ndb .map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
.get_profile_by_pubkey(self.services.tx, self.ev.pubkey())
.map_or(None, |p| p.record().profile());
let name = profile.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
let name_color = if is_host { PRIMARY } else { NEUTRAL_500 }; let name_color = if is_host { PRIMARY } else { NEUTRAL_500 };
@ -57,8 +49,19 @@ impl<'a> Widget for ChatMessage<'a> {
format.color = Color32::WHITE; format.color = Color32::WHITE;
job.append(self.ev.content(), 5.0, format.clone()); job.append(self.ev.content(), 5.0, format.clone());
ui.add(Avatar::from_profile(&profile, self.services).size(24.)); if Avatar::from_profile(self.profile)
.size(24.)
.render(ui, services.ctx.img_cache)
.clicked()
{
services.navigate(RouteType::ProfilePage {
link: NostrLink::profile(self.ev.pubkey()),
})
}
ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap)); ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap));
// consume reset of space
ui.add_space(ui.available_size_before_wrap().x);
}) })
.response .response
} }

68
src/widgets/chat_zap.rs Normal file
View File

@ -0,0 +1,68 @@
use crate::theme::{MARGIN_DEFAULT, ROUNDING_DEFAULT, ZAP};
use crate::widgets::Avatar;
use crate::zap::{format_sats, Zap};
use eframe::emath::Align;
use eframe::epaint::text::{LayoutJob, TextFormat, TextWrapMode};
use eframe::epaint::Color32;
use egui::{Frame, Label, Response, Stroke, Ui};
use nostrdb::NdbProfile;
use notedeck::ImageCache;
pub struct ChatZap<'a> {
zap: &'a Zap<'a>,
profile: &'a Option<NdbProfile<'a>>,
}
impl<'a> ChatZap<'a> {
pub fn new(zap: &'a Zap, profile: &'a Option<NdbProfile<'a>>) -> Self {
Self { zap, profile }
}
pub fn render(self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response {
Frame::default()
.rounding(ROUNDING_DEFAULT)
.inner_margin(MARGIN_DEFAULT)
.stroke(Stroke::new(1., ZAP))
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
let mut job = LayoutJob::default();
// TODO: avoid this somehow
job.wrap.break_anywhere = true;
let name = self
.profile
.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
let mut format = TextFormat::default();
format.line_height = Some(24.0);
format.valign = Align::Center;
format.color = ZAP;
job.append(name, 0.0, format.clone());
format.color = Color32::WHITE;
job.append("zapped", 5.0, format.clone());
format.color = ZAP;
job.append(
&format_sats((self.zap.amount / 1000) as f32),
5.0,
format.clone(),
);
format.color = Color32::WHITE;
job.append("sats", 5.0, format.clone());
if !self.zap.message.is_empty() {
job.append(&format!("\n{}", self.zap.message), 0.0, format.clone());
}
Avatar::from_profile(self.profile)
.size(24.)
.render(ui, img_cache);
ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap));
// consume reset of space
ui.add_space(ui.available_size_before_wrap().x);
});
})
.response
}
}

View File

@ -1,9 +1,11 @@
use crate::route::{RouteServices, Routes}; use crate::link::NostrLink;
use crate::route::{RouteServices, RouteType};
use crate::widgets::avatar::Avatar; use crate::widgets::avatar::Avatar;
use crate::widgets::{Button, NostrWidget}; use crate::widgets::Button;
use eframe::emath::Align; use eframe::emath::Align;
use eframe::epaint::Vec2; use eframe::epaint::Vec2;
use egui::{CursorIcon, Frame, Layout, Margin, Response, Sense, Ui, Widget}; use egui::{CursorIcon, Frame, Image, Layout, Margin, Response, Sense, Ui, Widget};
use nostrdb::Transaction;
pub struct Header; pub struct Header;
@ -11,10 +13,12 @@ impl Header {
pub fn new() -> Self { pub fn new() -> Self {
Self {} Self {}
} }
} pub fn render(
&mut self,
impl NostrWidget for Header { ui: &mut Ui,
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response { services: &mut RouteServices<'_, '_>,
tx: &Transaction,
) -> Response {
let logo_bytes = include_bytes!("../resources/logo.svg"); let logo_bytes = include_bytes!("../resources/logo.svg");
Frame::none() Frame::none()
.outer_margin(Margin::symmetric(16., 8.)) .outer_margin(Margin::symmetric(16., 8.))
@ -24,23 +28,28 @@ impl NostrWidget for Header {
Layout::left_to_right(Align::Center), Layout::left_to_right(Align::Center),
|ui| { |ui| {
ui.style_mut().spacing.item_spacing.x = 16.; ui.style_mut().spacing.item_spacing.x = 16.;
if services if Image::from_bytes("header_logo.svg", logo_bytes)
.img_cache
.load_bytes("logo.svg", logo_bytes)
.max_height(24.) .max_height(24.)
.sense(Sense::click()) .sense(Sense::click())
.ui(ui) .ui(ui)
.on_hover_and_drag_cursor(CursorIcon::PointingHand) .on_hover_and_drag_cursor(CursorIcon::PointingHand)
.clicked() .clicked()
{ {
services.navigate(Routes::HomePage); services.navigate(RouteType::HomePage);
} }
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if let Some(pk) = services.login.public_key() { if let Some(acc) = services.ctx.accounts.get_selected_account() {
ui.add(Avatar::pubkey(&pk, services)); if Avatar::pubkey(&acc.pubkey, services.ctx.ndb, tx)
.render(ui, services.ctx.img_cache)
.clicked()
{
services.navigate(RouteType::ProfilePage {
link: NostrLink::profile(acc.pubkey.bytes()),
})
}
} else if Button::new().show(ui, |ui| ui.label("Login")).clicked() { } else if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
services.navigate(Routes::LoginPage); services.navigate(RouteType::LoginPage);
} }
}); });
}, },

View File

@ -2,7 +2,9 @@ mod avatar;
mod button; mod button;
mod chat; mod chat;
mod chat_message; mod chat_message;
mod chat_zap;
mod header; mod header;
mod pill;
mod placeholder_rect; mod placeholder_rect;
mod profile; mod profile;
mod stream_list; mod stream_list;
@ -12,18 +14,58 @@ mod stream_title;
mod text_input; mod text_input;
mod username; mod username;
mod write_chat; mod write_chat;
mod zap;
use crate::note_ref::NoteRef;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::sub::SubRef;
use egui::{Response, Ui}; use egui::{Response, Ui};
use enostr::RelayPool;
use nostrdb::{Filter, Ndb, Transaction};
use std::collections::HashSet;
/// A stateful widget which requests nostr data
pub trait NostrWidget { pub trait NostrWidget {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response; /// Render with widget on the UI
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response;
/// Update widget on draw
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()>;
}
/// On widget update call this to update NDB data
pub fn sub_or_poll(
ndb: &Ndb,
tx: &Transaction,
pool: &mut RelayPool,
store: &mut HashSet<NoteRef>,
sub: &mut Option<SubRef>,
filters: Vec<Filter>,
) -> anyhow::Result<()> {
if let Some(sub) = sub {
ndb.poll_for_notes(sub.sub, 500).into_iter().for_each(|e| {
if let Ok(note) = ndb.get_note_by_key(tx, e) {
store.insert(NoteRef::from_note(&note));
}
});
} else {
let s = ndb.subscribe(filters.as_slice())?;
sub.replace(SubRef::new(s, ndb.clone()));
ndb.query(tx, filters.as_slice(), 500)?
.into_iter()
.for_each(|e| {
store.insert(NoteRef::from_query_result(e));
});
pool.subscribe(format!("ndb-{}", s.id()), filters);
}
Ok(())
} }
pub use self::avatar::Avatar; pub use self::avatar::Avatar;
pub use self::button::Button; pub use self::button::Button;
pub use self::chat::Chat; pub use self::chat::Chat;
pub use self::header::Header; pub use self::header::Header;
pub use self::pill::Pill;
pub use self::placeholder_rect::PlaceholderRect; pub use self::placeholder_rect::PlaceholderRect;
pub use self::profile::Profile; pub use self::profile::Profile;
pub use self::stream_list::StreamList; pub use self::stream_list::StreamList;

35
src/widgets/pill.rs Normal file
View File

@ -0,0 +1,35 @@
use crate::theme::{FONT_SIZE, NEUTRAL_800};
use eframe::epaint::Margin;
use egui::{Color32, Frame, Response, RichText, Ui, Widget};
pub struct Pill {
text: String,
color: Color32,
}
impl Pill {
pub fn new(text: &str) -> Self {
Self {
text: String::from(text),
color: NEUTRAL_800,
}
}
pub fn color(mut self, color: Color32) -> Self {
self.color = color;
self
}
}
impl Widget for Pill {
fn ui(self, ui: &mut Ui) -> Response {
Frame::default()
.inner_margin(Margin::symmetric(5.0, 3.0))
.rounding(5.0)
.fill(self.color)
.show(ui, |ui| {
ui.label(RichText::new(&self.text).size(FONT_SIZE));
})
.response
}
}

View File

@ -1,46 +1,49 @@
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::services::image_cache::ImageCache;
use crate::services::ndb_wrapper::SubWrapper;
use crate::theme::FONT_SIZE; use crate::theme::FONT_SIZE;
use crate::widgets::{Avatar, Username}; use crate::widgets::{Avatar, Username};
use egui::{Response, Ui, Widget}; use egui::{Response, Ui};
use nostrdb::NdbProfile; use nostrdb::NdbProfile;
pub struct Profile<'a> { pub struct Profile<'a> {
size: f32, size: f32,
pubkey: &'a [u8; 32], pubkey: &'a [u8; 32],
profile: Option<NdbProfile<'a>>, profile: &'a Option<NdbProfile<'a>>,
sub: Option<SubWrapper>,
img_cache: &'a ImageCache,
services: &'a RouteServices<'a>,
} }
impl<'a> Profile<'a> { impl<'a> Profile<'a> {
pub fn new(pubkey: &'a [u8; 32], services: &'a RouteServices<'a>) -> Self { pub fn new(pubkey: &'a [u8; 32]) -> Self {
let (p, sub) = services.ndb.fetch_profile(services.tx, pubkey);
Self { Self {
pubkey, pubkey,
size: 40., size: 40.,
profile: p, profile: &None,
img_cache: services.img_cache, }
sub, }
services,
pub fn from_profile(pubkey: &'a [u8; 32], profile: &'a Option<NdbProfile<'a>>) -> Self {
Self {
pubkey,
profile,
size: 40.,
} }
} }
pub fn size(self, size: f32) -> Self { pub fn size(self, size: f32) -> Self {
Self { size, ..self } Self { size, ..self }
} }
}
impl<'a> Widget for Profile<'a> { pub fn render(self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
fn ui(self, ui: &mut Ui) -> Response {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.; ui.spacing_mut().item_spacing.x = 8.;
ui.add(Avatar::from_profile(&self.profile, self.services).size(self.size)); let profile = if let Some(profile) = self.profile {
ui.add(Username::new(&self.profile, FONT_SIZE)) Some(*profile)
} else {
services.profile(self.pubkey)
};
Avatar::from_profile(&profile)
.size(self.size)
.render(ui, services.ctx.img_cache);
ui.add(Username::new(&profile, FONT_SIZE))
}) })
.response .response
} }

View File

@ -1,35 +1,31 @@
use crate::note_store::NoteStore; use crate::note_view::NotesView;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::stream_info::StreamInfo; use crate::stream_info::StreamInfo;
use crate::theme::MARGIN_DEFAULT;
use crate::widgets::stream_tile::StreamEvent; use crate::widgets::stream_tile::StreamEvent;
use egui::{vec2, Frame, Grid, Margin, Response, Ui, Widget, WidgetText}; use egui::{vec2, Frame, Grid, Margin, Response, Ui, WidgetText};
use itertools::Itertools; use itertools::Itertools;
pub struct StreamList<'a> { pub struct StreamList<'a> {
id: egui::Id, id: egui::Id,
streams: &'a NoteStore<'a>, streams: NotesView<'a>,
services: &'a RouteServices<'a>,
heading: Option<WidgetText>, heading: Option<WidgetText>,
} }
impl<'a> StreamList<'a> { impl<'a> StreamList<'a> {
pub fn new( pub fn new(
id: egui::Id, id: egui::Id,
streams: &'a NoteStore<'a>, streams: NotesView<'a>,
services: &'a RouteServices<'a>,
heading: Option<impl Into<WidgetText>>, heading: Option<impl Into<WidgetText>>,
) -> Self { ) -> Self {
Self { Self {
id, id,
streams, streams,
services,
heading: heading.map(Into::into), heading: heading.map(Into::into),
} }
} }
}
impl Widget for StreamList<'_> { pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
fn ui(self, ui: &mut Ui) -> Response {
let cols = match ui.available_width() as u16 { let cols = match ui.available_width() as u16 {
720..1080 => 2, 720..1080 => 2,
1080..1300 => 3, 1080..1300 => 3,
@ -40,13 +36,12 @@ impl Widget for StreamList<'_> {
}; };
let grid_padding = 20.; let grid_padding = 20.;
let frame_margin = 16.0;
Frame::none() Frame::none()
.inner_margin(Margin::symmetric(frame_margin, 0.)) .inner_margin(MARGIN_DEFAULT)
.show(ui, |ui| { .show(ui, |ui| {
let grid_spacing_consumed = (cols - 1) as f32 * grid_padding; let grid_spacing_consumed = (cols - 1) as f32 * grid_padding;
let g_w = (ui.available_width() - grid_spacing_consumed) / cols as f32; let g_w = (ui.available_width() - grid_spacing_consumed) / cols as f32;
if let Some(heading) = self.heading { if let Some(heading) = self.heading.take() {
ui.label(heading); ui.label(heading);
} }
Grid::new(self.id) Grid::new(self.id)
@ -58,10 +53,9 @@ impl Widget for StreamList<'_> {
.cmp(&b.status()) .cmp(&b.status())
.then(a.starts().cmp(&b.starts()).reverse()) .then(a.starts().cmp(&b.starts()).reverse())
}) { }) {
ui.add_sized( ui.allocate_ui(vec2(g_w, (g_w / 16.0) * 9.0), |ui| {
vec2(g_w, (g_w / 16.0) * 9.0), StreamEvent::new(event).render(ui, services)
StreamEvent::new(event, self.services), });
);
ctr += 1; ctr += 1;
if ctr % cols == 0 { if ctr % cols == 0 {
ui.end_row(); ui.end_row();

View File

@ -1,66 +1,61 @@
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::route::{RouteServices, Routes}; use crate::route::{image_from_cache, RouteServices, RouteType};
use crate::stream_info::{StreamInfo, StreamStatus}; use crate::stream_info::{StreamInfo, StreamStatus};
use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT}; use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT};
use crate::widgets::avatar::Avatar; use crate::widgets::avatar::Avatar;
use eframe::epaint::{Rounding, Vec2}; use eframe::epaint::{Rounding, Vec2};
use egui::epaint::RectShape; use egui::epaint::RectShape;
use egui::load::TexturePoll;
use egui::{ use egui::{
vec2, Color32, CursorIcon, FontId, Label, Pos2, Rect, Response, RichText, Sense, TextWrapMode, vec2, Color32, CursorIcon, FontId, ImageSource, Label, Pos2, Rect, Response, RichText, Sense,
Ui, Widget, TextWrapMode, Ui,
}; };
use nostrdb::Note; use nostrdb::Note;
pub struct StreamEvent<'a> { pub struct StreamEvent<'a> {
event: &'a Note<'a>, event: &'a Note<'a>,
services: &'a RouteServices<'a>,
} }
impl<'a> StreamEvent<'a> { impl<'a> StreamEvent<'a> {
pub fn new(event: &'a Note<'a>, services: &'a RouteServices) -> Self { pub fn new(event: &'a Note<'a>) -> Self {
Self { event, services } Self { event }
} }
}
impl Widget for StreamEvent<'_> { pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
fn ui(self, ui: &mut Ui) -> Response {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.style_mut().spacing.item_spacing = Vec2::new(12., 16.); ui.style_mut().spacing.item_spacing = Vec2::new(12., 16.);
let host = self.event.host(); let host = self.event.host();
let (host_profile, _sub) = self.services.ndb.fetch_profile(self.services.tx, host); let host_profile = services.profile(host);
let w = ui.available_width(); let w = ui.available_width();
let h = (w / 16.0) * 9.0; let h = (w / 16.0) * 9.0;
let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click()); let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click());
let cover = if ui.is_visible() { let cover = if ui.is_rect_visible(response.rect) {
self.event.image().map(|p| self.services.img_cache.load(p)) self.event
.image()
.and_then(|p| {
image_from_cache(services.ctx.img_cache, ui, p, Some(Vec2::new(w, h)))
})
.map(|i| i.rounding(ROUNDING_DEFAULT))
} else { } else {
None None
}; };
if let Some(cover) = cover.map(|c| { if let Some(cover) = cover {
c.rounding(Rounding::same(12.)) painter.add(RectShape {
.load_for_size(painter.ctx(), Vec2::new(w, h)) rect: response.rect,
}) { rounding: Rounding::same(ROUNDING_DEFAULT),
match cover { fill: Color32::WHITE,
Ok(TexturePoll::Ready { texture }) => { stroke: Default::default(),
painter.add(RectShape { blur_width: 0.0,
rect: response.rect, fill_texture_id: match cover.source(ui.ctx()) {
rounding: Rounding::same(ROUNDING_DEFAULT), ImageSource::Texture(t) => t.id,
fill: Color32::WHITE, _ => Default::default(),
stroke: Default::default(), },
blur_width: 0.0, uv: Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)),
fill_texture_id: texture.id, });
uv: Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)),
});
}
_ => {
painter.rect_filled(response.rect, ROUNDING_DEFAULT, NEUTRAL_800);
}
}
} else { } else {
painter.rect_filled(response.rect, ROUNDING_DEFAULT, NEUTRAL_800); painter.rect_filled(response.rect, ROUNDING_DEFAULT, NEUTRAL_800);
} }
@ -110,13 +105,15 @@ impl Widget for StreamEvent<'_> {
} }
let response = response.on_hover_and_drag_cursor(CursorIcon::PointingHand); let response = response.on_hover_and_drag_cursor(CursorIcon::PointingHand);
if response.clicked() { if response.clicked() {
self.services.navigate(Routes::EventPage { services.navigate(RouteType::EventPage {
link: NostrLink::from_note(self.event), link: NostrLink::from_note(self.event),
event: None, event: None,
}); });
} }
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add(Avatar::from_profile(&host_profile, self.services).size(40.)); Avatar::from_profile(&host_profile)
.size(40.)
.render(ui, services.ctx.img_cache);
let title = RichText::new(self.event.title().unwrap_or("Untitled")) let title = RichText::new(self.event.title().unwrap_or("Untitled"))
.size(16.) .size(16.)
.color(Color32::WHITE); .color(Color32::WHITE);

View File

@ -1,8 +1,11 @@
use crate::note_util::NoteUtil; use crate::note_util::NoteUtil;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::stream_info::StreamInfo; use crate::stream_info::{StreamInfo, StreamStatus};
use crate::widgets::{NostrWidget, Profile}; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, PRIMARY};
use egui::{Color32, Frame, Label, Margin, Response, RichText, TextWrapMode, Ui}; use crate::widgets::zap::ZapButton;
use crate::widgets::Pill;
use crate::widgets::Profile;
use egui::{vec2, Color32, Frame, Label, Response, RichText, TextWrapMode, Ui};
use nostrdb::Note; use nostrdb::Note;
pub struct StreamTitle<'a> { pub struct StreamTitle<'a> {
@ -10,32 +13,49 @@ pub struct StreamTitle<'a> {
} }
impl<'a> StreamTitle<'a> { impl<'a> StreamTitle<'a> {
pub fn new(event: &'a Note<'a>) -> StreamTitle { pub fn new(event: &'a Note<'a>) -> StreamTitle<'a> {
StreamTitle { event } StreamTitle { event }
} }
} pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
impl<'a> NostrWidget for StreamTitle<'a> {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
Frame::none() Frame::none()
.outer_margin(Margin::symmetric(12., 8.)) .outer_margin(MARGIN_DEFAULT)
.show(ui, |ui| { .show(ui, |ui| {
ui.style_mut().spacing.item_spacing.y = 8.; ui.spacing_mut().item_spacing = vec2(5., 8.0);
let title = RichText::new(self.event.title().unwrap_or("Untitled")) let title = RichText::new(self.event.title().unwrap_or("Untitled"))
.size(20.) .size(20.)
.color(Color32::WHITE); .color(Color32::WHITE);
ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Truncate)); ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Wrap));
ui.add(Profile::new(self.event.host(), services).size(32.)); ui.horizontal(|ui| {
Profile::new(self.event.host())
.size(32.)
.render(ui, services);
ZapButton::event(self.event).render(ui, services);
});
ui.horizontal(|ui| {
let status = self.event.status().to_string().to_uppercase();
let live_label_color = if self.event.status() == StreamStatus::Live {
PRIMARY
} else {
NEUTRAL_900
};
ui.add(Pill::new(&status).color(live_label_color));
ui.add(Pill::new(&format!(
"{} viewers",
self.event.viewers().unwrap_or(0)
)));
});
if let Some(summary) = self if let Some(summary) = self
.event .event
.get_tag_value("summary") .get_tag_value("summary")
.and_then(|r| r.variant().str()) .and_then(|r| r.variant().str())
{ {
if summary.len() > 0 { if !summary.is_empty() {
let summary = RichText::new(summary).color(Color32::WHITE); let summary = RichText::new(summary).color(Color32::WHITE);
ui.add(Label::new(summary).wrap_mode(TextWrapMode::Truncate)); ui.add(Label::new(summary).wrap_mode(TextWrapMode::Wrap));
} }
} }
}) })

View File

@ -1,7 +1,5 @@
use crate::route::{RouteAction, RouteServices};
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_500, NEUTRAL_900, ROUNDING_DEFAULT}; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_500, NEUTRAL_900, ROUNDING_DEFAULT};
use crate::widgets::NostrWidget; use egui::{Frame, Response, TextEdit, Ui, Widget};
use egui::{Frame, Response, TextEdit, Ui};
/// Wrap the [TextEdit] widget to handle native keyboard /// Wrap the [TextEdit] widget to handle native keyboard
pub struct NativeTextInput<'a> { pub struct NativeTextInput<'a> {
@ -30,8 +28,8 @@ impl<'a> NativeTextInput<'a> {
} }
} }
impl<'a> NostrWidget for NativeTextInput<'a> { impl Widget for NativeTextInput<'_> {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response { fn ui(self, ui: &mut Ui) -> Response {
let mut editor = TextEdit::multiline(self.text) let mut editor = TextEdit::multiline(self.text)
.frame(false) .frame(false)
.desired_rows(1) .desired_rows(1)
@ -39,7 +37,8 @@ impl<'a> NostrWidget for NativeTextInput<'a> {
if let Some(hint_text) = self.hint_text { if let Some(hint_text) = self.hint_text {
editor = editor.hint_text(egui::RichText::new(hint_text).color(NEUTRAL_500)); editor = editor.hint_text(egui::RichText::new(hint_text).color(NEUTRAL_500));
} }
let response = if self.frame {
if self.frame {
Frame::none() Frame::none()
.inner_margin(MARGIN_DEFAULT) .inner_margin(MARGIN_DEFAULT)
.fill(NEUTRAL_900) .fill(NEUTRAL_900)
@ -48,13 +47,6 @@ impl<'a> NostrWidget for NativeTextInput<'a> {
.inner .inner
} else { } else {
ui.add(editor) ui.add(editor)
};
if response.lost_focus() {
services.action(RouteAction::HideKeyboard);
} }
if response.gained_focus() {
services.action(RouteAction::ShowKeyboard);
}
response
} }
} }

View File

@ -12,7 +12,7 @@ impl<'a> Username<'a> {
} }
} }
impl<'a> Widget for Username<'a> { impl Widget for Username<'_> {
fn ui(self, ui: &mut Ui) -> Response { fn ui(self, ui: &mut Ui) -> Response {
let name = self let name = self
.profile .profile

View File

@ -1,9 +1,9 @@
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT}; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT};
use crate::widgets::{NativeTextInput, NostrWidget}; use crate::widgets::NativeTextInput;
use eframe::emath::Align; use eframe::emath::Align;
use egui::{Frame, Layout, Response, Sense, Ui, Widget}; use egui::{Frame, Image, Layout, Response, Sense, Ui, Widget};
use log::info; use log::info;
pub struct WriteChat { pub struct WriteChat {
@ -18,10 +18,8 @@ impl WriteChat {
msg: String::new(), msg: String::new(),
} }
} }
}
impl NostrWidget for WriteChat { pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
let logo_bytes = include_bytes!("../resources/send-03.svg"); let logo_bytes = include_bytes!("../resources/send-03.svg");
Frame::none() Frame::none()
.inner_margin(MARGIN_DEFAULT) .inner_margin(MARGIN_DEFAULT)
@ -30,17 +28,13 @@ impl NostrWidget for WriteChat {
.rounding(ROUNDING_DEFAULT) .rounding(ROUNDING_DEFAULT)
.show(ui, |ui| { .show(ui, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if services if Image::from_bytes("send-03.svg", logo_bytes)
.img_cache
.load_bytes("send-03.svg", logo_bytes)
.sense(Sense::click()) .sense(Sense::click())
.ui(ui) .ui(ui)
.clicked() .clicked()
|| self.msg.ends_with('\n') || self.msg.ends_with('\n')
{ {
if let Ok(ev) = services if let Some(ev) = services.write_live_chat_msg(&self.link, self.msg.trim())
.login
.write_live_chat_msg(&self.link, &self.msg.trim())
{ {
info!("Sending: {:?}", ev); info!("Sending: {:?}", ev);
services.broadcast_event(ev); services.broadcast_event(ev);
@ -48,9 +42,7 @@ impl NostrWidget for WriteChat {
self.msg.clear(); self.msg.clear();
} }
let mut editor = ui.add(NativeTextInput::new(&mut self.msg).with_hint_text("Message.."));
NativeTextInput::new(&mut self.msg).with_hint_text("Message..");
editor.render(ui, services)
}); });
}) })
.response .response

294
src/widgets/zap.rs Normal file
View File

@ -0,0 +1,294 @@
use crate::link::NostrLink;
use crate::route::RouteServices;
use crate::stream_info::StreamInfo;
use crate::theme::{
FONT_SIZE_LG, MARGIN_DEFAULT, NEUTRAL_700, NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT,
};
use crate::widgets::{Button, NativeTextInput};
use crate::zap::format_sats;
use anyhow::{anyhow, bail};
use egui::text::{LayoutJob, TextWrapping};
use egui::{vec2, Frame, Grid, Response, RichText, Stroke, TextFormat, TextWrapMode, Ui, Widget};
use egui_modal::Modal;
use egui_qr::QrCodeWidget;
use enostr::PoolRelay;
use itertools::Itertools;
use lnurl::pay::{LnURLPayInvoice, PayResponse};
use nostr::prelude::{hex, ZapRequestData};
use nostr::{serde_json, EventBuilder, JsonUtil, Kind, PublicKey, Tag, Url};
use nostrdb::Note;
use std::fmt::{Display, Formatter};
use std::task::Poll;
pub enum ZapTarget<'a> {
PublicKey { pubkey: [u8; 32] },
Event { event: &'a Note<'a> },
}
impl Display for ZapTarget<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ZapTarget::PublicKey { pubkey } => write!(f, "{}", hex::encode(pubkey)),
ZapTarget::Event { event } => write!(f, "{}", hex::encode(event.id())),
}
}
}
#[derive(Clone)]
pub enum ZapState {
NotStarted,
Ready { service: PayResponse },
FetchingInvoice { callback: String },
Invoice { invoice: LnURLPayInvoice },
Error(String),
}
pub struct ZapButton<'a> {
target: ZapTarget<'a>,
}
impl<'a> ZapButton<'a> {
pub fn pubkey(pubkey: [u8; 32]) -> Self {
Self {
target: ZapTarget::PublicKey { pubkey },
}
}
pub fn event(event: &'a Note<'a>) -> Self {
Self {
target: ZapTarget::Event { event },
}
}
pub fn render(self, ui: &mut Ui, services: &mut RouteServices) -> Response {
// TODO: fix id
let modal = Modal::new(ui.ctx(), format!("zapper-{}", 0)).with_close_on_outside_click(true);
let resp = Button::new().show(ui, |ui| ui.label("ZAP"));
if resp.clicked() {
modal.open();
}
ui.visuals_mut().window_rounding = ROUNDING_DEFAULT.into();
ui.visuals_mut().window_stroke = Stroke::NONE;
ui.visuals_mut().window_fill = NEUTRAL_900;
modal.show(|ui| {
Frame::none().inner_margin(MARGIN_DEFAULT).show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
let pubkey = match &self.target {
ZapTarget::PublicKey { pubkey } => pubkey,
ZapTarget::Event { event } => event.host(),
};
// zapping state machine
let zap_state = services.get("zap_state").unwrap_or(ZapState::NotStarted);
match &zap_state {
ZapState::NotStarted => match services.fetch_lnurlp(pubkey) {
Ok(Poll::Ready(r)) => {
services.set("zap_state", ZapState::Ready { service: r })
}
Err(e) => services.set("zap_state", ZapState::Error(e.to_string())),
_ => {}
},
ZapState::FetchingInvoice { callback } => {
match self.zap_get_invoice(callback, services) {
Ok(Poll::Ready(s)) => {
services.set("zap_state", ZapState::Invoice { invoice: s })
}
Err(e) => services.set("zap_state", ZapState::Error(e.to_string())),
_ => {}
}
}
_ => {}
}
// when ready state, show zap button
match &zap_state {
ZapState::Ready { service } => {
self.render_input(ui, services, pubkey, service);
}
ZapState::Invoice { invoice } => {
if let Ok(q) = QrCodeWidget::from_data(invoice.pr.as_bytes()) {
ui.vertical_centered(|ui| {
ui.add_sized(vec2(256., 256.), q);
let mut job = LayoutJob::default();
job.wrap = TextWrapping::from_wrap_mode_and_width(
TextWrapMode::Truncate,
ui.available_width(),
);
job.append(&invoice.pr, 0.0, TextFormat::default());
ui.label(job);
});
}
}
ZapState::Error(e) => {
ui.label(e);
}
_ => {}
}
})
});
if modal.was_outside_clicked() {
services.set("zap_state", ZapState::NotStarted)
}
resp
}
fn zap_get_invoice(
&self,
callback: &str,
services: &mut RouteServices,
) -> anyhow::Result<Poll<LnURLPayInvoice>> {
match services.fetch(callback) {
Poll::Ready(Ok(r)) => {
if r.ok {
let inv: LnURLPayInvoice = serde_json::from_slice(&r.bytes)?;
Ok(Poll::Ready(inv))
} else {
bail!("Invalid response code {}", r.status);
}
}
Poll::Ready(Err(e)) => Err(anyhow!("{}", e)),
Poll::Pending => Ok(Poll::Pending),
}
}
fn render_input(
&self,
ui: &mut Ui,
services: &mut RouteServices,
pubkey: &[u8; 32],
service: &PayResponse,
) {
let target_name = match self.target {
ZapTarget::PublicKey { pubkey } => services.profile(&pubkey).and_then(|p| p.name()),
ZapTarget::Event { event } => {
let host = event.host();
services.profile(host).and_then(|p| p.name())
}
};
let fallback_name = self.target.to_string();
let target_name = target_name.unwrap_or(&fallback_name);
ui.label(RichText::new(format!("Zap {}", target_name)).size(FONT_SIZE_LG));
ui.label("Zap amount in sats");
// amount buttons
const SATS_AMOUNTS: &[u64] = &[
21, 69, 121, 420, 1_000, 2_100, 4_200, 10_000, 21_000, 42_000, 69_000, 100_000,
210_000, 500_000, 1_000_000,
];
const COLS: u32 = 5;
let selected_amount = services.get("zap_amount").unwrap_or(0);
Grid::new("zap_amounts_grid").show(ui, |ui| {
let mut ctr = 0;
for x in SATS_AMOUNTS {
if Button::new()
.with_color(if selected_amount == *x {
NEUTRAL_700
} else {
NEUTRAL_800
})
.text(ui, &format_sats(*x as f32))
.clicked()
{
services.set("zap_amount", *x);
}
ctr += 1;
if ctr % COLS == 0 {
ui.end_row();
}
}
});
// comment section
let mut zap_comment = services.get("zap_comment").unwrap_or(String::new());
ui.label(format!("Your comment for {}", target_name));
let old_len = zap_comment.len();
NativeTextInput::new(&mut zap_comment)
.with_frame(true)
.ui(ui);
if Button::new().with_color(PRIMARY).text(ui, "Zap!").clicked() {
// on-click setup callback URL and transition state
match self.zap_callback(
services,
pubkey,
&zap_comment,
selected_amount * 1_000,
&service,
) {
Ok(callback) => services.set(
"zap_state",
ZapState::FetchingInvoice {
callback: callback.to_string(),
},
),
Err(e) => services.set("zap_state", ZapState::Error(e.to_string())),
}
}
if zap_comment.len() != old_len {
services.set("zap_comment", zap_comment);
}
}
fn zap_callback(
&self,
services: &mut RouteServices,
pubkey: &[u8; 32],
zap_comment: &str,
amount: u64,
lnurlp: &PayResponse,
) -> anyhow::Result<Url> {
let relays: Vec<Url> = services
.ctx
.pool
.relays
.iter()
.filter_map(|r| match r {
PoolRelay::Websocket(w) => Url::parse(&w.relay.url).ok(),
_ => None,
})
.collect();
if relays.is_empty() {
bail!("No relays found");
}
let mut req = ZapRequestData::new(PublicKey::from_slice(pubkey)?, relays)
.message(zap_comment)
.amount(amount);
match &self.target {
ZapTarget::Event { event } => {
req.event_coordinate = Some(
NostrLink::from_note(event)
.try_into()
.map_err(|e| anyhow!("{:?}", e))?,
)
}
_ => {}
};
let req_tags: Vec<Tag> = req.into();
let keys = if let Some(k) = services.current_account_keys() {
k
} else {
bail!("Not logged in")
};
let req_ev = EventBuilder::new(Kind::ZapRequest, zap_comment)
.tags(req_tags)
.sign_with_keys(&keys)?;
let mut url = Url::parse(&lnurlp.callback)?;
url.query_pairs_mut()
.append_pair("amount", amount.to_string().as_str());
if lnurlp.nostr_pubkey.is_some() {
url.query_pairs_mut()
.append_pair("nostr", req_ev.as_json().as_str());
}
Ok(url)
}
}

73
src/zap.rs Normal file
View File

@ -0,0 +1,73 @@
use crate::note_util::NoteUtil;
use anyhow::{anyhow, bail, Result};
use fixed_decimal::FixedDecimal;
use icu::decimal::FixedDecimalFormatter;
use icu::locid::Locale;
use nostr::{Event, JsonUtil, Kind, TagStandard};
use nostrdb::Note;
pub struct Zap<'a> {
pub sender: [u8; 32],
pub receiver: [u8; 32],
pub zapper_service: &'a [u8; 32],
pub amount: u64,
pub message: String,
}
impl<'a> Zap<'a> {
pub fn from_receipt(event: Note<'a>) -> Result<Zap> {
if event.kind() != 9735 {
bail!("not a zap receipt");
}
let req_json = event
.get_tag_value("description")
.ok_or(anyhow!("missing description"))?;
let req = Event::from_json(
req_json
.variant()
.str()
.ok_or(anyhow!("empty description"))?,
)?;
if req.kind != Kind::ZapRequest {
bail!("not a zap request");
}
let dest = req
.tags
.iter()
.find_map(|t| match t.as_standardized() {
Some(TagStandard::PublicKey { public_key, .. }) => Some(public_key.to_bytes()),
_ => None,
})
.ok_or(anyhow!("missing p tag in zap request"))?;
let amount = req.tags.iter().find_map(|t| match t.as_standardized() {
Some(TagStandard::Amount { millisats, .. }) => Some(*millisats),
_ => None,
});
Ok(Zap {
sender: req.pubkey.to_bytes(),
receiver: dest,
zapper_service: event.pubkey(),
amount: amount.unwrap_or(0u64),
message: req.content,
})
}
}
pub fn format_sats(n: f32) -> String {
let (div_n, suffix) = if n >= 1_000. && n < 1_000_000. {
(1_000., "K")
} else if n >= 1_000_000. {
(1_000_000., "M")
} else {
(1., "")
};
let fmt = FixedDecimalFormatter::try_new(&Locale::UND.into(), Default::default()).expect("icu");
let d: FixedDecimal = (n / div_n).to_string().parse().expect("fixed decimal");
format!("{}{}", fmt.format_to_string(&d), suffix)
}