feat: native layer code

feat: login kinds (nsec)
feat: write chat messages
This commit is contained in:
kieran 2024-10-31 15:56:29 +00:00
parent a98eb6f4ce
commit 58132d2cf5
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
21 changed files with 1396 additions and 180 deletions

889
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,6 @@ crate-type = ["lib", "cdylib"]
[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" } egui = { version = "0.29.1" }
eframe = { version = "0.29.1", default-features = false, features = ["glow", "wgpu", "wayland", "x11", "android-native-activity"] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", version = "0.3.4" } nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", version = "0.3.4" }
nostr-sdk = { version = "0.35.0", features = ["all-nips"] } nostr-sdk = { version = "0.35.0", features = ["all-nips"] }
log = "0.4.22" log = "0.4.22"
@ -25,12 +24,19 @@ sha2 = "0.10.8"
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls-native-roots"] } reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls-native-roots"] }
itertools = "0.13.0" itertools = "0.13.0"
lru = "0.12.5" lru = "0.12.5"
resvg = { version = "0.44.0", default-features = false }
egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "ced65d0bb4d2d144b87c70518a04b767ba37c0c1" } egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "ced65d0bb4d2d144b87c70518a04b767ba37c0c1" }
resvg = { version = "0.44.0", default-features = false } serde = { version = "1.0.214", features = ["derive"] }
serde_with = { version = "3.11.0", features = ["hex"] }
#egui-video = { path = "../egui-video" } #egui-video = { path = "../egui-video" }
[target.'cfg(not(target_os = "android"))'.dependencies]
eframe = { version = "0.29.1" }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
eframe = { version = "0.29.1", features = ["android-native-activity"] }
android_logger = "0.14.1" android_logger = "0.14.1"
android-activity = { version = "0.6.0", features = ["native-activity"] } android-activity = { version = "0.6.0" }
winit = { version = "0.30.5", features = ["android-native-activity"] } winit = { version = "0.30.5" }
android-ndk-sys = "0.2.0"

90
src/android.rs Normal file
View File

@ -0,0 +1,90 @@
use crate::app::{NativeLayer, NativeSecureStorage, ZapStreamApp};
use crate::av_log_redirect;
use eframe::Renderer;
use egui::{Margin, ViewportBuilder};
use std::ops::Div;
use winit::platform::android::activity::AndroidApp;
use winit::platform::android::EventLoopBuilderExtAndroid;
pub fn start_android(app: AndroidApp) {
std::env::set_var("RUST_BACKTRACE", "full");
android_logger::init_once(
android_logger::Config::default().with_max_level(log::LevelFilter::Info),
);
unsafe {
egui_video::ffmpeg_sys_the_third::av_log_set_callback(Some(av_log_redirect));
}
let mut options = eframe::NativeOptions::default();
options.renderer = Renderer::Glow;
options.viewport = ViewportBuilder::default()
.with_active(true)
.with_always_on_top()
.with_fullscreen(true);
let app_clone_for_event_loop = app.clone();
options.event_loop_builder = Some(Box::new(move |builder| {
builder.with_android_app(app_clone_for_event_loop);
}));
let data_path = app
.external_data_path()
.expect("external data path")
.to_path_buf();
if let Err(e) = eframe::run_native(
"zap.stream",
options,
Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, data_path, Box::new(app))))),
) {
eprintln!("{}", e);
}
}
impl NativeLayer 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 secure_storage(&self) -> Box<dyn NativeSecureStorage> {
Box::new(self.clone())
}
}
impl NativeSecureStorage for AndroidApp {
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
}
}

View File

@ -6,18 +6,30 @@ use nostr_sdk::Client;
use nostrdb::{Config, Ndb}; use nostrdb::{Config, Ndb};
use std::path::PathBuf; use std::path::PathBuf;
pub struct ZapStreamApp<T> { pub struct ZapStreamApp<T: NativeLayerOps> {
client: Client, client: Client,
router: Router, router: Router<T>,
config: T, native_layer: T,
} }
/// Trait to wrap native configuration layers pub trait NativeLayerOps {
pub trait AppConfig { /// Get any display layout margins
fn frame_margin(&self) -> Margin; fn frame_margin(&self) -> Margin;
/// 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> { impl<T> ZapStreamApp<T>
where
T: NativeLayerOps + Clone,
{
pub fn new(cc: &CreationContext, data_path: PathBuf, config: T) -> Self { pub fn new(cc: &CreationContext, data_path: PathBuf, config: T) -> Self {
let client = Client::builder() let client = Client::builder()
.database(MemoryDatabase::with_opts(Default::default())) .database(MemoryDatabase::with_opts(Default::default()))
@ -48,21 +60,28 @@ impl<T> ZapStreamApp<T> {
let ndb = Ndb::new(ndb_path.to_str().unwrap(), &ndb_config).unwrap(); let ndb = Ndb::new(ndb_path.to_str().unwrap(), &ndb_config).unwrap();
let cfg = config.clone();
Self { Self {
client: client.clone(), client: client.clone(),
router: Router::new(data_path, cc.egui_ctx.clone(), client.clone(), ndb.clone()), router: Router::new(
config, data_path,
cc.egui_ctx.clone(),
client.clone(),
ndb.clone(),
cfg,
),
native_layer: config,
} }
} }
} }
impl<T> App for ZapStreamApp<T> impl<T> App for ZapStreamApp<T>
where where
T: AppConfig, T: NativeLayerOps,
{ {
fn update(&mut self, ctx: &Context, frame: &mut Frame) { fn update(&mut self, ctx: &Context, frame: &mut Frame) {
let mut app_frame = egui::containers::Frame::default(); let mut app_frame = egui::containers::Frame::default();
let margin = self.config.frame_margin(); let margin = self.native_layer.frame_margin();
app_frame.inner_margin = margin; app_frame.inner_margin = margin;
app_frame.stroke.color = Color32::BLACK; app_frame.stroke.color = Color32::BLACK;

View File

@ -1,20 +1,29 @@
use eframe::Renderer; use eframe::Renderer;
use egui::{Margin, Vec2}; use egui::{Margin, Vec2};
use nostr_sdk::serde_json;
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::path::PathBuf;
use zap_stream_app::app::{AppConfig, ZapStreamApp}; use std::sync::{Arc, RwLock};
use zap_stream_app::app::{NativeLayerOps, ZapStreamApp};
use zap_stream_app::av_log_redirect;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
pretty_env_logger::init(); pretty_env_logger::init();
// TODO: redirect FFMPEG logs to log file (noisy) unsafe {
egui_video::ffmpeg_sys_the_third::av_log_set_callback(Some(av_log_redirect));
}
let mut options = eframe::NativeOptions::default(); let mut options = eframe::NativeOptions::default();
options.renderer = Renderer::Glow; options.renderer = Renderer::Glow;
options.viewport = options.viewport.with_inner_size(Vec2::new(360., 720.)); options.viewport = options.viewport.with_inner_size(Vec2::new(360., 720.));
let config = DesktopApp;
let data_path = PathBuf::from("./.data"); let data_path = PathBuf::from("./.data");
let config = DesktopApp::new(data_path.clone());
let _res = eframe::run_native( let _res = eframe::run_native(
"zap.stream", "zap.stream",
options, options,
@ -22,10 +31,85 @@ async fn main() {
); );
} }
struct DesktopApp; #[derive(Clone)]
pub struct DesktopApp {
data_path: PathBuf,
data: Arc<RwLock<HashMap<String, String>>>,
}
impl AppConfig for DesktopApp { 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 { fn frame_margin(&self) -> Margin {
Margin::ZERO 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

@ -1,5 +1,8 @@
#[cfg(target_os = "android")]
mod android;
pub mod app; pub mod app;
mod link; mod link;
mod login;
mod note_store; mod note_store;
mod note_util; mod note_util;
mod route; mod route;
@ -8,65 +11,51 @@ mod stream_info;
mod theme; mod theme;
mod widgets; mod widgets;
use crate::app::{AppConfig, ZapStreamApp};
use eframe::Renderer;
use egui::{Margin, ViewportBuilder};
use std::ops::{Div, Mul};
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use winit::platform::android::activity::AndroidApp; use android_activity::AndroidApp;
use log::log;
use std::ffi::CStr;
use std::ptr;
#[cfg(not(target_os = "android"))]
type VaList = *mut egui_video::ffmpeg_sys_the_third::__va_list_tag;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use winit::platform::android::EventLoopBuilderExtAndroid; type VaList = [u64; 4];
#[no_mangle]
pub unsafe extern "C" fn av_log_redirect(
av_class: *mut libc::c_void,
level: libc::c_int,
fmt: *const libc::c_char,
args: VaList,
) {
use egui_video::ffmpeg_sys_the_third::*;
let log_level = match level {
AV_LOG_DEBUG => log::Level::Debug,
AV_LOG_WARNING => log::Level::Warn,
AV_LOG_INFO => log::Level::Info,
AV_LOG_ERROR => log::Level::Error,
AV_LOG_PANIC => log::Level::Error,
AV_LOG_FATAL => log::Level::Error,
_ => log::Level::Trace,
};
let mut buf: [u8; 1024] = [0; 1024];
let mut prefix: libc::c_int = 1;
av_log_format_line(
av_class,
level,
fmt,
args,
buf.as_mut_ptr() as *mut libc::c_char,
1024,
ptr::addr_of_mut!(prefix),
);
log!(target: "ffmpeg", log_level, "{}", CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str().unwrap().trim());
}
#[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: AndroidApp) {
std::env::set_var("RUST_BACKTRACE", "full"); android::start_android(app);
android_logger::init_once(
android_logger::Config::default().with_max_level(log::LevelFilter::Info),
);
let mut options = eframe::NativeOptions::default();
options.renderer = Renderer::Glow;
options.viewport = ViewportBuilder::default().with_fullscreen(true);
let app_clone_for_event_loop = app.clone();
options.event_loop_builder = Some(Box::new(move |builder| {
builder.with_android_app(app_clone_for_event_loop);
}));
let data_path = app
.external_data_path()
.expect("external data path")
.to_path_buf();
let app = app.clone();
let _res = eframe::run_native(
"zap.stream",
options,
Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, data_path, app)))),
);
}
#[cfg(target_os = "android")]
impl AppConfig 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
}
}
} }

85
src/login.rs Normal file
View File

@ -0,0 +1,85 @@
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(&secret).map_err(Error::new)
}
pub fn write_live_chat_msg(&self, link: &NostrLink, msg: &str) -> Result<Event, Error> {
let secret = self.secret_key()?;
EventBuilder::new(Kind::LiveEventMessage, msg, [Tag::parse(&link.to_tag())?])
.to_event(&secret)
.map_err(Error::new)
}
}

View File

@ -27,7 +27,7 @@ impl HomePage {
} }
impl NostrWidget for HomePage { impl NostrWidget for HomePage {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
let new_notes = services.ndb.poll(&self.sub, 100); let new_notes = services.ndb.poll(&self.sub, 100);
new_notes new_notes
.iter() .iter()

View File

@ -1,6 +1,7 @@
use crate::route::{RouteAction, RouteServices, Routes}; use crate::login::LoginKind;
use crate::widgets::{Button, NostrWidget}; use crate::route::{RouteServices, Routes};
use egui::{Color32, Response, RichText, Ui}; use crate::widgets::{Button, NativeTextInput, NostrWidget};
use egui::{Color32, Frame, Margin, Response, RichText, Ui};
use nostr_sdk::util::hex; use nostr_sdk::util::hex;
pub struct LoginPage { pub struct LoginPage {
@ -18,27 +19,49 @@ impl LoginPage {
} }
impl NostrWidget for LoginPage { impl NostrWidget for LoginPage {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
ui.vertical_centered(|ui| { Frame::none()
ui.spacing_mut().item_spacing.y = 8.; .inner_margin(Margin::same(12.))
.show(ui, |ui| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 8.;
ui.label(RichText::new("Login").size(32.)); ui.label(RichText::new("Login").size(32.));
ui.label("Pubkey"); let mut input = NativeTextInput::new(&mut self.key).with_hint_text("npub/nsec");
ui.text_edit_singleline(&mut self.key); input.render(ui, services);
if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
if let Ok(pk) = hex::decode(&self.key) { if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
if let Ok(pk) = pk.as_slice().try_into() { if let Ok((hrp, key)) = bech32::decode(&self.key) {
services.action(RouteAction::LoginPubkey(pk)); match hrp.to_lowercase().as_str() {
services.navigate(Routes::HomePage); "nsec" => {
return; services.login.login(LoginKind::PrivateKey {
key: key.as_slice().try_into().unwrap(),
});
services.navigate(Routes::HomePage);
}
"npub" | "nprofile" => {
services.login.login(LoginKind::PublicKey {
key: key.as_slice().try_into().unwrap(),
});
services.navigate(Routes::HomePage);
}
_ => {}
}
} else if let Ok(pk) = hex::decode(&self.key) {
if let Ok(pk) = pk.as_slice().try_into() {
services.login.login(LoginKind::PublicKey { key: pk });
services.navigate(Routes::HomePage);
return;
}
}
self.error = Some("Invalid pubkey".to_string());
} }
} if let Some(e) = &self.error {
self.error = Some("Invalid pubkey".to_string()); ui.label(RichText::new(e).color(Color32::RED));
} }
if let Some(e) = &self.error { })
ui.label(RichText::new(e).color(Color32::RED)); .response
} })
}) .inner
.response
} }
} }

View File

@ -1,4 +1,6 @@
use crate::app::NativeLayerOps;
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::login::{Login, LoginKind};
use crate::note_util::OwnedNote; use crate::note_util::OwnedNote;
use crate::route::home::HomePage; use crate::route::home::HomePage;
use crate::route::login::LoginPage; use crate::route::login::LoginPage;
@ -9,7 +11,7 @@ use crate::widgets::{Header, NostrWidget};
use egui::{Context, Response, Ui}; use egui::{Context, Response, Ui};
use egui_inbox::{UiInbox, UiInboxSender}; use egui_inbox::{UiInbox, UiInboxSender};
use log::{info, warn}; use log::{info, warn};
use nostr_sdk::Client; use nostr_sdk::{Client, Event, JsonUtil};
use nostrdb::{Ndb, Transaction}; use nostrdb::{Ndb, Transaction};
use std::path::PathBuf; use std::path::PathBuf;
@ -36,24 +38,40 @@ pub enum Routes {
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum RouteAction { pub enum RouteAction {
/// Login with public key ShowKeyboard,
LoginPubkey([u8; 32]), HideKeyboard,
} }
pub struct Router { pub struct Router<T: NativeLayerOps> {
current: Routes, current: Routes,
current_widget: Option<Box<dyn NostrWidget>>, current_widget: Option<Box<dyn NostrWidget>>,
router: UiInbox<Routes>, router: UiInbox<Routes>,
ctx: Context, ctx: Context,
ndb: NDBWrapper, ndb: NDBWrapper,
login: Option<[u8; 32]>, login: Login,
client: Client, client: Client,
image_cache: ImageCache, image_cache: ImageCache,
native_layer: T,
} }
impl Router { impl<T: NativeLayerOps> Drop for Router<T> {
pub fn new(data_path: PathBuf, ctx: Context, client: Client, ndb: Ndb) -> Self { fn drop(&mut self) {
self.login.save(&mut self.native_layer)
}
}
impl<T: NativeLayerOps> Router<T> {
pub fn new(
data_path: PathBuf,
ctx: Context,
client: Client,
ndb: Ndb,
native_layer: T,
) -> Self {
let mut login = Login::new();
login.load(&native_layer);
Self { Self {
current: Routes::HomePage, current: Routes::HomePage,
current_widget: None, current_widget: None,
@ -61,8 +79,9 @@ impl Router {
ctx: ctx.clone(), ctx: ctx.clone(),
ndb: NDBWrapper::new(ctx.clone(), ndb.clone(), client.clone()), ndb: NDBWrapper::new(ctx.clone(), ndb.clone(), client.clone()),
client, client,
login: None, login,
image_cache: ImageCache::new(data_path, ctx.clone()), image_cache: ImageCache::new(data_path, ctx.clone()),
native_layer,
} }
} }
@ -91,9 +110,10 @@ impl Router {
// handle app state changes // handle app state changes
let q = self.router.read(ui); let q = self.router.read(ui);
for r in q { for r in q {
if let Routes::Action(a) = &r { if let Routes::Action(a) = r {
match a { match a {
RouteAction::LoginPubkey(k) => self.login = Some(*k), RouteAction::ShowKeyboard => self.native_layer.show_keyboard(),
RouteAction::HideKeyboard => self.native_layer.hide_keyboard(),
_ => info!("Not implemented"), _ => info!("Not implemented"),
} }
} else { } else {
@ -106,20 +126,21 @@ impl Router {
self.load_widget(Routes::HomePage, &tx); self.load_widget(Routes::HomePage, &tx);
} }
let svc = RouteServices { let mut svc = RouteServices {
context: self.ctx.clone(), context: self.ctx.clone(),
router: self.router.sender(), router: self.router.sender(),
client: self.client.clone(),
ndb: &self.ndb, ndb: &self.ndb,
tx: &tx, tx: &tx,
login: &self.login, login: &mut self.login,
img_cache: &self.image_cache, img_cache: &self.image_cache,
}; };
// display app // display app
ui.vertical(|ui| { ui.vertical(|ui| {
Header::new().render(ui, &svc); Header::new().render(ui, &mut svc);
if let Some(w) = self.current_widget.as_mut() { if let Some(w) = self.current_widget.as_mut() {
w.render(ui, &svc) w.render(ui, &mut svc)
} else { } else {
ui.label("No widget") ui.label("No widget")
} }
@ -131,11 +152,12 @@ impl Router {
pub struct RouteServices<'a> { pub struct RouteServices<'a> {
pub context: Context, //cloned pub context: Context, //cloned
pub router: UiInboxSender<Routes>, //cloned pub router: UiInboxSender<Routes>, //cloned
pub client: Client,
pub ndb: &'a NDBWrapper, //ref pub ndb: &'a NDBWrapper, //ref
pub tx: &'a Transaction, //ref pub tx: &'a Transaction, //ref
pub login: &'a Option<[u8; 32]>, //ref pub login: &'a mut Login, //ref
pub img_cache: &'a ImageCache, pub img_cache: &'a ImageCache, //ref
} }
impl<'a> RouteServices<'a> { impl<'a> RouteServices<'a> {
@ -150,4 +172,21 @@ impl<'a> RouteServices<'a> {
warn!("Failed to navigate"); warn!("Failed to navigate");
} }
} }
pub fn broadcast_event(&self, event: Event) {
let client = self.client.clone();
let ev_json = event.as_json();
if let Err(e) = self.ndb.submit_event(&ev_json) {
warn!("Failed to submit event {}", e);
}
tokio::spawn(async move {
match client.send_event(event).await {
Ok(e) => {
info!("Broadcast event: {:?}", e)
}
Err(e) => warn!("Failed to broadcast event: {:?}", e),
}
});
}
} }

View File

@ -23,18 +23,18 @@ impl StreamPage {
let f = [f.limit_mut(1)]; let f = [f.limit_mut(1)];
let (sub, events) = ndb.subscribe_with_results("streams", &f, tx, 1); let (sub, events) = ndb.subscribe_with_results("streams", &f, tx, 1);
Self { Self {
link, link: link.clone(),
sub, sub,
event: events.first().map(|n| OwnedNote(n.note_key.as_u64())), event: events.first().map(|n| OwnedNote(n.note_key.as_u64())),
chat: None, chat: None,
player: None, player: None,
new_msg: WriteChat::new(), new_msg: WriteChat::new(link),
} }
} }
} }
impl NostrWidget for StreamPage { impl NostrWidget for StreamPage {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
let poll = services.ndb.poll(&self.sub, 1); let poll = services.ndb.poll(&self.sub, 1);
if let Some(k) = poll.first() { if let Some(k) = poll.first() {
self.event = Some(OwnedNote(k.as_u64())) self.event = Some(OwnedNote(k.as_u64()))

View File

@ -154,4 +154,8 @@ impl NDBWrapper {
let sub = None; let sub = None;
(p, sub) (p, sub)
} }
pub fn submit_event(&self, ev: &str) -> Result<(), Error> {
self.ndb.process_event(ev)
}
} }

View File

@ -1,6 +1,8 @@
use egui::Color32; use egui::{Color32, Margin};
pub const FONT_SIZE: f32 = 13.0; pub const FONT_SIZE: f32 = 13.0;
pub const ROUNDING_DEFAULT: f32 = 12.0;
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_800: Color32 = Color32::from_rgb(38, 38, 38); pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38);

View File

@ -1,4 +1,4 @@
use crate::theme::NEUTRAL_800; use crate::theme::{NEUTRAL_800, ROUNDING_DEFAULT};
use egui::{Color32, CursorIcon, Frame, Margin, Response, Sense, Ui}; use egui::{Color32, CursorIcon, Frame, Margin, Response, Sense, Ui};
pub struct Button { pub struct Button {
@ -17,7 +17,7 @@ impl Button {
let r = Frame::none() let r = Frame::none()
.inner_margin(Margin::symmetric(12., 8.)) .inner_margin(Margin::symmetric(12., 8.))
.fill(self.color) .fill(self.color)
.rounding(12.) .rounding(ROUNDING_DEFAULT)
.show(ui, add_contents); .show(ui, add_contents);
let id = r.response.id; let id = r.response.id;

View File

@ -39,7 +39,7 @@ impl Chat {
} }
impl NostrWidget for Chat { impl NostrWidget for Chat {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
let poll = services.ndb.poll(&self.sub, 500); let poll = services.ndb.poll(&self.sub, 500);
poll.iter() poll.iter()
.for_each(|n| self.events.push(OwnedNote(n.as_u64()))); .for_each(|n| self.events.push(OwnedNote(n.as_u64())));

View File

@ -1,3 +1,4 @@
use crate::login::LoginKind;
use crate::route::{RouteServices, Routes}; use crate::route::{RouteServices, Routes};
use crate::widgets::avatar::Avatar; use crate::widgets::avatar::Avatar;
use crate::widgets::{Button, NostrWidget}; use crate::widgets::{Button, NostrWidget};
@ -14,7 +15,7 @@ impl Header {
} }
impl NostrWidget for Header { impl NostrWidget for Header {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> 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.))
@ -37,8 +38,8 @@ impl NostrWidget for Header {
} }
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 { if let Some(pk) = services.login.public_key() {
ui.add(Avatar::pubkey(pk, services)); ui.add(Avatar::pubkey(&pk, services));
} 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(Routes::LoginPage);
} }

View File

@ -8,6 +8,7 @@ mod stream_list;
mod stream_player; mod stream_player;
mod stream_tile; mod stream_tile;
mod stream_title; mod stream_title;
mod text_input;
mod username; mod username;
mod video_placeholder; mod video_placeholder;
mod write_chat; mod write_chat;
@ -16,7 +17,7 @@ use crate::route::RouteServices;
use egui::{Response, Ui}; use egui::{Response, Ui};
pub trait NostrWidget { pub trait NostrWidget {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response; fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response;
} }
pub use self::avatar::Avatar; pub use self::avatar::Avatar;
@ -27,6 +28,7 @@ pub use self::profile::Profile;
pub use self::stream_list::StreamList; pub use self::stream_list::StreamList;
pub use self::stream_player::StreamPlayer; pub use self::stream_player::StreamPlayer;
pub use self::stream_title::StreamTitle; pub use self::stream_title::StreamTitle;
pub use self::text_input::NativeTextInput;
pub use self::username::Username; pub use self::username::Username;
pub use self::video_placeholder::VideoPlaceholder; pub use self::video_placeholder::VideoPlaceholder;
pub use self::write_chat::WriteChat; pub use self::write_chat::WriteChat;

View File

@ -1,7 +1,7 @@
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::route::{RouteServices, Routes}; use crate::route::{RouteServices, Routes};
use crate::stream_info::{StreamInfo, StreamStatus}; use crate::stream_info::{StreamInfo, StreamStatus};
use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY}; 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;
@ -44,7 +44,7 @@ impl Widget for StreamEvent<'_> {
Ok(TexturePoll::Ready { texture }) => { Ok(TexturePoll::Ready { texture }) => {
painter.add(RectShape { painter.add(RectShape {
rect: response.rect, rect: response.rect,
rounding: Rounding::same(12.), rounding: Rounding::same(ROUNDING_DEFAULT),
fill: Color32::WHITE, fill: Color32::WHITE,
stroke: Default::default(), stroke: Default::default(),
blur_width: 0.0, blur_width: 0.0,

View File

@ -16,7 +16,7 @@ impl<'a> StreamTitle<'a> {
} }
impl<'a> NostrWidget for StreamTitle<'a> { impl<'a> NostrWidget for StreamTitle<'a> {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
Frame::none() Frame::none()
.outer_margin(Margin::symmetric(12., 8.)) .outer_margin(Margin::symmetric(12., 8.))
.show(ui, |ui| { .show(ui, |ui| {

46
src/widgets/text_input.rs Normal file
View File

@ -0,0 +1,46 @@
use crate::route::{RouteAction, RouteServices};
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_500, NEUTRAL_800, ROUNDING_DEFAULT};
use crate::widgets::NostrWidget;
use egui::{Frame, Response, TextEdit, Ui};
/// Wrap the [TextEdit] widget to handle native keyboard
pub struct NativeTextInput<'a> {
pub text: &'a mut String,
hint_text: Option<&'a str>,
}
impl<'a> NativeTextInput<'a> {
pub fn new(text: &'a mut String) -> Self {
Self {
text,
hint_text: None,
}
}
pub fn with_hint_text(mut self, hint_text: &'a str) -> Self {
self.hint_text = Some(hint_text);
self
}
}
impl<'a> NostrWidget for NativeTextInput<'a> {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
let mut editor = TextEdit::singleline(self.text).frame(false);
if let Some(hint_text) = self.hint_text {
editor = editor.hint_text(egui::RichText::new(hint_text).color(NEUTRAL_500));
}
let response = Frame::none()
.inner_margin(MARGIN_DEFAULT)
.fill(NEUTRAL_800)
.rounding(ROUNDING_DEFAULT)
.show(ui, |ui| ui.add(editor))
.inner;
if response.lost_focus() {
services.action(RouteAction::HideKeyboard);
}
if response.gained_focus() {
services.action(RouteAction::ShowKeyboard);
}
response
}
}

View File

@ -1,32 +1,36 @@
use crate::route::RouteServices; use crate::link::NostrLink;
use crate::theme::NEUTRAL_900; use crate::route::{RouteAction, RouteServices};
use crate::widgets::NostrWidget; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT};
use crate::widgets::{NativeTextInput, NostrWidget};
use eframe::emath::Align; use eframe::emath::Align;
use egui::{Frame, Image, Layout, Margin, Response, Rounding, Sense, Stroke, TextEdit, Ui, Widget}; use egui::{Frame, Image, Layout, Margin, Response, Rounding, Sense, Stroke, TextEdit, Ui, Widget};
use log::info; use log::info;
pub struct WriteChat { pub struct WriteChat {
link: NostrLink,
msg: String, msg: String,
} }
impl WriteChat { impl WriteChat {
pub fn new() -> Self { pub fn new(link: NostrLink) -> Self {
Self { msg: String::new() } Self {
link,
msg: String::new(),
}
} }
} }
impl NostrWidget for WriteChat { impl NostrWidget for WriteChat {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
let size = ui.available_size();
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::symmetric(12., 6.)) .inner_margin(MARGIN_DEFAULT)
.stroke(Stroke::new(1.0, NEUTRAL_900)) .stroke(Stroke::new(1.0, NEUTRAL_900))
.show(ui, |ui| { .show(ui, |ui| {
Frame::none() Frame::none()
.fill(NEUTRAL_900) .fill(NEUTRAL_900)
.rounding(Rounding::same(12.0)) .rounding(ROUNDING_DEFAULT)
.inner_margin(Margin::symmetric(12., 10.)) .inner_margin(MARGIN_DEFAULT)
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if services if services
@ -36,12 +40,19 @@ impl NostrWidget for WriteChat {
.ui(ui) .ui(ui)
.clicked() .clicked()
{ {
info!("Sending: {}", self.msg); if let Ok(ev) =
services.login.write_live_chat_msg(&self.link, &self.msg)
{
info!("Sending: {:?}", ev);
services.broadcast_event(ev);
}
self.msg.clear(); self.msg.clear();
} }
let editor = TextEdit::singleline(&mut self.msg).frame(false); ui.allocate_ui(ui.available_size(), |ui| {
ui.add_sized(ui.available_size(), editor); let mut editor = NativeTextInput::new(&mut self.msg);
editor.render(ui, services);
});
}); });
}) })
}) })