feat: chat

feat: android setup
This commit is contained in:
kieran 2024-10-16 20:23:52 +01:00
parent f0a50918a8
commit 6017ce18d4
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
28 changed files with 556 additions and 146 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
/lock.mdb
/data.mdb
/.idea
/cache

113
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

39
src/lib.rs Normal file
View File

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

View File

@ -112,6 +112,8 @@ impl TryInto<Filter> for &NostrLink {
fn try_into(self) -> Result<Filter, Self::Error> {
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(),

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5004 11.9998H5.00043M4.91577 12.2913L2.58085 19.266C2.39742 19.8139 2.3057 20.0879 2.37152 20.2566C2.42868 20.4031 2.55144 20.5142 2.70292 20.5565C2.87736 20.6052 3.14083 20.4866 3.66776 20.2495L20.3792 12.7293C20.8936 12.4979 21.1507 12.3822 21.2302 12.2214C21.2993 12.0817 21.2993 11.9179 21.2302 11.7782C21.1507 11.6174 20.8936 11.5017 20.3792 11.2703L3.66193 3.74751C3.13659 3.51111 2.87392 3.39291 2.69966 3.4414C2.54832 3.48351 2.42556 3.59429 2.36821 3.74054C2.30216 3.90893 2.3929 4.18231 2.57437 4.72906L4.91642 11.7853C4.94759 11.8792 4.96317 11.9262 4.96933 11.9742C4.97479 12.0168 4.97473 12.0599 4.96916 12.1025C4.96289 12.1506 4.94718 12.1975 4.91577 12.2913Z" stroke="#737373" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 878 B

View File

@ -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());
ScrollArea::vertical()
.show(ui, |ui| {
widgets::StreamList::new(&events, &services).ui(ui)
}).inner
}
}

View File

@ -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| {
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")
}
})
.inner
}).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> {

View File

@ -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<StreamPlayer>,
chat: Option<Chat>,
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);
}
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..")
}

View File

@ -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<Mutex<HashSet<String>>>,
}
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<U>(&self, 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());
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<String>,
{
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()))
}
}

View File

@ -1,2 +1,3 @@
pub mod ndb_wrapper;
pub mod query;
pub mod image_cache;

View File

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

View File

@ -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<QueryTrace> {
let next: Vec<QueryFilter> = self.queue.drain().collect();
let mut next: Vec<QueryFilter> = 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<Vec<QueryFilter>>,
{
self.queue_into_queries.send(QueueDefer {
id: id.to_string(),
filters: filters.into(),
}).unwrap()
}
}

View File

@ -1,38 +1,61 @@
use crate::note_util::NoteUtil;
use nostrdb::Note;
use nostrdb::{NdbStrVariant, Note};
pub trait StreamInfo {
fn title(&self) -> Option<String>;
fn title(&self) -> Option<&str>;
fn summary(&self) -> Option<String>;
fn summary(&self) -> Option<&str>;
fn host(&self) -> [u8; 32];
fn host(&self) -> &[u8; 32];
fn stream(&self) -> Option<String>;
fn stream(&self) -> Option<&str>;
fn starts(&self) -> u64;
}
impl<'a> StreamInfo for Note<'a> {
fn title(&self) -> Option<String> {
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<String> {
todo!()
fn summary(&self) -> Option<&str> {
if let Some(s) = self.get_tag_value("summary") {
s.variant().str()
} else {
None
}
}
fn host(&self) -> [u8; 32] {
todo!()
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<String> {
fn stream(&self) -> Option<&str> {
if let Some(s) = self.get_tag_value("streaming") {
s.variant().str().map(ToString::to_string)
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::<u64>().unwrap_or(self.created_at()))
} else {
self.created_at()
}
}
}

5
src/theme.rs Normal file
View File

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

View File

@ -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<Image<'a>>,
sub: Option<SubWrapper>,
size: Option<f32>,
}
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
}
}

View File

@ -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<OwnedNote>,
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| {
Frame::none()
.outer_margin(Margin::symmetric(12., 8.))
.show(ui, |ui| {
ui.vertical(|ui| {
for ev in events {
ChatMessage::new(&ev, services).ui(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
}).response
}).inner
}
}

View File

@ -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<NdbProfile<'a>>, Option<SubWrapper>),
}
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
}
}

View File

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

View File

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

View File

@ -8,16 +8,21 @@ pub struct Profile<'a> {
size: f32,
pubkey: &'a [u8; 32],
profile: Option<NdbProfile<'a>>,
profile_image: Option<Image<'a>>,
sub: Option<SubWrapper>,
}
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
}
}

View File

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

View File

@ -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,10 +57,7 @@ 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",
})
let title = RichText::new(self.event.title().unwrap_or("Untitled"))
.size(16.)
.color(Color32::WHITE);
ui.add(Label::new(title).wrap_mode(TextWrapMode::Truncate));

View File

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

47
src/widgets/write_chat.rs Normal file
View File

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