feat: native layer code
feat: login kinds (nsec) feat: write chat messages
This commit is contained in:
parent
a98eb6f4ce
commit
58132d2cf5
889
Cargo.lock
generated
889
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
@ -9,7 +9,6 @@ crate-type = ["lib", "cdylib"]
|
||||
[dependencies]
|
||||
tokio = { version = "1.40.0", features = ["fs", "rt-multi-thread", "rt"] }
|
||||
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" }
|
||||
nostr-sdk = { version = "0.35.0", features = ["all-nips"] }
|
||||
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"] }
|
||||
itertools = "0.13.0"
|
||||
lru = "0.12.5"
|
||||
resvg = { version = "0.44.0", default-features = false }
|
||||
|
||||
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" }
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
eframe = { version = "0.29.1" }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
eframe = { version = "0.29.1", features = ["android-native-activity"] }
|
||||
android_logger = "0.14.1"
|
||||
android-activity = { version = "0.6.0", features = ["native-activity"] }
|
||||
winit = { version = "0.30.5", features = ["android-native-activity"] }
|
||||
android-activity = { version = "0.6.0" }
|
||||
winit = { version = "0.30.5" }
|
||||
android-ndk-sys = "0.2.0"
|
||||
|
90
src/android.rs
Normal file
90
src/android.rs
Normal 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
|
||||
}
|
||||
}
|
39
src/app.rs
39
src/app.rs
@ -6,18 +6,30 @@ use nostr_sdk::Client;
|
||||
use nostrdb::{Config, Ndb};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct ZapStreamApp<T> {
|
||||
pub struct ZapStreamApp<T: NativeLayerOps> {
|
||||
client: Client,
|
||||
router: Router,
|
||||
config: T,
|
||||
router: Router<T>,
|
||||
native_layer: T,
|
||||
}
|
||||
|
||||
/// Trait to wrap native configuration layers
|
||||
pub trait AppConfig {
|
||||
pub trait NativeLayerOps {
|
||||
/// Get any display layout margins
|
||||
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 {
|
||||
let client = Client::builder()
|
||||
.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 cfg = config.clone();
|
||||
Self {
|
||||
client: client.clone(),
|
||||
router: Router::new(data_path, cc.egui_ctx.clone(), client.clone(), ndb.clone()),
|
||||
config,
|
||||
router: Router::new(
|
||||
data_path,
|
||||
cc.egui_ctx.clone(),
|
||||
client.clone(),
|
||||
ndb.clone(),
|
||||
cfg,
|
||||
),
|
||||
native_layer: config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> App for ZapStreamApp<T>
|
||||
where
|
||||
T: AppConfig,
|
||||
T: NativeLayerOps,
|
||||
{
|
||||
fn update(&mut self, ctx: &Context, frame: &mut Frame) {
|
||||
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.stroke.color = Color32::BLACK;
|
||||
|
@ -1,20 +1,29 @@
|
||||
use eframe::Renderer;
|
||||
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 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]
|
||||
async fn main() {
|
||||
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();
|
||||
options.renderer = Renderer::Glow;
|
||||
options.viewport = options.viewport.with_inner_size(Vec2::new(360., 720.));
|
||||
|
||||
let config = DesktopApp;
|
||||
let data_path = PathBuf::from("./.data");
|
||||
let config = DesktopApp::new(data_path.clone());
|
||||
let _res = eframe::run_native(
|
||||
"zap.stream",
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
97
src/lib.rs
97
src/lib.rs
@ -1,5 +1,8 @@
|
||||
#[cfg(target_os = "android")]
|
||||
mod android;
|
||||
pub mod app;
|
||||
mod link;
|
||||
mod login;
|
||||
mod note_store;
|
||||
mod note_util;
|
||||
mod route;
|
||||
@ -8,65 +11,51 @@ mod stream_info;
|
||||
mod theme;
|
||||
mod widgets;
|
||||
|
||||
use crate::app::{AppConfig, ZapStreamApp};
|
||||
use eframe::Renderer;
|
||||
use egui::{Margin, ViewportBuilder};
|
||||
use std::ops::{Div, Mul};
|
||||
#[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")]
|
||||
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")]
|
||||
#[no_mangle]
|
||||
#[tokio::main]
|
||||
pub async fn android_main(app: AndroidApp) {
|
||||
std::env::set_var("RUST_BACKTRACE", "full");
|
||||
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
|
||||
}
|
||||
}
|
||||
android::start_android(app);
|
||||
}
|
||||
|
85
src/login.rs
Normal file
85
src/login.rs
Normal 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)
|
||||
}
|
||||
}
|
@ -27,7 +27,7 @@ impl 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);
|
||||
new_notes
|
||||
.iter()
|
||||
|
@ -1,6 +1,7 @@
|
||||
use crate::route::{RouteAction, RouteServices, Routes};
|
||||
use crate::widgets::{Button, NostrWidget};
|
||||
use egui::{Color32, Response, RichText, Ui};
|
||||
use crate::login::LoginKind;
|
||||
use crate::route::{RouteServices, Routes};
|
||||
use crate::widgets::{Button, NativeTextInput, NostrWidget};
|
||||
use egui::{Color32, Frame, Margin, Response, RichText, Ui};
|
||||
use nostr_sdk::util::hex;
|
||||
|
||||
pub struct LoginPage {
|
||||
@ -18,17 +19,37 @@ impl LoginPage {
|
||||
}
|
||||
|
||||
impl NostrWidget for LoginPage {
|
||||
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response {
|
||||
ui.vertical_centered(|ui| {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
|
||||
Frame::none()
|
||||
.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("Pubkey");
|
||||
ui.text_edit_singleline(&mut self.key);
|
||||
let mut input = NativeTextInput::new(&mut self.key).with_hint_text("npub/nsec");
|
||||
input.render(ui, services);
|
||||
|
||||
if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
|
||||
if let Ok(pk) = hex::decode(&self.key) {
|
||||
if let Ok((hrp, key)) = bech32::decode(&self.key) {
|
||||
match hrp.to_lowercase().as_str() {
|
||||
"nsec" => {
|
||||
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.action(RouteAction::LoginPubkey(pk));
|
||||
services.login.login(LoginKind::PublicKey { key: pk });
|
||||
services.navigate(Routes::HomePage);
|
||||
return;
|
||||
}
|
||||
@ -40,5 +61,7 @@ impl NostrWidget for LoginPage {
|
||||
}
|
||||
})
|
||||
.response
|
||||
})
|
||||
.inner
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
use crate::app::NativeLayerOps;
|
||||
use crate::link::NostrLink;
|
||||
use crate::login::{Login, LoginKind};
|
||||
use crate::note_util::OwnedNote;
|
||||
use crate::route::home::HomePage;
|
||||
use crate::route::login::LoginPage;
|
||||
@ -9,7 +11,7 @@ use crate::widgets::{Header, NostrWidget};
|
||||
use egui::{Context, Response, Ui};
|
||||
use egui_inbox::{UiInbox, UiInboxSender};
|
||||
use log::{info, warn};
|
||||
use nostr_sdk::Client;
|
||||
use nostr_sdk::{Client, Event, JsonUtil};
|
||||
use nostrdb::{Ndb, Transaction};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@ -36,24 +38,40 @@ pub enum Routes {
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum RouteAction {
|
||||
/// Login with public key
|
||||
LoginPubkey([u8; 32]),
|
||||
ShowKeyboard,
|
||||
HideKeyboard,
|
||||
}
|
||||
|
||||
pub struct Router {
|
||||
pub struct Router<T: NativeLayerOps> {
|
||||
current: Routes,
|
||||
current_widget: Option<Box<dyn NostrWidget>>,
|
||||
router: UiInbox<Routes>,
|
||||
|
||||
ctx: Context,
|
||||
ndb: NDBWrapper,
|
||||
login: Option<[u8; 32]>,
|
||||
login: Login,
|
||||
client: Client,
|
||||
image_cache: ImageCache,
|
||||
native_layer: T,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
pub fn new(data_path: PathBuf, ctx: Context, client: Client, ndb: Ndb) -> Self {
|
||||
impl<T: NativeLayerOps> Drop for Router<T> {
|
||||
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 {
|
||||
current: Routes::HomePage,
|
||||
current_widget: None,
|
||||
@ -61,8 +79,9 @@ impl Router {
|
||||
ctx: ctx.clone(),
|
||||
ndb: NDBWrapper::new(ctx.clone(), ndb.clone(), client.clone()),
|
||||
client,
|
||||
login: None,
|
||||
login,
|
||||
image_cache: ImageCache::new(data_path, ctx.clone()),
|
||||
native_layer,
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,9 +110,10 @@ impl Router {
|
||||
// handle app state changes
|
||||
let q = self.router.read(ui);
|
||||
for r in q {
|
||||
if let Routes::Action(a) = &r {
|
||||
if let Routes::Action(a) = r {
|
||||
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"),
|
||||
}
|
||||
} else {
|
||||
@ -106,20 +126,21 @@ impl Router {
|
||||
self.load_widget(Routes::HomePage, &tx);
|
||||
}
|
||||
|
||||
let svc = RouteServices {
|
||||
let mut svc = RouteServices {
|
||||
context: self.ctx.clone(),
|
||||
router: self.router.sender(),
|
||||
client: self.client.clone(),
|
||||
ndb: &self.ndb,
|
||||
tx: &tx,
|
||||
login: &self.login,
|
||||
login: &mut self.login,
|
||||
img_cache: &self.image_cache,
|
||||
};
|
||||
|
||||
// display app
|
||||
ui.vertical(|ui| {
|
||||
Header::new().render(ui, &svc);
|
||||
Header::new().render(ui, &mut svc);
|
||||
if let Some(w) = self.current_widget.as_mut() {
|
||||
w.render(ui, &svc)
|
||||
w.render(ui, &mut svc)
|
||||
} else {
|
||||
ui.label("No widget")
|
||||
}
|
||||
@ -131,11 +152,12 @@ impl Router {
|
||||
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 Option<[u8; 32]>, //ref
|
||||
pub img_cache: &'a ImageCache,
|
||||
pub login: &'a mut Login, //ref
|
||||
pub img_cache: &'a ImageCache, //ref
|
||||
}
|
||||
|
||||
impl<'a> RouteServices<'a> {
|
||||
@ -150,4 +172,21 @@ impl<'a> RouteServices<'a> {
|
||||
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),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -23,18 +23,18 @@ impl StreamPage {
|
||||
let f = [f.limit_mut(1)];
|
||||
let (sub, events) = ndb.subscribe_with_results("streams", &f, tx, 1);
|
||||
Self {
|
||||
link,
|
||||
link: link.clone(),
|
||||
sub,
|
||||
event: events.first().map(|n| OwnedNote(n.note_key.as_u64())),
|
||||
chat: None,
|
||||
player: None,
|
||||
new_msg: WriteChat::new(),
|
||||
new_msg: WriteChat::new(link),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if let Some(k) = poll.first() {
|
||||
self.event = Some(OwnedNote(k.as_u64()))
|
||||
|
@ -154,4 +154,8 @@ impl NDBWrapper {
|
||||
let sub = None;
|
||||
(p, sub)
|
||||
}
|
||||
|
||||
pub fn submit_event(&self, ev: &str) -> Result<(), Error> {
|
||||
self.ndb.process_event(ev)
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
use egui::Color32;
|
||||
use egui::{Color32, Margin};
|
||||
|
||||
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 NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115);
|
||||
pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38);
|
||||
|
@ -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};
|
||||
|
||||
pub struct Button {
|
||||
@ -17,7 +17,7 @@ impl Button {
|
||||
let r = Frame::none()
|
||||
.inner_margin(Margin::symmetric(12., 8.))
|
||||
.fill(self.color)
|
||||
.rounding(12.)
|
||||
.rounding(ROUNDING_DEFAULT)
|
||||
.show(ui, add_contents);
|
||||
|
||||
let id = r.response.id;
|
||||
|
@ -39,7 +39,7 @@ impl 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);
|
||||
poll.iter()
|
||||
.for_each(|n| self.events.push(OwnedNote(n.as_u64())));
|
||||
|
@ -1,3 +1,4 @@
|
||||
use crate::login::LoginKind;
|
||||
use crate::route::{RouteServices, Routes};
|
||||
use crate::widgets::avatar::Avatar;
|
||||
use crate::widgets::{Button, NostrWidget};
|
||||
@ -14,7 +15,7 @@ impl 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");
|
||||
Frame::none()
|
||||
.outer_margin(Margin::symmetric(16., 8.))
|
||||
@ -37,8 +38,8 @@ impl NostrWidget for Header {
|
||||
}
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if let Some(pk) = services.login {
|
||||
ui.add(Avatar::pubkey(pk, services));
|
||||
if let Some(pk) = services.login.public_key() {
|
||||
ui.add(Avatar::pubkey(&pk, services));
|
||||
} else if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
|
||||
services.navigate(Routes::LoginPage);
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ mod stream_list;
|
||||
mod stream_player;
|
||||
mod stream_tile;
|
||||
mod stream_title;
|
||||
mod text_input;
|
||||
mod username;
|
||||
mod video_placeholder;
|
||||
mod write_chat;
|
||||
@ -16,7 +17,7 @@ use crate::route::RouteServices;
|
||||
use egui::{Response, Ui};
|
||||
|
||||
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;
|
||||
@ -27,6 +28,7 @@ pub use self::profile::Profile;
|
||||
pub use self::stream_list::StreamList;
|
||||
pub use self::stream_player::StreamPlayer;
|
||||
pub use self::stream_title::StreamTitle;
|
||||
pub use self::text_input::NativeTextInput;
|
||||
pub use self::username::Username;
|
||||
pub use self::video_placeholder::VideoPlaceholder;
|
||||
pub use self::write_chat::WriteChat;
|
||||
|
@ -1,7 +1,7 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::{RouteServices, Routes};
|
||||
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 eframe::epaint::{Rounding, Vec2};
|
||||
use egui::epaint::RectShape;
|
||||
@ -44,7 +44,7 @@ impl Widget for StreamEvent<'_> {
|
||||
Ok(TexturePoll::Ready { texture }) => {
|
||||
painter.add(RectShape {
|
||||
rect: response.rect,
|
||||
rounding: Rounding::same(12.),
|
||||
rounding: Rounding::same(ROUNDING_DEFAULT),
|
||||
fill: Color32::WHITE,
|
||||
stroke: Default::default(),
|
||||
blur_width: 0.0,
|
||||
|
@ -16,7 +16,7 @@ impl<'a> 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()
|
||||
.outer_margin(Margin::symmetric(12., 8.))
|
||||
.show(ui, |ui| {
|
||||
|
46
src/widgets/text_input.rs
Normal file
46
src/widgets/text_input.rs
Normal 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
|
||||
}
|
||||
}
|
@ -1,32 +1,36 @@
|
||||
use crate::route::RouteServices;
|
||||
use crate::theme::NEUTRAL_900;
|
||||
use crate::widgets::NostrWidget;
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::{RouteAction, RouteServices};
|
||||
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT};
|
||||
use crate::widgets::{NativeTextInput, NostrWidget};
|
||||
use eframe::emath::Align;
|
||||
use egui::{Frame, Image, Layout, Margin, Response, Rounding, Sense, Stroke, TextEdit, Ui, Widget};
|
||||
use log::info;
|
||||
|
||||
pub struct WriteChat {
|
||||
link: NostrLink,
|
||||
msg: String,
|
||||
}
|
||||
|
||||
impl WriteChat {
|
||||
pub fn new() -> Self {
|
||||
Self { msg: String::new() }
|
||||
pub fn new(link: NostrLink) -> Self {
|
||||
Self {
|
||||
link,
|
||||
msg: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrWidget for WriteChat {
|
||||
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response {
|
||||
let size = ui.available_size();
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
|
||||
let logo_bytes = include_bytes!("../resources/send-03.svg");
|
||||
Frame::none()
|
||||
.inner_margin(Margin::symmetric(12., 6.))
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
.stroke(Stroke::new(1.0, NEUTRAL_900))
|
||||
.show(ui, |ui| {
|
||||
Frame::none()
|
||||
.fill(NEUTRAL_900)
|
||||
.rounding(Rounding::same(12.0))
|
||||
.inner_margin(Margin::symmetric(12., 10.))
|
||||
.rounding(ROUNDING_DEFAULT)
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if services
|
||||
@ -36,12 +40,19 @@ impl NostrWidget for WriteChat {
|
||||
.ui(ui)
|
||||
.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();
|
||||
}
|
||||
|
||||
let editor = TextEdit::singleline(&mut self.msg).frame(false);
|
||||
ui.add_sized(ui.available_size(), editor);
|
||||
ui.allocate_ui(ui.available_size(), |ui| {
|
||||
let mut editor = NativeTextInput::new(&mut self.msg);
|
||||
editor.render(ui, services);
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user