wip notedeck
This commit is contained in:
parent
c8c5485581
commit
0e19c1a8f3
908
Cargo.lock
generated
908
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
26
Cargo.toml
@ -7,34 +7,30 @@ edition = "2021"
|
||||
crate-type = ["lib", "cdylib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
notedeck = ["dep:notedeck", "dep:notedeck-chrome"]
|
||||
default = ["notedeck"]
|
||||
notedeck = ["dep:notedeck", "dep:notedeck-chrome", "dep:enostr"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.40.0", features = ["fs", "rt-multi-thread", "rt"] }
|
||||
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"
|
||||
pretty_env_logger = "0.5.0"
|
||||
egui_inbox = "0.6.0"
|
||||
bech32 = "0.11.0"
|
||||
libc = "0.2.158"
|
||||
uuid = { version = "1.11.0", features = ["v4"] }
|
||||
chrono = "0.4.38"
|
||||
anyhow = "^1.0.91"
|
||||
async-trait = "0.1.83"
|
||||
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_with = { version = "3.11.0", features = ["hex"] }
|
||||
directories = "5.0.1"
|
||||
egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "d2ea3b4db21eb870a207db19e4cd21c7d1d24836" }
|
||||
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"] }
|
||||
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "2111948b078b24a1659d0bd5d8570f370269c99b" }
|
||||
notedeck-chrome = { git = "https://git.v0l.io/nostr/notedeck.git", rev = "e08e30f9125b9cf7391e97a2683ba0034bff1644", package = "notedeck_chrome", optional = true }
|
||||
notedeck = { git = "https://git.v0l.io/nostr/notedeck.git", rev = "e08e30f9125b9cf7391e97a2683ba0034bff1644", package = "notedeck", optional = true }
|
||||
enostr = { git = "https://git.v0l.io/nostr/notedeck.git", rev = "e08e30f9125b9cf7391e97a2683ba0034bff1644", package = "enostr", optional = true }
|
||||
poll-promise = "0.3.0"
|
||||
ehttp = "0.5.0"
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
eframe = { version = "0.29.1" }
|
||||
|
@ -1,9 +1,6 @@
|
||||
use crate::app::{NativeLayerOps, ZapStreamApp};
|
||||
use crate::app::ZapStreamApp;
|
||||
use eframe::Renderer;
|
||||
use egui::{Margin, ViewportBuilder};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::ops::Div;
|
||||
use egui::ViewportBuilder;
|
||||
use winit::platform::android::activity::AndroidApp;
|
||||
use winit::platform::android::EventLoopBuilderExtAndroid;
|
||||
|
||||
@ -34,57 +31,17 @@ pub fn start_android(app: AndroidApp) {
|
||||
if let Err(e) = eframe::run_native(
|
||||
"zap.stream",
|
||||
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 app = ZapStreamApp::new(cc);
|
||||
notedeck.add_app(app);
|
||||
|
||||
Ok(Box::new(notedeck))
|
||||
}),
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
209
src/app.rs
209
src/app.rs
@ -1,67 +1,26 @@
|
||||
use crate::route::Router;
|
||||
use eframe::epaint::FontFamily;
|
||||
use crate::route::{page, RouteServices, RouteType};
|
||||
use crate::widgets::{Header, NostrWidget};
|
||||
use eframe::epaint::{FontFamily, Margin};
|
||||
use eframe::CreationContext;
|
||||
use egui::{Color32, FontData, FontDefinitions, Margin};
|
||||
use nostr_sdk::prelude::MemoryDatabase;
|
||||
use nostr_sdk::Client;
|
||||
use nostrdb::{Config, Ndb};
|
||||
use egui::{Color32, FontData, FontDefinitions, Ui};
|
||||
use enostr::ewebsock::{WsEvent, WsMessage};
|
||||
use enostr::{PoolEvent, RelayEvent, RelayMessage};
|
||||
use log::{error, info, warn};
|
||||
use nostrdb::Transaction;
|
||||
use notedeck::AppContext;
|
||||
use std::path::PathBuf;
|
||||
use std::ops::Div;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub struct ZapStreamApp<T: NativeLayerOps> {
|
||||
client: Client,
|
||||
router: Router<T>,
|
||||
native_layer: T,
|
||||
pub struct ZapStreamApp {
|
||||
current: RouteType,
|
||||
routes_rx: mpsc::Receiver<RouteType>,
|
||||
routes_tx: mpsc::Sender<RouteType>,
|
||||
|
||||
widget: Box<dyn NostrWidget>,
|
||||
}
|
||||
|
||||
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>
|
||||
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();
|
||||
|
||||
impl ZapStreamApp {
|
||||
pub fn new(cc: &CreationContext) -> Self {
|
||||
let mut fd = FontDefinitions::default();
|
||||
fd.font_data.insert(
|
||||
"Outfit".to_string(),
|
||||
@ -71,63 +30,113 @@ where
|
||||
.insert(FontFamily::Proportional, vec!["Outfit".to_string()]);
|
||||
cc.egui_ctx.set_fonts(fd);
|
||||
|
||||
let cfg = config.clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
Self {
|
||||
client: client.clone(),
|
||||
router: Router::new(
|
||||
data_path,
|
||||
cc.egui_ctx.clone(),
|
||||
client.clone(),
|
||||
ndb.clone(),
|
||||
cfg,
|
||||
),
|
||||
native_layer: config,
|
||||
current: RouteType::HomePage,
|
||||
widget: Box::new(page::HomePage::new()),
|
||||
routes_tx: tx,
|
||||
routes_rx: rx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "notedeck"))]
|
||||
impl<T> App for ZapStreamApp<T>
|
||||
where
|
||||
T: NativeLayerOps,
|
||||
{
|
||||
fn update(&mut self, ctx: &Context, frame: &mut Frame) {
|
||||
impl notedeck::App for ZapStreamApp {
|
||||
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut Ui) {
|
||||
ctx.accounts.update(ctx.ndb, ctx.pool, ui.ctx());
|
||||
while let Some(PoolEvent { event, relay }) = ctx.pool.try_recv() {
|
||||
match (&event).into() {
|
||||
RelayEvent::Message(msg) => match msg {
|
||||
RelayMessage::OK(_) => {}
|
||||
RelayMessage::Eose(_) => {}
|
||||
RelayMessage::Event(_sub, ev) => {
|
||||
if let Err(e) = ctx.ndb.process_event(ev) {
|
||||
error!("Error processing event: {:?}", e);
|
||||
}
|
||||
}
|
||||
RelayMessage::Notice(m) => warn!("Notice from {}: {}", relay, m),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut app_frame = egui::containers::Frame::default();
|
||||
let margin = self.native_layer.frame_margin();
|
||||
let margin = self.frame_margin();
|
||||
|
||||
app_frame.inner_margin = margin;
|
||||
app_frame.stroke.color = Color32::BLACK;
|
||||
|
||||
//ctx.set_debug_on_hover(true);
|
||||
|
||||
// handle app state changes
|
||||
while let Ok(r) = self.routes_rx.try_recv() {
|
||||
if let RouteType::Action(a) = r {
|
||||
match a {
|
||||
_ => 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::Action { .. } => panic!("Actions!"),
|
||||
_ => panic!("Not implemented"),
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::CentralPanel::default()
|
||||
.frame(app_frame)
|
||||
.show(ctx, |ui| {
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.visuals_mut().override_text_color = Some(Color32::WHITE);
|
||||
self.router.show(ui);
|
||||
|
||||
// display app
|
||||
ui.vertical(|ui| {
|
||||
let mut svc = RouteServices {
|
||||
router: self.routes_tx.clone(),
|
||||
tx: Transaction::new(ctx.ndb).expect("transaction"),
|
||||
egui: ui.ctx().clone(),
|
||||
ctx,
|
||||
};
|
||||
Header::new().render(ui, &mut svc);
|
||||
if let Err(e) = self.widget.update(&mut svc) {
|
||||
error!("{}", e);
|
||||
}
|
||||
self.widget.render(ui, &mut svc);
|
||||
})
|
||||
.response
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "notedeck")]
|
||||
impl<T> notedeck::App for ZapStreamApp<T>
|
||||
where
|
||||
T: NativeLayerOps,
|
||||
{
|
||||
fn update(&mut self, ctx: &mut AppContext<'_>) {
|
||||
let mut app_frame = egui::containers::Frame::default();
|
||||
let margin = self.native_layer.frame_margin();
|
||||
|
||||
app_frame.inner_margin = margin;
|
||||
app_frame.stroke.color = Color32::BLACK;
|
||||
|
||||
//ctx.set_debug_on_hover(true);
|
||||
|
||||
egui::CentralPanel::default()
|
||||
.frame(app_frame)
|
||||
.show(ctx.egui, |ui| {
|
||||
ui.visuals_mut().override_text_color = Some(Color32::WHITE);
|
||||
self.router.show(ui);
|
||||
});
|
||||
#[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.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use directories::ProjectDirs;
|
||||
use eframe::Renderer;
|
||||
use egui::{Margin, Vec2, ViewportBuilder};
|
||||
use egui::{Vec2, ViewportBuilder};
|
||||
use log::error;
|
||||
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::sync::{Arc, RwLock};
|
||||
use zap_stream_app::app::{NativeLayerOps, ZapStreamApp};
|
||||
use zap_stream_app::app::ZapStreamApp;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@ -26,8 +18,6 @@ async fn main() -> Result<()> {
|
||||
.config_dir()
|
||||
.to_path_buf();
|
||||
|
||||
let config = DesktopApp::new(data_path.clone());
|
||||
#[cfg(feature = "notedeck")]
|
||||
if let Err(e) = eframe::run_native(
|
||||
"zap.stream",
|
||||
options,
|
||||
@ -36,7 +26,7 @@ async fn main() -> Result<()> {
|
||||
let mut notedeck =
|
||||
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);
|
||||
|
||||
Ok(Box::new(notedeck))
|
||||
@ -44,93 +34,5 @@ async fn main() -> Result<()> {
|
||||
) {
|
||||
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(())
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
|
@ -2,14 +2,14 @@
|
||||
mod android;
|
||||
pub mod app;
|
||||
mod link;
|
||||
mod login;
|
||||
mod note_store;
|
||||
mod note_util;
|
||||
mod note_view;
|
||||
mod route;
|
||||
mod services;
|
||||
mod stream_info;
|
||||
mod theme;
|
||||
mod widgets;
|
||||
mod note_ref;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use android_activity::AndroidApp;
|
||||
|
@ -1,7 +1,7 @@
|
||||
use crate::note_util::NoteUtil;
|
||||
use bech32::{Hrp, NoChecksum};
|
||||
use egui::TextBuffer;
|
||||
use nostr_sdk::util::hex;
|
||||
use nostr::prelude::hex;
|
||||
use nostrdb::{Filter, Note};
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
|
89
src/login.rs
89
src/login.rs
@ -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
43
src/note_ref.rs
Normal 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))
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
use nostr_sdk::util::hex;
|
||||
use nostr::prelude::hex;
|
||||
use nostrdb::{NdbStr, Note, Tag};
|
||||
|
||||
pub trait NoteUtil {
|
||||
@ -64,6 +64,3 @@ impl<'a> Iterator for TagIterBorrow<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq)]
|
||||
pub struct OwnedNote(pub u64);
|
||||
|
@ -2,11 +2,11 @@ use crate::link::NostrLink;
|
||||
use nostrdb::Note;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct NoteStore<'a> {
|
||||
pub struct NotesView<'a> {
|
||||
events: HashMap<String, &'a Note<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> NoteStore<'a> {
|
||||
impl<'a> NotesView<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: HashMap::new(),
|
@ -1,96 +1,93 @@
|
||||
use crate::note_store::NoteStore;
|
||||
use crate::note_util::OwnedNote;
|
||||
use crate::note_ref::NoteRef;
|
||||
use crate::note_view::NotesView;
|
||||
use crate::route::RouteServices;
|
||||
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper};
|
||||
use crate::stream_info::{StreamInfo, StreamStatus};
|
||||
use crate::widgets;
|
||||
use crate::widgets::NostrWidget;
|
||||
use egui::{Id, Response, RichText, ScrollArea, Ui, Widget};
|
||||
use nostrdb::{Filter, Note, NoteKey, Transaction};
|
||||
use crate::widgets::{sub_or_poll, NostrWidget};
|
||||
use egui::{Id, Response, RichText, ScrollArea, Ui};
|
||||
use nostrdb::{Filter, Note, Subscription};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct HomePage {
|
||||
sub: SubWrapper,
|
||||
events: Vec<OwnedNote>,
|
||||
events: HashSet<NoteRef>,
|
||||
sub: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl HomePage {
|
||||
pub fn new(ndb: &NDBWrapper, tx: &Transaction) -> Self {
|
||||
let filter = [Filter::new().kinds([30_311]).limit(100).build()];
|
||||
let (sub, events) = ndb.subscribe_with_results("home-page", &filter, tx, 1000);
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub,
|
||||
events: events
|
||||
.iter()
|
||||
.map(|e| OwnedNote(e.note_key.as_u64()))
|
||||
.collect(),
|
||||
events: HashSet::new(),
|
||||
sub: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filters() -> Vec<Filter> {
|
||||
vec![Filter::new().kinds([30_311]).limit(100).build()]
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrWidget for HomePage {
|
||||
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();
|
||||
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
ScrollArea::vertical()
|
||||
.show(ui, |ui| {
|
||||
let events_live = NoteStore::from_vec(
|
||||
events
|
||||
let events: Vec<Note> = self
|
||||
.events
|
||||
.iter()
|
||||
.map_while(|n| services.ctx.ndb.get_note_by_key(&services.tx, n.key).ok())
|
||||
.collect();
|
||||
|
||||
let events_live = NotesView::from_vec(
|
||||
events.iter()
|
||||
.filter(|r| matches!(r.status(), StreamStatus::Live))
|
||||
.collect(),
|
||||
);
|
||||
if events_live.len() > 0 {
|
||||
widgets::StreamList::new(
|
||||
Id::new("live-streams"),
|
||||
&events_live,
|
||||
services,
|
||||
events_live,
|
||||
Some(RichText::new("Live").size(32.0)),
|
||||
)
|
||||
.ui(ui);
|
||||
.render(ui, services);
|
||||
}
|
||||
let events_planned = NoteStore::from_vec(
|
||||
events
|
||||
.iter()
|
||||
let events_planned = NotesView::from_vec(
|
||||
events.iter()
|
||||
.filter(|r| matches!(r.status(), StreamStatus::Planned))
|
||||
.collect(),
|
||||
);
|
||||
if events_planned.len() > 0 {
|
||||
widgets::StreamList::new(
|
||||
Id::new("planned-streams"),
|
||||
&events_planned,
|
||||
services,
|
||||
events_planned,
|
||||
Some(RichText::new("Planned").size(32.0)),
|
||||
)
|
||||
.ui(ui);
|
||||
.render(ui, services);
|
||||
}
|
||||
let events_ended = NoteStore::from_vec(
|
||||
events
|
||||
.iter()
|
||||
let events_ended = NotesView::from_vec(
|
||||
events.iter()
|
||||
.filter(|r| matches!(r.status(), StreamStatus::Ended))
|
||||
.collect(),
|
||||
);
|
||||
if events_ended.len() > 0 {
|
||||
widgets::StreamList::new(
|
||||
Id::new("ended-streams"),
|
||||
&events_ended,
|
||||
services,
|
||||
events_ended,
|
||||
Some(RichText::new("Ended").size(32.0)),
|
||||
)
|
||||
.ui(ui);
|
||||
.render(ui, services);
|
||||
}
|
||||
ui.response()
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
sub_or_poll(
|
||||
services.ctx.ndb,
|
||||
&services.tx,
|
||||
&mut services.ctx.pool,
|
||||
&mut self.events,
|
||||
&mut self.sub,
|
||||
Self::get_filters(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
use crate::login::LoginKind;
|
||||
use crate::route::{RouteServices, Routes};
|
||||
use crate::route::{RouteServices, RouteType};
|
||||
use crate::widgets::{Button, NativeTextInput, NostrWidget};
|
||||
use egui::{Color32, Frame, Margin, Response, RichText, Ui};
|
||||
use nostr_sdk::util::hex;
|
||||
use nostr::prelude::hex;
|
||||
use nostr::SecretKey;
|
||||
|
||||
pub struct LoginPage {
|
||||
key: String,
|
||||
@ -19,7 +19,7 @@ impl 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()
|
||||
.inner_margin(Margin::same(12.))
|
||||
.show(ui, |ui| {
|
||||
@ -27,30 +27,53 @@ impl NostrWidget for LoginPage {
|
||||
ui.spacing_mut().item_spacing.y = 8.;
|
||||
|
||||
ui.label(RichText::new("Login").size(32.));
|
||||
let mut input = NativeTextInput::new(&mut self.key).with_hint_text("npub/nsec");
|
||||
input.render(ui, services);
|
||||
let input = NativeTextInput::new(&mut self.key).with_hint_text("npub/nsec");
|
||||
ui.add(input);
|
||||
|
||||
if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
|
||||
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);
|
||||
let mut ids = services.ctx.accounts.add_account(
|
||||
enostr::Keypair::from_secret(
|
||||
SecretKey::from_slice(key.as_slice()).unwrap(),
|
||||
),
|
||||
);
|
||||
ids.process_action(
|
||||
services.ctx.unknown_ids,
|
||||
services.ctx.ndb,
|
||||
&services.tx,
|
||||
);
|
||||
services.ctx.accounts.select_account(0);
|
||||
services.navigate(RouteType::HomePage);
|
||||
}
|
||||
"npub" | "nprofile" => {
|
||||
services.login.login(LoginKind::PublicKey {
|
||||
key: key.as_slice().try_into().unwrap(),
|
||||
});
|
||||
services.navigate(Routes::HomePage);
|
||||
let mut ids =
|
||||
services.ctx.accounts.add_account(enostr::Keypair::new(
|
||||
enostr::Pubkey::new(key.as_slice().try_into().unwrap()),
|
||||
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) {
|
||||
if let Ok(pk) = pk.as_slice().try_into() {
|
||||
services.login.login(LoginKind::PublicKey { key: pk });
|
||||
services.navigate(Routes::HomePage);
|
||||
let mut ids = services.ctx.accounts.add_account(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -64,4 +87,8 @@ impl NostrWidget for LoginPage {
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn update(&mut self, _services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
288
src/route/mod.rs
288
src/route/mod.rs
@ -1,34 +1,43 @@
|
||||
use crate::app::NativeLayerOps;
|
||||
use crate::link::NostrLink;
|
||||
use crate::login::Login;
|
||||
use crate::note_util::OwnedNote;
|
||||
use crate::route::home::HomePage;
|
||||
use crate::route::login::LoginPage;
|
||||
use crate::route::stream::StreamPage;
|
||||
use crate::services::image_cache::ImageCache;
|
||||
use crate::services::ndb_wrapper::NDBWrapper;
|
||||
use crate::widgets::{Header, NostrWidget};
|
||||
use egui::{Context, Response, Ui};
|
||||
use egui_inbox::{UiInbox, UiInboxSender};
|
||||
use crate::services::ffmpeg_loader::FfmpegLoader;
|
||||
use crate::widgets::{Header, NostrWidget, PlaceholderRect};
|
||||
use anyhow::{bail, Result};
|
||||
use egui::{Context, Image, Response, TextureHandle, Ui};
|
||||
use egui_inbox::{RequestRepaintTrait, UiInbox, UiInboxSender};
|
||||
use enostr::{EventClientMessage, Note};
|
||||
use itertools::Itertools;
|
||||
use log::{info, warn};
|
||||
use nostr_sdk::{Client, Event, JsonUtil};
|
||||
use nostrdb::{Ndb, Transaction};
|
||||
use std::path::PathBuf;
|
||||
use nostr::{ClientMessage, Event, EventBuilder, JsonUtil, Kind, Tag};
|
||||
use nostrdb::{Ndb, NdbProfile, NoteKey, Transaction};
|
||||
use notedeck::{AppContext, ImageCache};
|
||||
use poll_promise::Promise;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
|
||||
mod home;
|
||||
mod login;
|
||||
mod stream;
|
||||
|
||||
pub mod page {
|
||||
use crate::route::{home, login, stream};
|
||||
pub use home::HomePage;
|
||||
pub use login::LoginPage;
|
||||
pub use stream::StreamPage;
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum Routes {
|
||||
pub enum RouteType {
|
||||
HomePage,
|
||||
EventPage {
|
||||
link: NostrLink,
|
||||
event: Option<OwnedNote>,
|
||||
event: Option<NoteKey>,
|
||||
},
|
||||
ProfilePage {
|
||||
link: NostrLink,
|
||||
profile: Option<OwnedNote>,
|
||||
profile: Option<NoteKey>,
|
||||
},
|
||||
LoginPage,
|
||||
|
||||
@ -37,156 +46,129 @@ pub enum Routes {
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum RouteAction {
|
||||
ShowKeyboard,
|
||||
HideKeyboard,
|
||||
pub enum RouteAction {}
|
||||
|
||||
pub struct RouteServices<'a, 'ctx> {
|
||||
pub router: mpsc::Sender<RouteType>,
|
||||
pub tx: Transaction,
|
||||
pub egui: Context,
|
||||
pub ctx: &'a mut AppContext<'ctx>,
|
||||
}
|
||||
|
||||
pub struct Router<T: NativeLayerOps> {
|
||||
current: Routes,
|
||||
current_widget: Option<Box<dyn NostrWidget>>,
|
||||
router: UiInbox<Routes>,
|
||||
|
||||
ctx: Context,
|
||||
ndb: NDBWrapper,
|
||||
login: Login,
|
||||
client: Client,
|
||||
image_cache: ImageCache,
|
||||
native_layer: T,
|
||||
}
|
||||
|
||||
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,
|
||||
router: UiInbox::new(),
|
||||
ctx: ctx.clone(),
|
||||
ndb: NDBWrapper::new(ctx.clone(), ndb.clone(), client.clone()),
|
||||
client,
|
||||
login,
|
||||
image_cache: ImageCache::new(data_path, ctx.clone()),
|
||||
native_layer,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_widget(&mut self, route: Routes, tx: &Transaction) {
|
||||
match &route {
|
||||
Routes::HomePage => {
|
||||
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");
|
||||
}
|
||||
impl<'a, 'ctx> RouteServices<'a, 'ctx> {
|
||||
pub fn navigate(&self, route: RouteType) {
|
||||
self.router.send(route).expect("route send failed");
|
||||
self.egui.request_repaint();
|
||||
}
|
||||
|
||||
pub fn action(&self, route: RouteAction) {
|
||||
if let Err(e) = self.router.send(Routes::Action(route)) {
|
||||
warn!("Failed to navigate");
|
||||
}
|
||||
self.router
|
||||
.send(RouteType::Action(route))
|
||||
.expect("route send failed");
|
||||
self.egui.request_repaint();
|
||||
}
|
||||
|
||||
pub fn broadcast_event(&self, event: Event) {
|
||||
let client = self.client.clone();
|
||||
|
||||
pub fn broadcast_event(&mut self, event: Event) {
|
||||
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);
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
match client.send_event(event).await {
|
||||
Ok(e) => {
|
||||
info!("Broadcast event: {:?}", e)
|
||||
self.ctx
|
||||
.pool
|
||||
.send(&enostr::ClientMessage::Event(EventClientMessage {
|
||||
note_json: ev_json,
|
||||
}));
|
||||
}
|
||||
Err(e) => warn!("Failed to broadcast event: {:?}", e),
|
||||
|
||||
/// Load/Fetch profiles
|
||||
pub fn profile(&self, pk: &[u8; 32]) -> Option<NdbProfile<'a>> {
|
||||
// TODO
|
||||
None
|
||||
}
|
||||
|
||||
/// Load image from URL
|
||||
pub fn image<'img, 'b>(&'b mut self, url: &'b str) -> Image<'img> {
|
||||
image_from_cache(self.ctx.img_cache, &self.egui, url)
|
||||
}
|
||||
|
||||
/// Load image from bytes
|
||||
pub fn image_bytes(&self, name: &'static str, data: &'static [u8]) -> Image<'_> {
|
||||
// TODO: loader
|
||||
Image::from_bytes(name, data)
|
||||
}
|
||||
|
||||
pub fn write_live_chat_msg(&self, link: &NostrLink, msg: &str) -> Option<Event> {
|
||||
if msg.len() == 0 {
|
||||
return None;
|
||||
}
|
||||
if let Some(acc) = self.ctx.accounts.get_selected_account() {
|
||||
if let Some(key) = &acc.secret_key {
|
||||
let nostr_key =
|
||||
nostr::Keys::new(nostr::SecretKey::from_slice(key.as_secret_bytes()).unwrap());
|
||||
return Some(
|
||||
EventBuilder::new(Kind::LiveEventMessage, msg)
|
||||
.tag(Tag::parse(&link.to_tag()).unwrap())
|
||||
.sign_with_keys(&nostr_key)
|
||||
.ok()?,
|
||||
);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn image_from_cache<'a>(img_cache: &mut ImageCache, ctx: &Context, url: &str) -> Image<'a> {
|
||||
let m_cached_promise = img_cache.map().get(url);
|
||||
if m_cached_promise.is_none() {
|
||||
let fetch = fetch_img(img_cache, ctx, url);
|
||||
img_cache.map_mut().insert(url.to_string(), fetch);
|
||||
}
|
||||
Image::new(url.to_string())
|
||||
}
|
||||
|
||||
fn fetch_img(
|
||||
img_cache: &ImageCache,
|
||||
ctx: &Context,
|
||||
url: &str,
|
||||
) -> Promise<notedeck::Result<TextureHandle>> {
|
||||
let k = ImageCache::key(url);
|
||||
let dst_path = img_cache.cache_dir.join(k);
|
||||
if dst_path.exists() {
|
||||
let ctx = ctx.clone();
|
||||
let url = url.to_owned();
|
||||
let dst_path = dst_path.clone();
|
||||
Promise::spawn_async(async move {
|
||||
match FfmpegLoader::new().load_image(dst_path) {
|
||||
Ok(img) => Ok(ctx.load_texture(&url, img, Default::default())),
|
||||
Err(e) => Err(notedeck::Error::Generic(e.to_string())),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
fetch_img_from_net(&dst_path, ctx, url)
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_img_from_net(
|
||||
cache_path: &Path,
|
||||
ctx: &Context,
|
||||
url: &str,
|
||||
) -> Promise<notedeck::Result<TextureHandle>> {
|
||||
let (sender, promise) = Promise::new();
|
||||
let request = ehttp::Request::get(url);
|
||||
let ctx = ctx.clone();
|
||||
let cloned_url = url.to_owned();
|
||||
let cache_path = cache_path.to_owned();
|
||||
ehttp::fetch(request, move |response| {
|
||||
let handle = response.map_err(notedeck::Error::Generic).map(|img| {
|
||||
std::fs::write(&cache_path, &img.bytes).unwrap();
|
||||
let img_loaded = FfmpegLoader::new().load_image(cache_path).unwrap();
|
||||
|
||||
ctx.load_texture(&cloned_url, img_loaded, Default::default())
|
||||
});
|
||||
}
|
||||
|
||||
sender.send(handle);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
|
||||
promise
|
||||
}
|
||||
|
@ -1,42 +1,51 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::note_util::OwnedNote;
|
||||
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::widgets::{Chat, NostrWidget, PlaceholderRect, StreamPlayer, StreamTitle, WriteChat};
|
||||
use crate::widgets::{
|
||||
sub_or_poll, Chat, NostrWidget, PlaceholderRect, StreamPlayer, StreamTitle, WriteChat,
|
||||
};
|
||||
use egui::{vec2, Align, Frame, Layout, Response, Stroke, Ui, Vec2, Widget};
|
||||
use nostrdb::{Filter, Note, NoteKey, Transaction};
|
||||
use nostrdb::{Filter, Note, NoteKey, Subscription};
|
||||
|
||||
use crate::note_ref::NoteRef;
|
||||
use std::borrow::Borrow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct StreamPage {
|
||||
link: NostrLink,
|
||||
event: Option<OwnedNote>,
|
||||
event: Option<NoteKey>,
|
||||
player: Option<StreamPlayer>,
|
||||
chat: Option<Chat>,
|
||||
sub: SubWrapper,
|
||||
new_msg: WriteChat,
|
||||
|
||||
events: HashSet<NoteRef>,
|
||||
sub: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl StreamPage {
|
||||
pub fn new_from_link(ndb: &NDBWrapper, tx: &Transaction, 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);
|
||||
pub fn new_from_link(link: NostrLink) -> Self {
|
||||
Self {
|
||||
link: link.clone(),
|
||||
sub,
|
||||
event: events.first().map(|n| OwnedNote(n.note_key.as_u64())),
|
||||
new_msg: WriteChat::new(link.clone()),
|
||||
link,
|
||||
event: None,
|
||||
chat: 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(
|
||||
&mut self,
|
||||
event: &Note<'_>,
|
||||
ui: &mut Ui,
|
||||
services: &mut RouteServices<'_>,
|
||||
services: &mut RouteServices<'_, '_>,
|
||||
) -> Response {
|
||||
let chat_h = 60.0;
|
||||
let w = ui.available_width();
|
||||
@ -80,7 +89,7 @@ impl StreamPage {
|
||||
&mut self,
|
||||
event: &Note<'_>,
|
||||
ui: &mut Ui,
|
||||
services: &mut RouteServices<'_>,
|
||||
services: &mut RouteServices<'_, '_>,
|
||||
) -> Response {
|
||||
let max_h = ui.available_height();
|
||||
let chat_w = 450.0;
|
||||
@ -136,21 +145,10 @@ impl StreamPage {
|
||||
}
|
||||
|
||||
impl NostrWidget for StreamPage {
|
||||
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()))
|
||||
}
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
let events: Vec<Note> = vec![];
|
||||
|
||||
let event = if let Some(k) = &self.event {
|
||||
services
|
||||
.ndb
|
||||
.get_note_by_key(services.tx, NoteKey::new(k.0))
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(event) = event {
|
||||
if let Some(event) = events.first() {
|
||||
if let Some(stream) = event.stream() {
|
||||
if self.player.is_none() {
|
||||
let p = StreamPlayer::new(ui.ctx(), &stream.to_string());
|
||||
@ -159,8 +157,8 @@ impl NostrWidget for StreamPage {
|
||||
}
|
||||
|
||||
if self.chat.is_none() {
|
||||
let ok = OwnedNote(event.key().unwrap().as_u64());
|
||||
let chat = Chat::new(self.link.clone(), ok, services.ndb, services.tx);
|
||||
let ok = event.key().unwrap();
|
||||
let chat = Chat::new(self.link.clone(), ok);
|
||||
self.chat = Some(chat);
|
||||
}
|
||||
|
||||
@ -173,4 +171,20 @@ impl NostrWidget for StreamPage {
|
||||
ui.label("Loading..")
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
let filt = self.get_filters();
|
||||
sub_or_poll(
|
||||
services.ctx.ndb,
|
||||
&services.tx,
|
||||
&mut services.ctx.pool,
|
||||
&mut self.events,
|
||||
&mut self.sub,
|
||||
filt,
|
||||
)?;
|
||||
if let Some(c) = self.chat.as_mut() {
|
||||
c.update(services)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ impl FfmpegLoader {
|
||||
Self::load_image_from_demuxer(demux)
|
||||
}
|
||||
|
||||
pub fn load_image_bytes<'a>(
|
||||
pub fn load_image_bytes(
|
||||
&self,
|
||||
key: &str,
|
||||
data: &'static [u8],
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -1,5 +1 @@
|
||||
pub mod image_cache;
|
||||
pub mod ndb_wrapper;
|
||||
pub mod query;
|
||||
|
||||
mod ffmpeg_loader;
|
||||
pub mod ffmpeg_loader;
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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(())
|
||||
}
|
||||
}
|
@ -1,42 +1,37 @@
|
||||
use crate::route::RouteServices;
|
||||
use crate::services::ndb_wrapper::SubWrapper;
|
||||
use crate::route::image_from_cache;
|
||||
use egui::{vec2, Color32, Pos2, Response, Rounding, Sense, Ui, Vec2, Widget};
|
||||
use nostrdb::NdbProfile;
|
||||
use nostrdb::{Ndb, NdbProfile, Transaction};
|
||||
use notedeck::ImageCache;
|
||||
|
||||
pub struct Avatar<'a> {
|
||||
image: Option<&'a str>,
|
||||
sub: Option<SubWrapper>,
|
||||
pub struct Avatar {
|
||||
image: Option<String>,
|
||||
size: Option<f32>,
|
||||
services: &'a RouteServices<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Avatar<'a> {
|
||||
pub fn new_optional(img: Option<&'a str>, services: &'a RouteServices<'a>) -> Self {
|
||||
impl Avatar {
|
||||
pub fn new_optional(img: Option<&str>) -> Self {
|
||||
Self {
|
||||
image: img,
|
||||
sub: None,
|
||||
image: img.map(String::from),
|
||||
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);
|
||||
Self {
|
||||
image: img,
|
||||
sub: None,
|
||||
image: img.map(String::from),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,21 +49,15 @@ impl<'a> Avatar<'a> {
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for Avatar<'a> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
pub fn render(&self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response {
|
||||
let size_v = self.size.unwrap_or(40.);
|
||||
let size = Vec2::new(size_v, size_v);
|
||||
if !ui.is_visible() {
|
||||
return Self::placeholder(ui, size_v);
|
||||
}
|
||||
match self
|
||||
.image
|
||||
.as_ref()
|
||||
.map(|i| self.services.img_cache.load(*i))
|
||||
{
|
||||
Some(img) => img
|
||||
match &self.image {
|
||||
Some(img) => image_from_cache(img_cache, ui.ctx(), &img)
|
||||
.fit_to_exact_size(size)
|
||||
.rounding(Rounding::same(size_v))
|
||||
.ui(ui),
|
||||
|
@ -1,62 +1,44 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::note_util::OwnedNote;
|
||||
use crate::note_ref::NoteRef;
|
||||
use crate::route::RouteServices;
|
||||
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper};
|
||||
use crate::widgets::chat_message::ChatMessage;
|
||||
use crate::widgets::NostrWidget;
|
||||
use crate::widgets::{sub_or_poll, NostrWidget};
|
||||
use egui::{Frame, Margin, Response, ScrollArea, Ui};
|
||||
use itertools::Itertools;
|
||||
use nostrdb::{Filter, Note, NoteKey, Transaction};
|
||||
use nostrdb::{Filter, NoteKey, Subscription};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct Chat {
|
||||
link: NostrLink,
|
||||
stream: OwnedNote,
|
||||
events: Vec<OwnedNote>,
|
||||
sub: SubWrapper,
|
||||
stream: NoteKey,
|
||||
events: HashSet<NoteRef>,
|
||||
sub: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new(link: NostrLink, stream: OwnedNote, ndb: &NDBWrapper, tx: &Transaction) -> 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);
|
||||
|
||||
pub fn new<'a>(link: NostrLink, stream: NoteKey) -> Self {
|
||||
Self {
|
||||
link,
|
||||
sub,
|
||||
stream,
|
||||
events: events
|
||||
.iter()
|
||||
.map(|n| OwnedNote(n.note_key.as_u64()))
|
||||
.collect(),
|
||||
events: HashSet::new(),
|
||||
sub: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([1_311])
|
||||
.tags([self.link.to_tag_value()], 'a')
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrWidget for Chat {
|
||||
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();
|
||||
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
let stream = services
|
||||
.ctx
|
||||
.ndb
|
||||
.get_note_by_key(services.tx, NoteKey::new(self.stream.0))
|
||||
.get_note_by_key(&services.tx, self.stream)
|
||||
.unwrap();
|
||||
|
||||
ScrollArea::vertical()
|
||||
@ -67,12 +49,17 @@ impl NostrWidget for Chat {
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
for ev in events
|
||||
.into_iter()
|
||||
.sorted_by(|a, b| a.created_at().cmp(&b.created_at()))
|
||||
for ev in self
|
||||
.events
|
||||
.iter()
|
||||
.sorted_by(|a, b| a.created_at.cmp(&b.created_at))
|
||||
{
|
||||
let c = ChatMessage::new(&stream, &ev, services);
|
||||
ui.add(c);
|
||||
if let Ok(ev) =
|
||||
services.ctx.ndb.get_note_by_key(&services.tx, ev.key)
|
||||
{
|
||||
ChatMessage::new(&stream, &ev, &None)
|
||||
.render(ui, services.ctx.img_cache);
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -80,4 +67,16 @@ impl NostrWidget for Chat {
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
let filters = vec![self.get_filter()];
|
||||
sub_or_poll(
|
||||
services.ctx.ndb,
|
||||
&services.tx,
|
||||
&mut services.ctx.pool,
|
||||
&mut self.events,
|
||||
&mut self.sub,
|
||||
filters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,50 +1,39 @@
|
||||
use crate::route::RouteServices;
|
||||
use crate::services::ndb_wrapper::SubWrapper;
|
||||
use crate::stream_info::StreamInfo;
|
||||
use crate::theme::{NEUTRAL_500, PRIMARY};
|
||||
use crate::widgets::Avatar;
|
||||
use crate::widgets::{Avatar, NostrWidget};
|
||||
use eframe::epaint::text::TextWrapMode;
|
||||
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 notedeck::ImageCache;
|
||||
|
||||
pub struct ChatMessage<'a> {
|
||||
stream: &'a Note<'a>,
|
||||
ev: &'a Note<'a>,
|
||||
services: &'a RouteServices<'a>,
|
||||
profile: (Option<NdbProfile<'a>>, Option<SubWrapper>),
|
||||
profile: &'a Option<NdbProfile<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> ChatMessage<'a> {
|
||||
pub fn new(
|
||||
stream: &'a Note<'a>,
|
||||
ev: &'a Note<'a>,
|
||||
services: &'a RouteServices<'a>,
|
||||
profile: &'a Option<NdbProfile<'a>>,
|
||||
) -> ChatMessage<'a> {
|
||||
ChatMessage {
|
||||
stream,
|
||||
ev,
|
||||
services,
|
||||
profile: services.ndb.fetch_profile(services.tx, ev.pubkey()),
|
||||
}
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for ChatMessage<'a> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
pub fn render(&mut self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let mut job = LayoutJob::default();
|
||||
// TODO: avoid this somehow
|
||||
job.wrap.break_anywhere = true;
|
||||
|
||||
let is_host = self.stream.host().eq(self.ev.pubkey());
|
||||
let profile = self
|
||||
.services
|
||||
.ndb
|
||||
.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 = self.profile.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
|
||||
|
||||
let name_color = if is_host { PRIMARY } else { NEUTRAL_500 };
|
||||
|
||||
@ -57,7 +46,9 @@ impl<'a> Widget for ChatMessage<'a> {
|
||||
format.color = Color32::WHITE;
|
||||
job.append(self.ev.content(), 5.0, format.clone());
|
||||
|
||||
ui.add(Avatar::from_profile(&profile, self.services).size(24.));
|
||||
Avatar::from_profile(&self.profile)
|
||||
.size(24.)
|
||||
.render(ui, img_cache);
|
||||
ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap));
|
||||
})
|
||||
.response
|
||||
|
@ -1,4 +1,4 @@
|
||||
use crate::route::{RouteServices, Routes};
|
||||
use crate::route::{RouteServices, RouteType};
|
||||
use crate::widgets::avatar::Avatar;
|
||||
use crate::widgets::{Button, NostrWidget};
|
||||
use eframe::emath::Align;
|
||||
@ -14,7 +14,7 @@ impl Header {
|
||||
}
|
||||
|
||||
impl NostrWidget for Header {
|
||||
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/logo.svg");
|
||||
Frame::none()
|
||||
.outer_margin(Margin::symmetric(16., 8.))
|
||||
@ -25,22 +25,21 @@ impl NostrWidget for Header {
|
||||
|ui| {
|
||||
ui.style_mut().spacing.item_spacing.x = 16.;
|
||||
if services
|
||||
.img_cache
|
||||
.load_bytes("logo.svg", logo_bytes)
|
||||
.image_bytes("logo.svg", logo_bytes)
|
||||
.max_height(24.)
|
||||
.sense(Sense::click())
|
||||
.ui(ui)
|
||||
.on_hover_and_drag_cursor(CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
services.navigate(Routes::HomePage);
|
||||
services.navigate(RouteType::HomePage);
|
||||
}
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if let Some(pk) = services.login.public_key() {
|
||||
ui.add(Avatar::pubkey(&pk, services));
|
||||
if let Some(acc) = services.ctx.accounts.get_selected_account() {
|
||||
Avatar::pubkey(&acc.pubkey, services.ctx.ndb, &services.tx).render(ui, services.ctx.img_cache);
|
||||
} else if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
|
||||
services.navigate(Routes::LoginPage);
|
||||
services.navigate(RouteType::LoginPage);
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -48,4 +47,8 @@ impl NostrWidget for Header {
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
fn update(&mut self, _services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -13,11 +13,48 @@ mod text_input;
|
||||
mod username;
|
||||
mod write_chat;
|
||||
|
||||
use crate::note_ref::NoteRef;
|
||||
use crate::route::RouteServices;
|
||||
use egui::{Response, Ui};
|
||||
use enostr::RelayPool;
|
||||
use nostrdb::{Filter, Ndb, Subscription, Transaction};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A stateful widget which requests nostr data
|
||||
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<Subscription>,
|
||||
filters: Vec<Filter>,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(sub) = sub {
|
||||
ndb.poll_for_notes(*sub, 500).into_iter().for_each(|e| {
|
||||
if let Ok(note) = ndb.get_note_by_key(tx, e) {
|
||||
store.insert(NoteRef::from_note(¬e));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let s = ndb.subscribe(filters.as_slice())?;
|
||||
sub.replace(s);
|
||||
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;
|
||||
|
@ -1,46 +1,31 @@
|
||||
use crate::route::RouteServices;
|
||||
use crate::services::image_cache::ImageCache;
|
||||
use crate::services::ndb_wrapper::SubWrapper;
|
||||
use crate::theme::FONT_SIZE;
|
||||
use crate::widgets::{Avatar, Username};
|
||||
use egui::{Response, Ui, Widget};
|
||||
use nostrdb::NdbProfile;
|
||||
use crate::widgets::{Avatar, NostrWidget, Username};
|
||||
use egui::{Response, Ui};
|
||||
|
||||
pub struct Profile<'a> {
|
||||
size: f32,
|
||||
pubkey: &'a [u8; 32],
|
||||
profile: Option<NdbProfile<'a>>,
|
||||
sub: Option<SubWrapper>,
|
||||
img_cache: &'a ImageCache,
|
||||
services: &'a RouteServices<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Profile<'a> {
|
||||
pub fn new(pubkey: &'a [u8; 32], services: &'a RouteServices<'a>) -> Self {
|
||||
let (p, sub) = services.ndb.fetch_profile(services.tx, pubkey);
|
||||
|
||||
Self {
|
||||
pubkey,
|
||||
size: 40.,
|
||||
profile: p,
|
||||
img_cache: services.img_cache,
|
||||
sub,
|
||||
services,
|
||||
}
|
||||
pub fn new(pubkey: &'a [u8; 32]) -> Self {
|
||||
Self { pubkey, size: 40. }
|
||||
}
|
||||
|
||||
pub fn size(self, size: f32) -> Self {
|
||||
Self { size, ..self }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for Profile<'a> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
pub fn render(self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.;
|
||||
|
||||
ui.add(Avatar::from_profile(&self.profile, self.services).size(self.size));
|
||||
ui.add(Username::new(&self.profile, FONT_SIZE))
|
||||
let profile = 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
|
||||
}
|
||||
|
@ -1,35 +1,31 @@
|
||||
use crate::note_store::NoteStore;
|
||||
use crate::note_view::NotesView;
|
||||
use crate::route::RouteServices;
|
||||
use crate::stream_info::StreamInfo;
|
||||
use crate::widgets::stream_tile::StreamEvent;
|
||||
use egui::{vec2, Frame, Grid, Margin, Response, Ui, Widget, WidgetText};
|
||||
use crate::widgets::NostrWidget;
|
||||
use egui::{vec2, Frame, Grid, Margin, Response, Ui, WidgetText};
|
||||
use itertools::Itertools;
|
||||
|
||||
pub struct StreamList<'a> {
|
||||
id: egui::Id,
|
||||
streams: &'a NoteStore<'a>,
|
||||
services: &'a RouteServices<'a>,
|
||||
streams: NotesView<'a>,
|
||||
heading: Option<WidgetText>,
|
||||
}
|
||||
|
||||
impl<'a> StreamList<'a> {
|
||||
pub fn new(
|
||||
id: egui::Id,
|
||||
streams: &'a NoteStore<'a>,
|
||||
services: &'a RouteServices<'a>,
|
||||
streams: NotesView<'a>,
|
||||
heading: Option<impl Into<WidgetText>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
streams,
|
||||
services,
|
||||
heading: heading.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for StreamList<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
let cols = match ui.available_width() as u16 {
|
||||
720..1080 => 2,
|
||||
1080..1300 => 3,
|
||||
@ -46,7 +42,7 @@ impl Widget for StreamList<'_> {
|
||||
.show(ui, |ui| {
|
||||
let grid_spacing_consumed = (cols - 1) as f32 * grid_padding;
|
||||
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);
|
||||
}
|
||||
Grid::new(self.id)
|
||||
@ -58,10 +54,9 @@ impl Widget for StreamList<'_> {
|
||||
.cmp(&b.status())
|
||||
.then(a.starts().cmp(&b.starts()).reverse())
|
||||
}) {
|
||||
ui.add_sized(
|
||||
vec2(g_w, (g_w / 16.0) * 9.0),
|
||||
StreamEvent::new(event, self.services),
|
||||
);
|
||||
ui.allocate_ui(vec2(g_w, (g_w / 16.0) * 9.0), |ui| {
|
||||
StreamEvent::new(event).render(ui, services)
|
||||
});
|
||||
ctr += 1;
|
||||
if ctr % cols == 0 {
|
||||
ui.end_row();
|
||||
|
@ -1,34 +1,34 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::{RouteServices, Routes};
|
||||
use crate::route::{RouteServices, RouteType};
|
||||
use crate::stream_info::{StreamInfo, StreamStatus};
|
||||
use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT};
|
||||
use crate::widgets::avatar::Avatar;
|
||||
use crate::widgets::NostrWidget;
|
||||
use eframe::epaint::{Rounding, Vec2};
|
||||
use egui::epaint::RectShape;
|
||||
use egui::load::TexturePoll;
|
||||
use egui::{
|
||||
vec2, Color32, CursorIcon, FontId, Label, Pos2, Rect, Response, RichText, Sense, TextWrapMode,
|
||||
Ui, Widget,
|
||||
Ui,
|
||||
};
|
||||
use nostrdb::Note;
|
||||
|
||||
pub struct StreamEvent<'a> {
|
||||
event: &'a Note<'a>,
|
||||
services: &'a RouteServices<'a>,
|
||||
}
|
||||
|
||||
impl<'a> StreamEvent<'a> {
|
||||
pub fn new(event: &'a Note<'a>, services: &'a RouteServices) -> Self {
|
||||
Self { event, services }
|
||||
pub fn new(event: &'a Note<'a>) -> Self {
|
||||
Self { event }
|
||||
}
|
||||
}
|
||||
impl Widget for StreamEvent<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
impl NostrWidget for StreamEvent<'_> {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
ui.vertical(|ui| {
|
||||
ui.style_mut().spacing.item_spacing = Vec2::new(12., 16.);
|
||||
|
||||
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 h = (w / 16.0) * 9.0;
|
||||
@ -36,7 +36,7 @@ impl Widget for StreamEvent<'_> {
|
||||
let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click());
|
||||
|
||||
let cover = if ui.is_visible() {
|
||||
self.event.image().map(|p| self.services.img_cache.load(p))
|
||||
self.event.image().map(|p| services.image(p))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@ -110,13 +110,15 @@ impl Widget for StreamEvent<'_> {
|
||||
}
|
||||
let response = response.on_hover_and_drag_cursor(CursorIcon::PointingHand);
|
||||
if response.clicked() {
|
||||
self.services.navigate(Routes::EventPage {
|
||||
services.navigate(RouteType::EventPage {
|
||||
link: NostrLink::from_note(self.event),
|
||||
event: None,
|
||||
});
|
||||
}
|
||||
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"))
|
||||
.size(16.)
|
||||
.color(Color32::WHITE);
|
||||
@ -125,4 +127,8 @@ impl Widget for StreamEvent<'_> {
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
fn update(&mut self, _services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -10,13 +10,13 @@ pub struct 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 }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> NostrWidget for StreamTitle<'a> {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
|
||||
impl NostrWidget for StreamTitle<'_> {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
Frame::none()
|
||||
.outer_margin(Margin::symmetric(12., 8.))
|
||||
.show(ui, |ui| {
|
||||
@ -26,7 +26,9 @@ impl<'a> NostrWidget for StreamTitle<'a> {
|
||||
.color(Color32::WHITE);
|
||||
ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Truncate));
|
||||
|
||||
ui.add(Profile::new(self.event.host(), services).size(32.));
|
||||
Profile::new(self.event.host())
|
||||
.size(32.)
|
||||
.render(ui, services);
|
||||
|
||||
if let Some(summary) = self
|
||||
.event
|
||||
@ -41,4 +43,8 @@ impl<'a> NostrWidget for StreamTitle<'a> {
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
fn update(&mut self, _services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,5 @@
|
||||
use crate::route::{RouteAction, RouteServices};
|
||||
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_500, NEUTRAL_900, ROUNDING_DEFAULT};
|
||||
use crate::widgets::NostrWidget;
|
||||
use egui::{Frame, Response, TextEdit, Ui};
|
||||
use egui::{Frame, Response, TextEdit, Ui, Widget};
|
||||
|
||||
/// Wrap the [TextEdit] widget to handle native keyboard
|
||||
pub struct NativeTextInput<'a> {
|
||||
@ -30,8 +28,8 @@ impl<'a> NativeTextInput<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> NostrWidget for NativeTextInput<'a> {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
|
||||
impl Widget for NativeTextInput<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let mut editor = TextEdit::multiline(self.text)
|
||||
.frame(false)
|
||||
.desired_rows(1)
|
||||
@ -49,12 +47,6 @@ impl<'a> NostrWidget for NativeTextInput<'a> {
|
||||
} else {
|
||||
ui.add(editor)
|
||||
};
|
||||
if response.lost_focus() {
|
||||
services.action(RouteAction::HideKeyboard);
|
||||
}
|
||||
if response.gained_focus() {
|
||||
services.action(RouteAction::ShowKeyboard);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,8 @@ use crate::widgets::{NativeTextInput, NostrWidget};
|
||||
use eframe::emath::Align;
|
||||
use egui::{Frame, Layout, Response, Sense, Ui, Widget};
|
||||
use log::info;
|
||||
use nostrdb::Filter;
|
||||
use notedeck::AppContext;
|
||||
|
||||
pub struct WriteChat {
|
||||
link: NostrLink,
|
||||
@ -21,7 +23,7 @@ impl WriteChat {
|
||||
}
|
||||
|
||||
impl NostrWidget for WriteChat {
|
||||
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");
|
||||
Frame::none()
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
@ -31,16 +33,13 @@ impl NostrWidget for WriteChat {
|
||||
.show(ui, |ui| {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if services
|
||||
.img_cache
|
||||
.load_bytes("send-03.svg", logo_bytes)
|
||||
.image_bytes("send-03.svg", logo_bytes)
|
||||
.sense(Sense::click())
|
||||
.ui(ui)
|
||||
.clicked()
|
||||
|| self.msg.ends_with('\n')
|
||||
{
|
||||
if let Ok(ev) = services
|
||||
.login
|
||||
.write_live_chat_msg(&self.link, &self.msg.trim())
|
||||
if let Some(ev) = services.write_live_chat_msg(&self.link, &self.msg.trim())
|
||||
{
|
||||
info!("Sending: {:?}", ev);
|
||||
services.broadcast_event(ev);
|
||||
@ -48,11 +47,13 @@ impl NostrWidget for WriteChat {
|
||||
self.msg.clear();
|
||||
}
|
||||
|
||||
let mut editor =
|
||||
NativeTextInput::new(&mut self.msg).with_hint_text("Message..");
|
||||
editor.render(ui, services)
|
||||
ui.add(NativeTextInput::new(&mut self.msg).with_hint_text("Message.."));
|
||||
});
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
fn update(&mut self, _services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user