diff --git a/src/app.rs b/src/app.rs index 54507af..e47672f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,6 @@ use crate::profiles::ProfileLoader; use crate::route::{page, RouteAction, RouteServices, RouteType}; +use crate::theme::MARGIN_DEFAULT; use crate::widgets::{Header, NostrWidget}; use eframe::epaint::{FontFamily, Margin}; use eframe::CreationContext; @@ -85,6 +86,7 @@ impl notedeck::App for ZapStreamApp { if let Err(e) = ctx.ndb.process_event(ev) { error!("Error processing event: {:?}", e); } + ui.ctx().request_repaint(); } RelayMessage::Notice(m) => warn!("Notice from {}: {}", relay, m), } @@ -101,8 +103,7 @@ impl notedeck::App for ZapStreamApp { }, ); - let mut app_frame = egui::containers::Frame::default(); - app_frame.inner_margin = self.frame_margin(); + let app_frame = egui::containers::Frame::default().outer_margin(self.frame_margin()); // handle app state changes while let Ok(r) = self.routes_rx.try_recv() { @@ -125,6 +126,11 @@ impl notedeck::App for ZapStreamApp { RouteType::LoginPage => { self.widget = Box::new(page::LoginPage::new()); } + RouteType::ProfilePage { link } => { + self.widget = Box::new(page::ProfilePage::new( + link.id.as_bytes().try_into().unwrap(), + )); + } RouteType::Action { .. } => panic!("Actions!"), _ => panic!("Not implemented"), } diff --git a/src/link.rs b/src/link.rs index 17eb650..025091d 100644 --- a/src/link.rs +++ b/src/link.rs @@ -20,6 +20,15 @@ pub enum IdOrStr { Str(String), } +impl IdOrStr { + pub fn as_bytes(&self) -> &[u8] { + match self { + IdOrStr::Id(i) => i, + IdOrStr::Str(s) => s.as_bytes(), + } + } +} + impl Display for IdOrStr { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -85,6 +94,16 @@ impl NostrLink { } } + pub fn profile(pubkey: &[u8; 32]) -> Self { + Self { + hrp: NostrLinkType::Profile, + id: IdOrStr::Id(*pubkey), + kind: None, + author: None, + relays: vec![], + } + } + pub fn to_tag(&self) -> Vec { if self.hrp == NostrLinkType::Coordinate { vec!["a".to_string(), self.to_tag_value()] diff --git a/src/route/home.rs b/src/route/home.rs index 2389692..6e7ddf9 100644 --- a/src/route/home.rs +++ b/src/route/home.rs @@ -34,7 +34,8 @@ impl NostrWidget for HomePage { let events: Vec = self .events .iter() - .map_while(|n| services.ctx.ndb.get_note_by_key(services.tx, n.key).ok()) + .filter_map(|n| services.ctx.ndb.get_note_by_key(services.tx, n.key).ok()) + .filter(|e| e.can_play()) .collect(); let events_live = NotesView::from_vec( @@ -68,7 +69,14 @@ impl NostrWidget for HomePage { let events_ended = NotesView::from_vec( events .iter() - .filter(|r| matches!(r.status(), StreamStatus::Ended)) + .filter(|r| { + matches!(r.status(), StreamStatus::Ended) + && if let Some(r) = r.recording() { + r.len() > 0 + } else { + false + } + }) .collect(), ); if events_ended.len() > 0 { diff --git a/src/route/mod.rs b/src/route/mod.rs index 3a2e0d9..22828d4 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -1,9 +1,9 @@ use crate::link::NostrLink; use crate::services::ffmpeg_loader::FfmpegLoader; -use crate::PollOption; +use crate::widgets::PlaceholderRect; use anyhow::{anyhow, bail}; use egui::load::SizedTexture; -use egui::{Context, Id, Image, TextureHandle}; +use egui::{Context, Id, Image, ImageSource, TextureHandle, Ui}; use ehttp::Response; use enostr::EventClientMessage; use lnurl::lightning_address::LightningAddress; @@ -21,13 +21,14 @@ use std::task::Poll; mod home; mod login; +mod profile; mod stream; pub mod page { - use crate::route::{home, login, stream}; - pub use home::HomePage; - pub use login::LoginPage; - pub use stream::StreamPage; + pub use super::home::HomePage; + pub use super::login::LoginPage; + pub use super::profile::ProfilePage; + pub use super::stream::StreamPage; } #[derive(PartialEq)] @@ -39,7 +40,6 @@ pub enum RouteType { }, ProfilePage { link: NostrLink, - profile: Option, }, LoginPage, @@ -117,17 +117,6 @@ impl<'a, 'ctx> RouteServices<'a, 'ctx> { p } - /// 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) - } - /// Create a poll_promise fetch pub fn fetch(&mut self, url: &str) -> Poll<&ehttp::Result> { if !self.fetch.contains_key(url) { @@ -199,14 +188,15 @@ impl<'a, 'ctx> RouteServices<'a, 'ctx> { } const BLACK_PIXEL: [u8; 4] = [0, 0, 0, 0]; -pub fn image_from_cache<'a>(img_cache: &mut ImageCache, ctx: &Context, url: &str) -> Image<'a> { + +pub fn image_from_cache<'a>(img_cache: &mut ImageCache, ui: &Ui, url: &str) -> Image<'a> { if let Some(promise) = img_cache.map().get(url) { match promise.poll() { Poll::Ready(Ok(t)) => Image::new(SizedTexture::from_handle(t)), _ => Image::from_bytes(url.to_string(), &BLACK_PIXEL), } } else { - let fetch = fetch_img(img_cache, ctx, url); + let fetch = fetch_img(img_cache, ui.ctx(), url); img_cache.map_mut().insert(url.to_string(), fetch); Image::from_bytes(url.to_string(), &BLACK_PIXEL) } diff --git a/src/route/profile.rs b/src/route/profile.rs new file mode 100644 index 0000000..0cedcd0 --- /dev/null +++ b/src/route/profile.rs @@ -0,0 +1,85 @@ +use crate::note_ref::NoteRef; +use crate::note_view::NotesView; +use crate::route::{image_from_cache, RouteServices}; +use crate::sub::SubRef; +use crate::theme::{MARGIN_DEFAULT, ROUNDING_DEFAULT}; +use crate::widgets::{sub_or_poll, NostrWidget, PlaceholderRect, Profile, StreamList}; +use egui::{vec2, Frame, Id, Response, ScrollArea, Ui, Widget}; +use nostrdb::{Filter, Note}; +use std::collections::HashSet; + +pub struct ProfilePage { + pubkey: [u8; 32], + events: HashSet, + sub: Option, +} + +impl ProfilePage { + pub fn new(pubkey: [u8; 32]) -> Self { + Self { + pubkey, + events: HashSet::new(), + sub: None, + } + } +} + +impl NostrWidget for ProfilePage { + fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response { + let profile = services.profile(&self.pubkey); + + ScrollArea::vertical().show(ui, |ui| { + Frame::default() + .inner_margin(MARGIN_DEFAULT) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 8.0; + + if let Some(banner) = profile.map(|p| p.banner()).flatten() { + image_from_cache(&mut services.ctx.img_cache, ui, banner) + .fit_to_exact_size(vec2(ui.available_width(), 360.0)) + .rounding(ROUNDING_DEFAULT) + .ui(ui); + } else { + ui.add(PlaceholderRect); + } + Profile::from_profile(&self.pubkey, &profile) + .size(88.0) + .render(ui, services); + }); + + let events: Vec = self + .events + .iter() + .filter_map(|e| services.ctx.ndb.get_note_by_key(services.tx, e.key).ok()) + .collect(); + + StreamList::new( + Id::from("profile-streams"), + NotesView::from_vec(events.iter().collect()), + Some("Past Streams"), + ) + .render(ui, services); + }); + ui.response() + } + + fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> { + sub_or_poll( + services.ctx.ndb, + services.tx, + services.ctx.pool, + &mut self.events, + &mut self.sub, + vec![ + Filter::new() + .kinds([30_311]) + .authors(&[self.pubkey]) + .build(), + Filter::new() + .kinds([30_311]) + .pubkeys(&[self.pubkey]) + .build(), + ], + ) + } +} diff --git a/src/route/stream.rs b/src/route/stream.rs index 3417755..3e406fb 100644 --- a/src/route/stream.rs +++ b/src/route/stream.rs @@ -152,7 +152,7 @@ impl NostrWidget for StreamPage { .collect(); if let Some(event) = events.first() { - if let Some(stream) = event.stream() { + if let Some(stream) = event.streaming() { if self.player.is_none() { let p = StreamPlayer::new(ui.ctx(), &stream.to_string()); self.player = Some(p); diff --git a/src/stream_info.rs b/src/stream_info.rs index cb62ee0..afd8d80 100644 --- a/src/stream_info.rs +++ b/src/stream_info.rs @@ -26,7 +26,10 @@ pub trait StreamInfo { fn host(&self) -> &[u8; 32]; - fn stream(&self) -> Option<&str>; + fn streaming(&self) -> Option<&str>; + + fn recording(&self) -> Option<&str>; + /// Is the stream playable by this app fn can_play(&self) -> bool; @@ -68,7 +71,7 @@ impl StreamInfo for Note<'_> { } } - fn stream(&self) -> Option<&str> { + fn streaming(&self) -> Option<&str> { if let Some(s) = self.get_tag_value("streaming") { s.variant().str() } else { @@ -76,9 +79,17 @@ impl StreamInfo for Note<'_> { } } + fn recording(&self) -> Option<&str> { + if let Some(s) = self.get_tag_value("recording") { + s.variant().str() + } else { + None + } + } + /// Is the stream playable by this app fn can_play(&self) -> bool { - if let Some(stream) = self.stream() { + if let Some(stream) = self.streaming() { stream.contains(".m3u8") } else { false diff --git a/src/theme.rs b/src/theme.rs index 571a533..9805670 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,6 +1,7 @@ use egui::{Color32, Margin}; pub const FONT_SIZE: f32 = 13.0; +pub const FONT_SIZE_SM: f32 = FONT_SIZE * 0.8; pub const FONT_SIZE_LG: f32 = FONT_SIZE * 1.5; pub const ROUNDING_DEFAULT: f32 = 12.0; pub const MARGIN_DEFAULT: Margin = Margin::symmetric(12., 6.); diff --git a/src/widgets/avatar.rs b/src/widgets/avatar.rs index f6d47a3..f22616c 100644 --- a/src/widgets/avatar.rs +++ b/src/widgets/avatar.rs @@ -53,13 +53,15 @@ impl Avatar { 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() { + if !ui.is_rect_visible(ui.cursor()) { return Self::placeholder(ui, size_v); } match &self.image { - Some(img) => image_from_cache(img_cache, ui.ctx(), img) + Some(img) => image_from_cache(img_cache, ui, img) + .max_size(size) .fit_to_exact_size(size) .rounding(Rounding::same(size_v)) + .sense(Sense::click()) .ui(ui), None => Self::placeholder(ui, size_v), } diff --git a/src/widgets/header.rs b/src/widgets/header.rs index 1a010e1..ef26e90 100644 --- a/src/widgets/header.rs +++ b/src/widgets/header.rs @@ -1,9 +1,10 @@ +use crate::link::NostrLink; use crate::route::{RouteServices, RouteType}; use crate::widgets::avatar::Avatar; use crate::widgets::Button; use eframe::emath::Align; use eframe::epaint::Vec2; -use egui::{CursorIcon, Frame, Layout, Margin, Response, Sense, Ui, Widget}; +use egui::{CursorIcon, Frame, Image, Layout, Margin, Response, Sense, Ui, Widget}; use nostrdb::Transaction; pub struct Header; @@ -27,8 +28,7 @@ impl Header { Layout::left_to_right(Align::Center), |ui| { ui.style_mut().spacing.item_spacing.x = 16.; - if services - .image_bytes("logo.svg", logo_bytes) + if Image::from_bytes("logo.svg", logo_bytes) .max_height(24.) .sense(Sense::click()) .ui(ui) @@ -40,8 +40,14 @@ impl Header { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if let Some(acc) = services.ctx.accounts.get_selected_account() { - Avatar::pubkey(&acc.pubkey, services.ctx.ndb, tx) - .render(ui, services.ctx.img_cache); + if Avatar::pubkey(&acc.pubkey, services.ctx.ndb, tx) + .render(ui, services.ctx.img_cache) + .clicked() + { + services.navigate(RouteType::ProfilePage { + link: NostrLink::profile(acc.pubkey.bytes()), + }) + } } else if Button::new().show(ui, |ui| ui.label("Login")).clicked() { services.navigate(RouteType::LoginPage); } diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 460d561..a10a69d 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -4,6 +4,7 @@ mod chat; mod chat_message; mod chat_zap; mod header; +mod pill; mod placeholder_rect; mod profile; mod stream_list; @@ -64,6 +65,7 @@ pub use self::avatar::Avatar; pub use self::button::Button; pub use self::chat::Chat; pub use self::header::Header; +pub use self::pill::Pill; pub use self::placeholder_rect::PlaceholderRect; pub use self::profile::Profile; pub use self::stream_list::StreamList; diff --git a/src/widgets/pill.rs b/src/widgets/pill.rs new file mode 100644 index 0000000..1ac60a7 --- /dev/null +++ b/src/widgets/pill.rs @@ -0,0 +1,35 @@ +use crate::theme::{FONT_SIZE, NEUTRAL_800}; +use eframe::epaint::Margin; +use egui::{Color32, Frame, Response, RichText, Ui, Widget}; + +pub struct Pill { + text: String, + color: Color32, +} + +impl Pill { + pub fn new(text: &str) -> Self { + Self { + text: String::from(text), + color: NEUTRAL_800, + } + } + + pub fn color(mut self, color: Color32) -> Self { + self.color = color; + self + } +} + +impl Widget for Pill { + fn ui(self, ui: &mut Ui) -> Response { + Frame::default() + .inner_margin(Margin::symmetric(5.0, 3.0)) + .rounding(5.0) + .fill(self.color) + .show(ui, |ui| { + ui.label(RichText::new(&self.text).size(FONT_SIZE)); + }) + .response + } +} diff --git a/src/widgets/profile.rs b/src/widgets/profile.rs index cb354fa..283fd36 100644 --- a/src/widgets/profile.rs +++ b/src/widgets/profile.rs @@ -2,15 +2,29 @@ use crate::route::RouteServices; use crate::theme::FONT_SIZE; use crate::widgets::{Avatar, Username}; use egui::{Response, Ui}; +use nostrdb::NdbProfile; pub struct Profile<'a> { size: f32, pubkey: &'a [u8; 32], + profile: &'a Option>, } impl<'a> Profile<'a> { pub fn new(pubkey: &'a [u8; 32]) -> Self { - Self { pubkey, size: 40. } + Self { + pubkey, + size: 40., + profile: &None, + } + } + + pub fn from_profile(pubkey: &'a [u8; 32], profile: &'a Option>) -> Self { + Self { + pubkey, + profile, + size: 40., + } } pub fn size(self, size: f32) -> Self { @@ -21,7 +35,11 @@ impl<'a> Profile<'a> { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.; - let profile = services.profile(self.pubkey); + let profile = if let Some(profile) = self.profile { + Some(*profile) + } else { + services.profile(self.pubkey) + }; Avatar::from_profile(&profile) .size(self.size) .render(ui, services.ctx.img_cache); diff --git a/src/widgets/stream_list.rs b/src/widgets/stream_list.rs index 508ff8b..316988c 100644 --- a/src/widgets/stream_list.rs +++ b/src/widgets/stream_list.rs @@ -1,6 +1,7 @@ use crate::note_view::NotesView; use crate::route::RouteServices; use crate::stream_info::StreamInfo; +use crate::theme::MARGIN_DEFAULT; use crate::widgets::stream_tile::StreamEvent; use egui::{vec2, Frame, Grid, Margin, Response, Ui, WidgetText}; use itertools::Itertools; @@ -35,9 +36,8 @@ impl<'a> StreamList<'a> { }; let grid_padding = 20.; - let frame_margin = 16.0; Frame::none() - .inner_margin(Margin::symmetric(frame_margin, 0.)) + .inner_margin(MARGIN_DEFAULT) .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; diff --git a/src/widgets/stream_tile.rs b/src/widgets/stream_tile.rs index 7ac3935..b7c6658 100644 --- a/src/widgets/stream_tile.rs +++ b/src/widgets/stream_tile.rs @@ -1,5 +1,5 @@ use crate::link::NostrLink; -use crate::route::{RouteServices, RouteType}; +use crate::route::{image_from_cache, RouteServices, RouteType}; use crate::stream_info::{StreamInfo, StreamStatus}; use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT}; use crate::widgets::avatar::Avatar; @@ -33,8 +33,10 @@ impl<'a> StreamEvent<'a> { let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click()); - let cover = if ui.is_visible() { - self.event.image().map(|p| services.image(p)) + let cover = if ui.is_rect_visible(response.rect) { + self.event + .image() + .map(|p| image_from_cache(services.ctx.img_cache, ui, p)) } else { None }; diff --git a/src/widgets/stream_title.rs b/src/widgets/stream_title.rs index fe475fd..4c510a9 100644 --- a/src/widgets/stream_title.rs +++ b/src/widgets/stream_title.rs @@ -1,10 +1,11 @@ use crate::note_util::NoteUtil; use crate::route::RouteServices; -use crate::stream_info::StreamInfo; -use crate::theme::MARGIN_DEFAULT; +use crate::stream_info::{StreamInfo, StreamStatus}; +use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, PRIMARY}; use crate::widgets::zap::ZapButton; +use crate::widgets::Pill; use crate::widgets::Profile; -use egui::{Color32, Frame, Label, Response, RichText, TextWrapMode, Ui}; +use egui::{vec2, Color32, Frame, Label, Response, RichText, TextWrapMode, Ui}; use nostrdb::Note; pub struct StreamTitle<'a> { @@ -19,6 +20,8 @@ impl<'a> StreamTitle<'a> { Frame::none() .outer_margin(MARGIN_DEFAULT) .show(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(5., 8.0); + let title = RichText::new(self.event.title().unwrap_or("Untitled")) .size(20.) .color(Color32::WHITE); @@ -31,6 +34,20 @@ impl<'a> StreamTitle<'a> { ZapButton::event(self.event).render(ui, services); }); + ui.horizontal(|ui| { + let status = self.event.status().to_string().to_uppercase(); + let live_label_color = if self.event.status() == StreamStatus::Live { + PRIMARY + } else { + NEUTRAL_900 + }; + ui.add(Pill::new(&status).color(live_label_color)); + + ui.add(Pill::new(&format!( + "{} viewers", + self.event.viewers().unwrap_or(0) + ))); + }); if let Some(summary) = self .event .get_tag_value("summary") diff --git a/src/widgets/write_chat.rs b/src/widgets/write_chat.rs index b73b872..1e066e8 100644 --- a/src/widgets/write_chat.rs +++ b/src/widgets/write_chat.rs @@ -3,7 +3,7 @@ use crate::route::RouteServices; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT}; use crate::widgets::NativeTextInput; use eframe::emath::Align; -use egui::{Frame, Layout, Response, Sense, Ui, Widget}; +use egui::{Frame, Image, Layout, Response, Sense, Ui, Widget}; use log::info; pub struct WriteChat { @@ -28,8 +28,7 @@ impl WriteChat { .rounding(ROUNDING_DEFAULT) .show(ui, |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if services - .image_bytes("send-03.svg", logo_bytes) + if Image::from_bytes("send-03.svg", logo_bytes) .sense(Sense::click()) .ui(ui) .clicked() diff --git a/src/widgets/zap.rs b/src/widgets/zap.rs index 1b990b4..8e1beb1 100644 --- a/src/widgets/zap.rs +++ b/src/widgets/zap.rs @@ -7,7 +7,8 @@ use crate::theme::{ use crate::widgets::{Button, NativeTextInput}; use crate::zap::format_sats; use anyhow::{anyhow, bail}; -use egui::{vec2, Frame, Grid, Response, RichText, Stroke, Ui, Widget}; +use egui::text::{LayoutJob, TextWrapping}; +use egui::{vec2, Frame, Grid, Response, RichText, Stroke, TextFormat, TextWrapMode, Ui, Widget}; use egui_modal::Modal; use egui_qr::QrCodeWidget; use enostr::PoolRelay; @@ -109,10 +110,17 @@ impl<'a> ZapButton<'a> { } ZapState::Invoice { invoice } => { if let Ok(q) = QrCodeWidget::from_data(invoice.pr.as_bytes()) { - ui.add_sized(vec2(256., 256.), q); + ui.vertical_centered(|ui| { + ui.add_sized(vec2(256., 256.), q); - let rt = RichText::new(&invoice.pr).code(); - ui.label(rt); + let mut job = LayoutJob::default(); + job.wrap = TextWrapping::from_wrap_mode_and_width( + TextWrapMode::Truncate, + ui.available_width(), + ); + job.append(&invoice.pr, 0.0, TextFormat::default()); + ui.label(job); + }); } } ZapState::Error(e) => {