wip notedeck

This commit is contained in:
kieran 2025-01-07 11:23:34 +00:00
parent c8c5485581
commit 0e19c1a8f3
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
31 changed files with 725 additions and 2173 deletions

908
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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" }

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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};

View File

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

43
src/note_ref.rs Normal file
View File

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

View File

@ -1,4 +1,4 @@
use nostr_sdk::util::hex;
use nostr::prelude::hex;
use nostrdb::{NdbStr, Note, Tag};
pub trait NoteUtil {
@ -64,6 +64,3 @@ impl<'a> Iterator for TagIterBorrow<'a> {
}
}
}
#[derive(Eq, PartialEq)]
pub struct OwnedNote(pub u64);

View File

@ -2,11 +2,11 @@ use crate::link::NostrLink;
use nostrdb::Note;
use 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(),

View File

@ -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
.iter()
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(),
)
}
}

View File

@ -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(())
}
}

View File

@ -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)
}
Err(e) => warn!("Failed to broadcast event: {:?}", e),
self.ctx
.pool
.send(&enostr::ClientMessage::Event(EventClientMessage {
note_json: ev_json,
}));
}
/// 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
}

View File

@ -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(())
}
}

View File

@ -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],

View File

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

View File

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

View File

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

View File

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

View File

@ -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),

View File

@ -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,
)
}
}

View File

@ -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

View File

@ -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(())
}
}

View File

@ -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(&note));
}
});
} 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;

View File

@ -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
}

View File

@ -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();
@ -71,4 +66,4 @@ impl Widget for StreamList<'_> {
})
.response
}
}
}

View File

@ -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(())
}
}

View File

@ -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(())
}
}

View File

@ -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
}
}

View File

@ -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(())
}
}