From 6017ce18d4a65c99cc28cb74a658edd1acf8759c Mon Sep 17 00:00:00 2001 From: kieran Date: Wed, 16 Oct 2024 20:23:52 +0100 Subject: [PATCH] feat: chat feat: android setup --- .gitignore | 3 +- Cargo.lock | 113 ++++++++++++++++++++++ Cargo.toml | 30 +++++- src/app.rs | 6 +- src/{main.rs => bin/zap_stream_app.rs} | 10 +- src/lib.rs | 39 ++++++++ src/link.rs | 2 + src/{ => resources}/logo.svg | 0 src/resources/send-03.svg | 3 + src/route/home.rs | 11 ++- src/route/mod.rs | 23 +++-- src/route/stream.rs | 42 ++++---- src/services/image_cache.rs | 68 +++++++++++++ src/services/mod.rs | 1 + src/services/ndb_wrapper.rs | 9 +- src/services/query.rs | 19 +++- src/stream_info.rs | 59 +++++++---- src/theme.rs | 5 + src/widgets/avatar.rs | 33 +++---- src/widgets/chat.rs | 34 +++++-- src/widgets/chat_message.rs | 56 ++++++++--- src/widgets/header.rs | 2 +- src/widgets/mod.rs | 6 +- src/widgets/profile.rs | 13 +-- src/widgets/stream_list.rs | 2 +- src/widgets/{stream.rs => stream_tile.rs} | 24 ++--- src/widgets/stream_title.rs | 42 ++++++++ src/widgets/write_chat.rs | 47 +++++++++ 28 files changed, 556 insertions(+), 146 deletions(-) rename src/{main.rs => bin/zap_stream_app.rs} (79%) create mode 100644 src/lib.rs rename src/{ => resources}/logo.svg (100%) create mode 100644 src/resources/send-03.svg create mode 100644 src/services/image_cache.rs create mode 100644 src/theme.rs rename src/widgets/{stream.rs => stream_tile.rs} (71%) create mode 100644 src/widgets/stream_title.rs create mode 100644 src/widgets/write_chat.rs diff --git a/.gitignore b/.gitignore index adec2cb..9b17dc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target /lock.mdb /data.mdb -/.idea \ No newline at end of file +/.idea +/cache \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index bc1b26c..eb8fba2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,23 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +[[package]] +name = "android_log-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ecc8056bf6ab9892dcd53216c83d1597487d7dacac16c8df6b877d127df9937" + +[[package]] +name = "android_logger" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -1177,6 +1194,16 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_logger" version = "0.10.2" @@ -1814,6 +1841,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs 0.8.0", "rustls-pki-types", "tokio", "tokio-rustls", @@ -2850,6 +2878,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + [[package]] name = "orbclient" version = "0.3.47" @@ -3367,6 +3401,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs 0.7.3", "rustls-pemfile", "rustls-pki-types", "serde", @@ -3494,6 +3529,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "2.1.3" @@ -3545,6 +3606,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01227be5826fa0690321a2ba6c5cd57a19cf3f6a09e76973b58e61de6ab9d1c1" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3569,6 +3639,19 @@ dependencies = [ "sha2", ] +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + [[package]] name = "sdl2" version = "0.37.0" @@ -3614,6 +3697,29 @@ dependencies = [ "cc", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.23" @@ -5146,6 +5252,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix", + "sctk-adwaita", "smithay-client-toolkit", "smol_str", "tracing", @@ -5246,6 +5353,8 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" name = "zap_stream_app" version = "0.1.0" dependencies = [ + "android-activity", + "android_logger", "anyhow", "async-trait", "bech32", @@ -5256,13 +5365,17 @@ dependencies = [ "egui_extras", "egui_inbox", "image", + "itertools 0.13.0", "libc", "log", "nostr-sdk", "nostrdb", "pretty_env_logger", + "reqwest", + "sha2", "tokio", "uuid", + "winit", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f01e44e..cc0b0ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,11 @@ name = "zap_stream_app" version = "0.1.0" edition = "2021" -[dependencies] -tokio = "1.40.0" +[lib] +crate-type = ["lib", "cdylib"] +[dependencies] +tokio = { version = "1.40.0", features = ["fs", "rt-multi-thread", "rt"] } egui = { version = "0.29.1" } eframe = { version = "0.29.1", default-features = false, features = ["glow", "wgpu", "wayland", "x11", "android-native-activity"] } nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", version = "0.3.4" } @@ -22,3 +24,27 @@ uuid = { version = "1.11.0", features = ["v4"] } chrono = "0.4.38" anyhow = "1.0.89" 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" + +[target.'cfg(target_os = "android")'.dependencies] +android_logger = "0.14.1" +android-activity = { version = "0.6.0", features = [ "native-activity" ] } +winit = { version = "0.30.5", features = [ "android-native-activity" ] } + +[package.metadata.android] +package = "stream.zap.app" +build_targets = [ "aarch64-linux-android" ] +#build_targets = [ "armv7-linux-androideabi" ] +large_heap = true + +[package.metadata.android.sdk] +min_sdk_version = 20 +target_sdk_version = 32 + +[package.metadata.android.application] +extract_native_libs = true + +[package.metadata.android.application.activity] +config_changes = "orientation" \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index c50cd2a..82f7d56 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crate::route::Router; use eframe::{App, CreationContext, Frame}; -use egui::{Color32, Context}; +use egui::{Color32, Context, Pos2, Rect, Rounding}; use nostr_sdk::database::MemoryDatabase; use nostr_sdk::{Client, RelayPoolNotification}; use nostrdb::{Config, Ndb}; @@ -53,6 +53,8 @@ impl App for ZapStreamApp { egui::CentralPanel::default() .frame(app_frame) - .show(ctx, |ui| self.router.show(ui)); + .show(ctx, |ui| { + self.router.show(ui); + }); } } diff --git a/src/main.rs b/src/bin/zap_stream_app.rs similarity index 79% rename from src/main.rs rename to src/bin/zap_stream_app.rs index f0ac737..73a0420 100644 --- a/src/main.rs +++ b/src/bin/zap_stream_app.rs @@ -1,14 +1,6 @@ -use crate::app::ZapStreamApp; use eframe::Renderer; use egui::Vec2; - -mod app; -mod link; -mod note_util; -mod route; -mod services; -mod stream_info; -pub mod widgets; +use zap_stream_app::app::ZapStreamApp; #[tokio::main] async fn main() { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d6157e9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,39 @@ +use crate::app::ZapStreamApp; +use eframe::Renderer; +use egui::Vec2; + +pub mod app; +mod link; +mod note_util; +mod route; +mod services; +mod stream_info; +pub mod widgets; +pub mod theme; + +#[cfg(target_os = "android")] +use winit::platform::android::activity::AndroidApp; +#[cfg(target_os = "android")] +use winit::platform::android::EventLoopBuilderExtAndroid; + +#[cfg(target_os = "android")] +#[no_mangle] +#[tokio::main] +pub async fn android_main(app: AndroidApp) { + std::env::set_var("RUST_BACKTRACE", "full"); + android_logger::init_once(android_logger::Config::default().with_min_level(log::Level::Info)); + + let mut options = eframe::NativeOptions::default(); + options.renderer = Renderer::Glow; + options.viewport = options.viewport.with_inner_size(Vec2::new(360., 720.)); + let app_clone_for_event_loop = app.clone(); + options.event_loop_builder = Some(Box::new(move |builder| { + builder.with_android_app(app_clone_for_event_loop); + })); + + let _res = eframe::run_native( + "zap.stream", + options, + Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc)))), + ); +} \ No newline at end of file diff --git a/src/link.rs b/src/link.rs index 7140350..1673dd3 100644 --- a/src/link.rs +++ b/src/link.rs @@ -112,6 +112,8 @@ impl TryInto for &NostrLink { fn try_into(self) -> Result { match self.hrp { NostrLinkType::Coordinate => Ok(Filter::new() + .kinds([self.kind.unwrap() as u64]) + .authors([&self.author.unwrap()]) .tags( [match self.id { IdOrStr::Str(ref s) => s.to_owned(), diff --git a/src/logo.svg b/src/resources/logo.svg similarity index 100% rename from src/logo.svg rename to src/resources/logo.svg diff --git a/src/resources/send-03.svg b/src/resources/send-03.svg new file mode 100644 index 0000000..63f18ac --- /dev/null +++ b/src/resources/send-03.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/route/home.rs b/src/route/home.rs index 0d5bd01..e16e820 100644 --- a/src/route/home.rs +++ b/src/route/home.rs @@ -3,9 +3,8 @@ use crate::route::RouteServices; use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper}; use crate::widgets; use crate::widgets::NostrWidget; -use egui::{Response, Ui, Widget}; -use log::{error, info}; -use nostrdb::{Filter, Ndb, Note, NoteKey, Transaction}; +use egui::{Response, ScrollArea, Ui, Widget}; +use nostrdb::{Filter, Note, NoteKey, Transaction}; pub struct HomePage { sub: SubWrapper, @@ -40,7 +39,9 @@ impl NostrWidget for HomePage { .map_while(|f| f.map_or(None, |f| Some(f))) .collect(); - info!("HomePage events: {}", events.len()); - widgets::StreamList::new(&events, &services).ui(ui) + ScrollArea::vertical() + .show(ui, |ui| { + widgets::StreamList::new(&events, &services).ui(ui) + }).inner } } diff --git a/src/route/mod.rs b/src/route/mod.rs index 06122d5..b1a4cad 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -3,6 +3,7 @@ use crate::note_util::OwnedNote; use crate::route; use crate::route::home::HomePage; use crate::route::stream::StreamPage; +use crate::services::image_cache::ImageCache; use crate::services::ndb_wrapper::NDBWrapper; use crate::widgets::{Header, NostrWidget, StreamList}; use egui::{Context, Response, ScrollArea, Ui, Widget}; @@ -47,6 +48,7 @@ pub struct Router { ndb: NDBWrapper, login: Option<[u8; 32]>, client: Client, + image_cache: ImageCache, } impl Router { @@ -59,6 +61,7 @@ impl Router { ndb: NDBWrapper::new(ctx.clone(), ndb.clone(), client.clone()), client, login: None, + image_cache: ImageCache::new(ctx.clone()), } } @@ -103,19 +106,18 @@ impl Router { ndb: &self.ndb, tx: &tx, login: &self.login, + img_cache: &self.image_cache, }; // display app - ScrollArea::vertical() - .show(ui, |ui| { - Header::new().render(ui, &svc); - if let Some(w) = self.current_widget.as_mut() { - w.render(ui, &svc) - } else { - ui.label("No widget") - } - }) - .inner + ui.vertical(|ui| { + Header::new().render(ui, &svc); + if let Some(w) = self.current_widget.as_mut() { + w.render(ui, &svc) + } else { + ui.label("No widget") + } + }).response } } @@ -126,6 +128,7 @@ pub struct RouteServices<'a> { pub ndb: &'a NDBWrapper, //ref pub tx: &'a Transaction, //ref pub login: &'a Option<[u8; 32]>, //ref + pub img_cache: &'a ImageCache, } impl<'a> RouteServices<'a> { diff --git a/src/route/stream.rs b/src/route/stream.rs index 2ececee..0c35383 100644 --- a/src/route/stream.rs +++ b/src/route/stream.rs @@ -1,11 +1,11 @@ use crate::link::NostrLink; -use crate::note_util::{NoteUtil, OwnedNote}; +use crate::note_util::OwnedNote; use crate::route::RouteServices; use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper}; use crate::stream_info::StreamInfo; -use crate::widgets::{Chat, NostrWidget, StreamPlayer}; -use egui::{Color32, Label, Response, RichText, TextWrapMode, Ui, Widget}; -use nostrdb::{Filter, NoteKey, Subscription, Transaction}; +use crate::widgets::{Chat, NostrWidget, StreamPlayer, StreamTitle, WriteChat}; +use egui::{Response, Ui, Vec2, Widget}; +use nostrdb::{Filter, NoteKey, Transaction}; use std::borrow::Borrow; pub struct StreamPage { @@ -14,6 +14,7 @@ pub struct StreamPage { player: Option, chat: Option, sub: SubWrapper, + new_msg: WriteChat, } impl StreamPage { @@ -29,6 +30,7 @@ impl StreamPage { .map_or(None, |n| Some(OwnedNote(n.note_key.as_u64()))), chat: None, player: None, + new_msg: WriteChat::new(), } } } @@ -51,7 +53,7 @@ impl NostrWidget for StreamPage { if let Some(event) = event { if let Some(stream) = event.stream() { if self.player.is_none() { - let p = StreamPlayer::new(ui.ctx(), &stream); + let p = StreamPlayer::new(ui.ctx(), &stream.to_string()); self.player = Some(p); } } @@ -59,25 +61,27 @@ impl NostrWidget for StreamPage { if let Some(player) = &mut self.player { player.ui(ui); } - - let title = RichText::new(match event.get_tag_value("title") { - Some(s) => s.variant().str().unwrap_or("Unknown"), - None => "Unknown", - }) - .size(16.) - .color(Color32::WHITE); - ui.add(Label::new(title).wrap_mode(TextWrapMode::Truncate)); + StreamTitle::new(&event).render(ui, services); if self.chat.is_none() { - let chat = Chat::new(self.link.clone(), &services.ndb, services.tx); + let ok = OwnedNote(event.key().unwrap().as_u64()); + let chat = Chat::new(self.link.clone(), ok, &services.ndb, services.tx); self.chat = Some(chat); } - if let Some(c) = self.chat.as_mut() { - c.render(ui, services) - } else { - ui.label("Loading..") - } + let chat_h = 60.0; + let w = ui.available_width(); + let h = ui.available_height(); + ui.allocate_ui(Vec2::new(w, h - chat_h), |ui| { + if let Some(c) = self.chat.as_mut() { + c.render(ui, services) + } else { + ui.label("Loading..") + } + }); + ui.allocate_ui(Vec2::new(w, chat_h), |ui| { + self.new_msg.render(ui, services) + }).response } else { ui.label("Loading..") } diff --git a/src/services/image_cache.rs b/src/services/image_cache.rs new file mode 100644 index 0000000..1503d0f --- /dev/null +++ b/src/services/image_cache.rs @@ -0,0 +1,68 @@ +use egui::Image; +use log::{error, info}; +use nostr_sdk::util::hex; +use sha2::digest::Update; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::fs; +use std::hash::Hash; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub struct ImageCache { + ctx: egui::Context, + dir: PathBuf, + fetch_lock: Arc>>, +} + +impl ImageCache { + pub fn new(ctx: egui::Context) -> Self { + let out = PathBuf::from("./cache/images"); + fs::create_dir_all(&out).unwrap(); + Self { + ctx, + dir: out, + fetch_lock: Arc::new(Mutex::new(HashSet::new())), + } + } + + pub fn find(&self, url: U) -> PathBuf + where + U: Into, + { + let mut sha = Sha256::new(); + sha2::digest::Update::update(&mut sha, url.into().as_bytes()); + let hash = hex::encode(sha.finalize()); + self.dir + .join(PathBuf::from(hash[0..2].to_string())) + .join(PathBuf::from(hash)) + } + + pub fn load<'a, U>(&self, url: U) -> Image<'a> + where + U: Into, + { + let u = url.into(); + let path = self.find(&u); + if !path.exists() { + let path = path.clone(); + let fl = self.fetch_lock.clone(); + let ctx = self.ctx.clone(); + tokio::spawn(async move { + if fl.lock().await.insert(u.clone()) { + info!("Fetching image: {}", &u); + if let Ok(data) = reqwest::get(&u) + .await { + tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap(); + if let Err(e) = tokio::fs::write(path, data.bytes().await.unwrap()).await { + error!("Failed to write file: {}", e); + } + ctx.request_repaint(); + } + } + }); + } + Image::from_uri(format!("file://{}", path.to_str().unwrap())) + } +} \ No newline at end of file diff --git a/src/services/mod.rs b/src/services/mod.rs index 08975b8..686f46e 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,2 +1,3 @@ pub mod ndb_wrapper; pub mod query; +pub mod image_cache; \ No newline at end of file diff --git a/src/services/ndb_wrapper.rs b/src/services/ndb_wrapper.rs index 39686cd..42cb95b 100644 --- a/src/services/ndb_wrapper.rs +++ b/src/services/ndb_wrapper.rs @@ -142,14 +142,7 @@ impl NDBWrapper { .get_profile_by_pubkey(tx, pubkey) .map_or(None, |p| p.record().profile()); - let sub = if p.is_none() { - Some(self.subscribe( - "profile", - &[Filter::new().kinds([0]).authors([pubkey]).build()], - )) - } else { - None - }; + let sub = None; (p, sub) } } diff --git a/src/services/query.rs b/src/services/query.rs index 8f01ad3..b8b3d59 100644 --- a/src/services/query.rs +++ b/src/services/query.rs @@ -2,6 +2,7 @@ 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, SubscriptionId}; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; @@ -56,12 +57,24 @@ impl Query { /// Return next query batch pub fn next(&mut self) -> Option { - let next: Vec = self.queue.drain().collect(); + let mut next: Vec = self.queue.drain().collect(); if next.len() == 0 { return None; } let now = Utc::now(); let id = Uuid::new_v4(); + + // 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())) + ] + } Some(QueryTrace { id, filters: next, @@ -145,6 +158,10 @@ where where F: Into>, { + self.queue_into_queries.send(QueueDefer { + id: id.to_string(), + filters: filters.into(), + }).unwrap() } } diff --git a/src/stream_info.rs b/src/stream_info.rs index 4f41c5e..f303c36 100644 --- a/src/stream_info.rs +++ b/src/stream_info.rs @@ -1,38 +1,61 @@ use crate::note_util::NoteUtil; -use nostrdb::Note; +use nostrdb::{NdbStrVariant, Note}; pub trait StreamInfo { - fn title(&self) -> Option; + fn title(&self) -> Option<&str>; - fn summary(&self) -> Option; + fn summary(&self) -> Option<&str>; - fn host(&self) -> [u8; 32]; + fn host(&self) -> &[u8; 32]; - fn stream(&self) -> Option; + fn stream(&self) -> Option<&str>; + + fn starts(&self) -> u64; } impl<'a> StreamInfo for Note<'a> { - fn title(&self) -> Option { + fn title(&self) -> Option<&str> { if let Some(s) = self.get_tag_value("title") { - s.variant().str().map(ToString::to_string) + s.variant().str() } else { None } } - fn summary(&self) -> Option { - todo!() - } - - fn host(&self) -> [u8; 32] { - todo!() - } - - fn stream(&self) -> Option { - if let Some(s) = self.get_tag_value("streaming") { - s.variant().str().map(ToString::to_string) + fn summary(&self) -> Option<&str> { + if let Some(s) = self.get_tag_value("summary") { + s.variant().str() } else { None } } + + fn host(&self) -> &[u8; 32] { + match self.find_tag_value(|t| { + t[0].variant().str() == Some("p") && t[3].variant().str() == Some("host") + }) { + Some(t) => match t.variant() { + NdbStrVariant::Id(i) => i, + NdbStrVariant::Str(s) => self.pubkey(), + }, + None => self.pubkey(), + } + } + + fn stream(&self) -> Option<&str> { + if let Some(s) = self.get_tag_value("streaming") { + s.variant().str() + } else { + None + } + } + + fn starts(&self) -> u64 { + if let Some(s) = self.get_tag_value("starts") { + s.variant().str() + .map_or(self.created_at(), |v| v.parse::().unwrap_or(self.created_at())) + } else { + self.created_at() + } + } } diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..2106f7f --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,5 @@ +use egui::Color32; + +pub const PRIMARY: Color32 = Color32::from_rgb(248, 56, 217); +pub const NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115); +pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23); \ No newline at end of file diff --git a/src/widgets/avatar.rs b/src/widgets/avatar.rs index 7f40945..8a84842 100644 --- a/src/widgets/avatar.rs +++ b/src/widgets/avatar.rs @@ -1,10 +1,11 @@ use crate::route::RouteServices; use crate::services::ndb_wrapper::SubWrapper; -use egui::{Color32, Image, Rect, Response, Rounding, Sense, Ui, Vec2, Widget}; +use egui::{Color32, Image, Pos2, Response, Rounding, Sense, Ui, Vec2, Widget}; pub struct Avatar<'a> { image: Option>, sub: Option, + size: Option, } impl<'a> Avatar<'a> { @@ -12,6 +13,7 @@ impl<'a> Avatar<'a> { Self { image: Some(img), sub: None, + size: None, } } @@ -19,6 +21,7 @@ impl<'a> Avatar<'a> { Self { image: img, sub: None, + size: None, } } @@ -27,39 +30,27 @@ impl<'a> Avatar<'a> { Self { image: img .map_or(None, |p| p.picture()) - .map(|p| Image::from_uri(p)), + .map(|p| svc.img_cache.load(p)), sub, + size: None, } } - pub fn max_size(mut self, size: f32) -> Self { - self.image = if let Some(i) = self.image { - Some(i.max_height(size)) - } else { - None - }; - self - } - pub fn size(mut self, size: f32) -> Self { - self.image = if let Some(i) = self.image { - Some(i.fit_to_exact_size(Vec2::new(size, size))) - } else { - None - }; + self.size = Some(size); self } } impl<'a> Widget for Avatar<'a> { fn ui(self, ui: &mut Ui) -> Response { + let size_v = self.size.unwrap_or(40.); + let size = Vec2::new(size_v, size_v); match self.image { - Some(img) => img.rounding(Rounding::same(ui.available_height())).ui(ui), + Some(img) => img.fit_to_exact_size(size).rounding(Rounding::same(size_v)).ui(ui), None => { - let h = ui.available_height(); - let rnd = Rounding::same(h); - let (response, painter) = ui.allocate_painter(Vec2::new(h, h), Sense::click()); - painter.rect_filled(Rect::EVERYTHING, rnd, Color32::from_rgb(200, 200, 200)); + let (response, painter) = ui.allocate_painter(size, Sense::click()); + painter.circle_filled(Pos2::new(size_v / 2., size_v / 2.), size_v / 2., Color32::from_rgb(200, 200, 200)); response } } diff --git a/src/widgets/chat.rs b/src/widgets/chat.rs index cab9037..4796cf6 100644 --- a/src/widgets/chat.rs +++ b/src/widgets/chat.rs @@ -2,19 +2,22 @@ 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::widgets::chat_message::ChatMessage; use crate::widgets::NostrWidget; -use egui::{Response, ScrollArea, Ui, Widget}; +use egui::{Frame, Margin, Response, ScrollArea, Ui, Widget}; +use itertools::Itertools; use nostrdb::{Filter, Note, NoteKey, Transaction}; pub struct Chat { link: NostrLink, + stream: OwnedNote, events: Vec, sub: SubWrapper, } impl Chat { - pub fn new(link: NostrLink, ndb: &NDBWrapper, tx: &Transaction) -> Self { + 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') @@ -26,6 +29,7 @@ impl Chat { Self { link, sub, + stream, events: events .iter() .map(|n| OwnedNote(n.note_key.as_u64())) @@ -51,15 +55,25 @@ impl NostrWidget for Chat { }) .collect(); + let stream = services.ndb + .get_note_by_key(services.tx, NoteKey::new(self.stream.0)) + .unwrap(); + ScrollArea::vertical() + .stick_to_bottom(true) .show(ui, |ui| { - ui.vertical(|ui| { - for ev in events { - ChatMessage::new(&ev, services).ui(ui); - } - }) - .response - }) - .inner + Frame::none() + .outer_margin(Margin::symmetric(12., 8.)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 8.0; + for ev in events.iter().sorted_by(|a, b| { + a.starts().cmp(&b.starts()) + }) { + ChatMessage::new(&stream, &ev, services).ui(ui); + } + }) + }).response + }).inner } } diff --git a/src/widgets/chat_message.rs b/src/widgets/chat_message.rs index 25b71aa..abd41f8 100644 --- a/src/widgets/chat_message.rs +++ b/src/widgets/chat_message.rs @@ -1,30 +1,58 @@ use crate::route::RouteServices; -use crate::widgets::Profile; -use eframe::epaint::Vec2; -use egui::{Response, Ui, Widget}; -use nostrdb::Note; +use crate::services::ndb_wrapper::SubWrapper; +use crate::stream_info::StreamInfo; +use crate::theme::{NEUTRAL_500, PRIMARY}; +use crate::widgets::Avatar; +use eframe::epaint::text::TextWrapMode; +use egui::text::LayoutJob; +use egui::{Align, Color32, Label, Response, TextFormat, Ui, Widget}; +use nostrdb::{NdbProfile, Note}; pub struct ChatMessage<'a> { + stream: &'a Note<'a>, ev: &'a Note<'a>, services: &'a RouteServices<'a>, + profile: (Option>, Option), } impl<'a> ChatMessage<'a> { - pub fn new(ev: &'a Note<'a>, services: &'a RouteServices<'a>) -> ChatMessage<'a> { - ChatMessage { ev, services } + pub fn new(stream: &'a Note<'a>, ev: &'a Note<'a>, services: &'a RouteServices<'a>) -> ChatMessage<'a> { + ChatMessage { stream, ev, services, profile: services.ndb.fetch_profile(services.tx, ev.pubkey()) } } } impl<'a> Widget for ChatMessage<'a> { fn ui(self, ui: &mut Ui) -> Response { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = Vec2::new(8., 2.); - let author = self.ev.pubkey(); - Profile::new(author, self.services).size(24.).ui(ui); + ui.horizontal_wrapped(|ui| { + let mut job = LayoutJob::default(); - let content = self.ev.content(); - ui.label(content); - }) - .response + let is_host = self.stream.host().eq(self.ev.pubkey()); + let name = self + .profile.0 + .map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f)); + let img = self + .profile.0 + .map_or(None, |f| f.picture().map(|f| self.services.img_cache.load(f))); + + let name_color = if is_host { + PRIMARY + } else { + NEUTRAL_500 + }; + + let mut format = TextFormat::default(); + format.line_height = Some(24.0); + format.valign = Align::Center; + + format.color = name_color; + job.append(name, 0.0, format.clone()); + format.color = Color32::WHITE; + job.append(self.ev.content(), 5.0, format.clone()); + + ui.add(Avatar::new_optional(img).size(24.)); + ui.add(Label::new(job) + .wrap_mode(TextWrapMode::Wrap) + ); + }).response } } diff --git a/src/widgets/header.rs b/src/widgets/header.rs index dc7595a..eafb506 100644 --- a/src/widgets/header.rs +++ b/src/widgets/header.rs @@ -15,7 +15,7 @@ impl Header { impl NostrWidget for Header { fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { - let logo_bytes = include_bytes!("../logo.svg"); + let logo_bytes = include_bytes!("../resources/logo.svg"); Frame::none() .outer_margin(Margin::symmetric(16., 8.)) .show(ui, |ui| { diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 519346c..f511e8b 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -3,10 +3,12 @@ mod chat; mod chat_message; mod header; mod profile; -mod stream; +mod stream_tile; mod stream_list; mod stream_player; mod video_placeholder; +mod stream_title; +mod write_chat; use crate::route::RouteServices; use egui::{Response, Ui}; @@ -22,3 +24,5 @@ pub use self::profile::Profile; pub use self::stream_list::StreamList; pub use self::stream_player::StreamPlayer; pub use self::video_placeholder::VideoPlaceholder; +pub use self::stream_title::StreamTitle; +pub use self::write_chat::WriteChat; diff --git a/src/widgets/profile.rs b/src/widgets/profile.rs index d40a586..a027a55 100644 --- a/src/widgets/profile.rs +++ b/src/widgets/profile.rs @@ -8,16 +8,21 @@ pub struct Profile<'a> { size: f32, pubkey: &'a [u8; 32], profile: Option>, + profile_image: Option>, sub: Option, } 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); + + let img = p + .map_or(None, |f| f.picture().map(|f| services.img_cache.load(f))); Self { pubkey, size: 40., profile: p, + profile_image: img, sub, } } @@ -32,17 +37,13 @@ impl<'a> Widget for Profile<'a> { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.; - let img = self - .profile - .map_or(None, |f| f.picture().map(|f| Image::from_uri(f))); - ui.add(Avatar::new_optional(img).size(self.size)); + ui.add(Avatar::new_optional(self.profile_image).size(self.size)); let name = self .profile .map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f)); let name = RichText::new(name).size(13.).color(Color32::WHITE); ui.add(Label::new(name).wrap_mode(TextWrapMode::Truncate)); - }) - .response + }).response } } diff --git a/src/widgets/stream_list.rs b/src/widgets/stream_list.rs index 8eceec1..a0af011 100644 --- a/src/widgets/stream_list.rs +++ b/src/widgets/stream_list.rs @@ -1,5 +1,5 @@ use crate::route::RouteServices; -use crate::widgets::stream::StreamEvent; +use crate::widgets::stream_tile::StreamEvent; use egui::{Frame, Margin, Response, Ui, Widget}; use nostrdb::Note; diff --git a/src/widgets/stream.rs b/src/widgets/stream_tile.rs similarity index 71% rename from src/widgets/stream.rs rename to src/widgets/stream_tile.rs index 02bc8a5..3ec2cfb 100644 --- a/src/widgets/stream.rs +++ b/src/widgets/stream_tile.rs @@ -6,6 +6,7 @@ use crate::widgets::VideoPlaceholder; use eframe::epaint::Vec2; use egui::{Color32, Image, Label, Response, RichText, Rounding, Sense, TextWrapMode, Ui, Widget}; use nostrdb::{NdbStrVariant, Note}; +use crate::stream_info::StreamInfo; pub struct StreamEvent<'a> { event: &'a Note<'a>, @@ -18,7 +19,7 @@ impl<'a> StreamEvent<'a> { let image = event.get_tag_value("image"); let cover = match image { Some(i) => match i.variant().str() { - Some(i) => Some(Image::from_uri(i)), + Some(i) => Some(services.img_cache.load(i)), None => None, }, None => None, @@ -35,15 +36,7 @@ impl Widget for StreamEvent<'_> { ui.vertical(|ui| { ui.style_mut().spacing.item_spacing = Vec2::new(12., 16.); - let host = match self.event.find_tag_value(|t| { - t[0].variant().str() == Some("p") && t[3].variant().str() == Some("host") - }) { - Some(t) => match t.variant() { - NdbStrVariant::Id(i) => i, - NdbStrVariant::Str(s) => self.event.pubkey(), - }, - None => self.event.pubkey(), - }; + let host = self.event.host(); let w = ui.available_width(); let h = (w / 16.0) * 9.0; let img_size = Vec2::new(w, h); @@ -64,15 +57,12 @@ impl Widget for StreamEvent<'_> { } ui.horizontal(|ui| { ui.add(Avatar::pubkey(&host, self.services).size(40.)); - let title = RichText::new(match self.event.get_tag_value("title") { - Some(s) => s.variant().str().unwrap_or("Unknown"), - None => "Unknown", - }) - .size(16.) - .color(Color32::WHITE); + let title = RichText::new(self.event.title().unwrap_or("Untitled")) + .size(16.) + .color(Color32::WHITE); ui.add(Label::new(title).wrap_mode(TextWrapMode::Truncate)); }) }) - .response + .response } } diff --git a/src/widgets/stream_title.rs b/src/widgets/stream_title.rs new file mode 100644 index 0000000..5a3ec19 --- /dev/null +++ b/src/widgets/stream_title.rs @@ -0,0 +1,42 @@ +use crate::note_util::NoteUtil; +use crate::route::RouteServices; +use crate::stream_info::StreamInfo; +use crate::widgets::{NostrWidget, Profile}; +use egui::{Color32, Frame, Label, Margin, Response, RichText, TextWrapMode, Ui, Widget}; +use nostrdb::Note; + +pub struct StreamTitle<'a> { + event: &'a Note<'a>, +} + +impl<'a> StreamTitle<'a> { + pub fn new(event: &'a Note<'a>) -> StreamTitle { + StreamTitle { + event + } + } +} + +impl<'a> NostrWidget for StreamTitle<'a> { + fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { + Frame::none() + .outer_margin(Margin::symmetric(12., 8.)) + .show(ui, |ui| { + ui.style_mut().spacing.item_spacing.y = 8.; + let title = RichText::new(self.event.title().unwrap_or("Untitled")) + .size(20.) + .color(Color32::WHITE); + ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Truncate)); + + Profile::new(self.event.host(), services) + .size(32.) + .ui(ui); + + if let Some(summary) = self.event.get_tag_value("summary").map_or(None, |r| r.variant().str()) { + let summary = RichText::new(summary) + .color(Color32::WHITE); + ui.add(Label::new(summary).wrap_mode(TextWrapMode::Truncate)); + } + }).response + } +} \ No newline at end of file diff --git a/src/widgets/write_chat.rs b/src/widgets/write_chat.rs new file mode 100644 index 0000000..ff6bf9d --- /dev/null +++ b/src/widgets/write_chat.rs @@ -0,0 +1,47 @@ +use crate::route::RouteServices; +use crate::theme::NEUTRAL_900; +use crate::widgets::NostrWidget; +use egui::{Button, Frame, Image, Margin, Rect, Response, Rounding, Sense, Shadow, Stroke, TextEdit, Ui, Vec2, Widget}; +use log::info; + +pub struct WriteChat { + msg: String, +} + +impl WriteChat { + pub fn new() -> Self { + Self { + msg: String::new(), + } + } +} + +impl NostrWidget for WriteChat { + fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response { + let size = ui.available_size(); + let logo_bytes = include_bytes!("../resources/send-03.svg"); + Frame::none() + .inner_margin(Margin::symmetric(12., 6.)) + .stroke(Stroke::new(1.0, NEUTRAL_900)) + .show(ui, |ui| { + Frame::none() + .fill(NEUTRAL_900) + .rounding(Rounding::same(12.0)) + .inner_margin(Margin::symmetric(12., 12.)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let editor = TextEdit::singleline(&mut self.msg) + .frame(false); + ui.add(editor); + if Image::from_bytes("send-03.svg", logo_bytes) + .sense(Sense::click()) + .ui(ui) + .clicked() { + info!("Sending: {}", self.msg); + self.msg.clear(); + } + }); + }) + }).response + } +} \ No newline at end of file