feat: profile page

This commit is contained in:
kieran 2025-01-13 12:13:37 +00:00
parent 73d03ca0f1
commit 7e9e61d14c
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
18 changed files with 261 additions and 52 deletions

View File

@ -1,5 +1,6 @@
use crate::profiles::ProfileLoader; use crate::profiles::ProfileLoader;
use crate::route::{page, RouteAction, RouteServices, RouteType}; use crate::route::{page, RouteAction, RouteServices, RouteType};
use crate::theme::MARGIN_DEFAULT;
use crate::widgets::{Header, NostrWidget}; use crate::widgets::{Header, NostrWidget};
use eframe::epaint::{FontFamily, Margin}; use eframe::epaint::{FontFamily, Margin};
use eframe::CreationContext; use eframe::CreationContext;
@ -85,6 +86,7 @@ impl notedeck::App for ZapStreamApp {
if let Err(e) = ctx.ndb.process_event(ev) { if let Err(e) = ctx.ndb.process_event(ev) {
error!("Error processing event: {:?}", e); error!("Error processing event: {:?}", e);
} }
ui.ctx().request_repaint();
} }
RelayMessage::Notice(m) => warn!("Notice from {}: {}", relay, m), 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(); let app_frame = egui::containers::Frame::default().outer_margin(self.frame_margin());
app_frame.inner_margin = self.frame_margin();
// handle app state changes // handle app state changes
while let Ok(r) = self.routes_rx.try_recv() { while let Ok(r) = self.routes_rx.try_recv() {
@ -125,6 +126,11 @@ impl notedeck::App for ZapStreamApp {
RouteType::LoginPage => { RouteType::LoginPage => {
self.widget = Box::new(page::LoginPage::new()); 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!"), RouteType::Action { .. } => panic!("Actions!"),
_ => panic!("Not implemented"), _ => panic!("Not implemented"),
} }

View File

@ -20,6 +20,15 @@ pub enum IdOrStr {
Str(String), 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 { impl Display for IdOrStr {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self { 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<String> { pub fn to_tag(&self) -> Vec<String> {
if self.hrp == NostrLinkType::Coordinate { if self.hrp == NostrLinkType::Coordinate {
vec!["a".to_string(), self.to_tag_value()] vec!["a".to_string(), self.to_tag_value()]

View File

@ -34,7 +34,8 @@ impl NostrWidget for HomePage {
let events: Vec<Note> = self let events: Vec<Note> = self
.events .events
.iter() .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(); .collect();
let events_live = NotesView::from_vec( let events_live = NotesView::from_vec(
@ -68,7 +69,14 @@ impl NostrWidget for HomePage {
let events_ended = NotesView::from_vec( let events_ended = NotesView::from_vec(
events events
.iter() .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(), .collect(),
); );
if events_ended.len() > 0 { if events_ended.len() > 0 {

View File

@ -1,9 +1,9 @@
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::services::ffmpeg_loader::FfmpegLoader; use crate::services::ffmpeg_loader::FfmpegLoader;
use crate::PollOption; use crate::widgets::PlaceholderRect;
use anyhow::{anyhow, bail}; use anyhow::{anyhow, bail};
use egui::load::SizedTexture; use egui::load::SizedTexture;
use egui::{Context, Id, Image, TextureHandle}; use egui::{Context, Id, Image, ImageSource, TextureHandle, Ui};
use ehttp::Response; use ehttp::Response;
use enostr::EventClientMessage; use enostr::EventClientMessage;
use lnurl::lightning_address::LightningAddress; use lnurl::lightning_address::LightningAddress;
@ -21,13 +21,14 @@ use std::task::Poll;
mod home; mod home;
mod login; mod login;
mod profile;
mod stream; mod stream;
pub mod page { pub mod page {
use crate::route::{home, login, stream}; pub use super::home::HomePage;
pub use home::HomePage; pub use super::login::LoginPage;
pub use login::LoginPage; pub use super::profile::ProfilePage;
pub use stream::StreamPage; pub use super::stream::StreamPage;
} }
#[derive(PartialEq)] #[derive(PartialEq)]
@ -39,7 +40,6 @@ pub enum RouteType {
}, },
ProfilePage { ProfilePage {
link: NostrLink, link: NostrLink,
profile: Option<NoteKey>,
}, },
LoginPage, LoginPage,
@ -117,17 +117,6 @@ impl<'a, 'ctx> RouteServices<'a, 'ctx> {
p 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 /// Create a poll_promise fetch
pub fn fetch(&mut self, url: &str) -> Poll<&ehttp::Result<Response>> { pub fn fetch(&mut self, url: &str) -> Poll<&ehttp::Result<Response>> {
if !self.fetch.contains_key(url) { 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]; 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) { if let Some(promise) = img_cache.map().get(url) {
match promise.poll() { match promise.poll() {
Poll::Ready(Ok(t)) => Image::new(SizedTexture::from_handle(t)), Poll::Ready(Ok(t)) => Image::new(SizedTexture::from_handle(t)),
_ => Image::from_bytes(url.to_string(), &BLACK_PIXEL), _ => Image::from_bytes(url.to_string(), &BLACK_PIXEL),
} }
} else { } 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); img_cache.map_mut().insert(url.to_string(), fetch);
Image::from_bytes(url.to_string(), &BLACK_PIXEL) Image::from_bytes(url.to_string(), &BLACK_PIXEL)
} }

85
src/route/profile.rs Normal file
View File

@ -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<NoteRef>,
sub: Option<SubRef>,
}
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<Note> = 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(),
],
)
}
}

View File

@ -152,7 +152,7 @@ impl NostrWidget for StreamPage {
.collect(); .collect();
if let Some(event) = events.first() { if let Some(event) = events.first() {
if let Some(stream) = event.stream() { if let Some(stream) = event.streaming() {
if self.player.is_none() { if self.player.is_none() {
let p = StreamPlayer::new(ui.ctx(), &stream.to_string()); let p = StreamPlayer::new(ui.ctx(), &stream.to_string());
self.player = Some(p); self.player = Some(p);

View File

@ -26,7 +26,10 @@ pub trait StreamInfo {
fn host(&self) -> &[u8; 32]; 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 /// Is the stream playable by this app
fn can_play(&self) -> bool; 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") { if let Some(s) = self.get_tag_value("streaming") {
s.variant().str() s.variant().str()
} else { } 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 /// Is the stream playable by this app
fn can_play(&self) -> bool { fn can_play(&self) -> bool {
if let Some(stream) = self.stream() { if let Some(stream) = self.streaming() {
stream.contains(".m3u8") stream.contains(".m3u8")
} else { } else {
false false

View File

@ -1,6 +1,7 @@
use egui::{Color32, Margin}; use egui::{Color32, Margin};
pub const FONT_SIZE: f32 = 13.0; 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 FONT_SIZE_LG: f32 = FONT_SIZE * 1.5;
pub const ROUNDING_DEFAULT: f32 = 12.0; pub const ROUNDING_DEFAULT: f32 = 12.0;
pub const MARGIN_DEFAULT: Margin = Margin::symmetric(12., 6.); pub const MARGIN_DEFAULT: Margin = Margin::symmetric(12., 6.);

View File

@ -53,13 +53,15 @@ impl Avatar {
pub fn render(self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response { pub fn render(self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response {
let size_v = self.size.unwrap_or(40.); let size_v = self.size.unwrap_or(40.);
let size = Vec2::new(size_v, size_v); 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); return Self::placeholder(ui, size_v);
} }
match &self.image { 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) .fit_to_exact_size(size)
.rounding(Rounding::same(size_v)) .rounding(Rounding::same(size_v))
.sense(Sense::click())
.ui(ui), .ui(ui),
None => Self::placeholder(ui, size_v), None => Self::placeholder(ui, size_v),
} }

View File

@ -1,9 +1,10 @@
use crate::link::NostrLink;
use crate::route::{RouteServices, RouteType}; use crate::route::{RouteServices, RouteType};
use crate::widgets::avatar::Avatar; use crate::widgets::avatar::Avatar;
use crate::widgets::Button; use crate::widgets::Button;
use eframe::emath::Align; use eframe::emath::Align;
use eframe::epaint::Vec2; 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; use nostrdb::Transaction;
pub struct Header; pub struct Header;
@ -27,8 +28,7 @@ impl Header {
Layout::left_to_right(Align::Center), Layout::left_to_right(Align::Center),
|ui| { |ui| {
ui.style_mut().spacing.item_spacing.x = 16.; ui.style_mut().spacing.item_spacing.x = 16.;
if services if Image::from_bytes("logo.svg", logo_bytes)
.image_bytes("logo.svg", logo_bytes)
.max_height(24.) .max_height(24.)
.sense(Sense::click()) .sense(Sense::click())
.ui(ui) .ui(ui)
@ -40,8 +40,14 @@ impl Header {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if let Some(acc) = services.ctx.accounts.get_selected_account() { if let Some(acc) = services.ctx.accounts.get_selected_account() {
Avatar::pubkey(&acc.pubkey, services.ctx.ndb, tx) if Avatar::pubkey(&acc.pubkey, services.ctx.ndb, tx)
.render(ui, services.ctx.img_cache); .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() { } else if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
services.navigate(RouteType::LoginPage); services.navigate(RouteType::LoginPage);
} }

View File

@ -4,6 +4,7 @@ mod chat;
mod chat_message; mod chat_message;
mod chat_zap; mod chat_zap;
mod header; mod header;
mod pill;
mod placeholder_rect; mod placeholder_rect;
mod profile; mod profile;
mod stream_list; mod stream_list;
@ -64,6 +65,7 @@ pub use self::avatar::Avatar;
pub use self::button::Button; pub use self::button::Button;
pub use self::chat::Chat; pub use self::chat::Chat;
pub use self::header::Header; pub use self::header::Header;
pub use self::pill::Pill;
pub use self::placeholder_rect::PlaceholderRect; pub use self::placeholder_rect::PlaceholderRect;
pub use self::profile::Profile; pub use self::profile::Profile;
pub use self::stream_list::StreamList; pub use self::stream_list::StreamList;

35
src/widgets/pill.rs Normal file
View File

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

View File

@ -2,15 +2,29 @@ use crate::route::RouteServices;
use crate::theme::FONT_SIZE; use crate::theme::FONT_SIZE;
use crate::widgets::{Avatar, Username}; use crate::widgets::{Avatar, Username};
use egui::{Response, Ui}; use egui::{Response, Ui};
use nostrdb::NdbProfile;
pub struct Profile<'a> { pub struct Profile<'a> {
size: f32, size: f32,
pubkey: &'a [u8; 32], pubkey: &'a [u8; 32],
profile: &'a Option<NdbProfile<'a>>,
} }
impl<'a> Profile<'a> { impl<'a> Profile<'a> {
pub fn new(pubkey: &'a [u8; 32]) -> Self { 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<NdbProfile<'a>>) -> Self {
Self {
pubkey,
profile,
size: 40.,
}
} }
pub fn size(self, size: f32) -> Self { pub fn size(self, size: f32) -> Self {
@ -21,7 +35,11 @@ impl<'a> Profile<'a> {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.; 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) Avatar::from_profile(&profile)
.size(self.size) .size(self.size)
.render(ui, services.ctx.img_cache); .render(ui, services.ctx.img_cache);

View File

@ -1,6 +1,7 @@
use crate::note_view::NotesView; use crate::note_view::NotesView;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::stream_info::StreamInfo; use crate::stream_info::StreamInfo;
use crate::theme::MARGIN_DEFAULT;
use crate::widgets::stream_tile::StreamEvent; use crate::widgets::stream_tile::StreamEvent;
use egui::{vec2, Frame, Grid, Margin, Response, Ui, WidgetText}; use egui::{vec2, Frame, Grid, Margin, Response, Ui, WidgetText};
use itertools::Itertools; use itertools::Itertools;
@ -35,9 +36,8 @@ impl<'a> StreamList<'a> {
}; };
let grid_padding = 20.; let grid_padding = 20.;
let frame_margin = 16.0;
Frame::none() Frame::none()
.inner_margin(Margin::symmetric(frame_margin, 0.)) .inner_margin(MARGIN_DEFAULT)
.show(ui, |ui| { .show(ui, |ui| {
let grid_spacing_consumed = (cols - 1) as f32 * grid_padding; let grid_spacing_consumed = (cols - 1) as f32 * grid_padding;
let g_w = (ui.available_width() - grid_spacing_consumed) / cols as f32; let g_w = (ui.available_width() - grid_spacing_consumed) / cols as f32;

View File

@ -1,5 +1,5 @@
use crate::link::NostrLink; 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::stream_info::{StreamInfo, StreamStatus};
use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT}; use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT};
use crate::widgets::avatar::Avatar; 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 (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click());
let cover = if ui.is_visible() { let cover = if ui.is_rect_visible(response.rect) {
self.event.image().map(|p| services.image(p)) self.event
.image()
.map(|p| image_from_cache(services.ctx.img_cache, ui, p))
} else { } else {
None None
}; };

View File

@ -1,10 +1,11 @@
use crate::note_util::NoteUtil; use crate::note_util::NoteUtil;
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::stream_info::StreamInfo; use crate::stream_info::{StreamInfo, StreamStatus};
use crate::theme::MARGIN_DEFAULT; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, PRIMARY};
use crate::widgets::zap::ZapButton; use crate::widgets::zap::ZapButton;
use crate::widgets::Pill;
use crate::widgets::Profile; 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; use nostrdb::Note;
pub struct StreamTitle<'a> { pub struct StreamTitle<'a> {
@ -19,6 +20,8 @@ impl<'a> StreamTitle<'a> {
Frame::none() Frame::none()
.outer_margin(MARGIN_DEFAULT) .outer_margin(MARGIN_DEFAULT)
.show(ui, |ui| { .show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(5., 8.0);
let title = RichText::new(self.event.title().unwrap_or("Untitled")) let title = RichText::new(self.event.title().unwrap_or("Untitled"))
.size(20.) .size(20.)
.color(Color32::WHITE); .color(Color32::WHITE);
@ -31,6 +34,20 @@ impl<'a> StreamTitle<'a> {
ZapButton::event(self.event).render(ui, services); 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 if let Some(summary) = self
.event .event
.get_tag_value("summary") .get_tag_value("summary")

View File

@ -3,7 +3,7 @@ use crate::route::RouteServices;
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT}; use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT};
use crate::widgets::NativeTextInput; use crate::widgets::NativeTextInput;
use eframe::emath::Align; use eframe::emath::Align;
use egui::{Frame, Layout, Response, Sense, Ui, Widget}; use egui::{Frame, Image, Layout, Response, Sense, Ui, Widget};
use log::info; use log::info;
pub struct WriteChat { pub struct WriteChat {
@ -28,8 +28,7 @@ impl WriteChat {
.rounding(ROUNDING_DEFAULT) .rounding(ROUNDING_DEFAULT)
.show(ui, |ui| { .show(ui, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if services if Image::from_bytes("send-03.svg", logo_bytes)
.image_bytes("send-03.svg", logo_bytes)
.sense(Sense::click()) .sense(Sense::click())
.ui(ui) .ui(ui)
.clicked() .clicked()

View File

@ -7,7 +7,8 @@ use crate::theme::{
use crate::widgets::{Button, NativeTextInput}; use crate::widgets::{Button, NativeTextInput};
use crate::zap::format_sats; use crate::zap::format_sats;
use anyhow::{anyhow, bail}; 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_modal::Modal;
use egui_qr::QrCodeWidget; use egui_qr::QrCodeWidget;
use enostr::PoolRelay; use enostr::PoolRelay;
@ -109,10 +110,17 @@ impl<'a> ZapButton<'a> {
} }
ZapState::Invoice { invoice } => { ZapState::Invoice { invoice } => {
if let Ok(q) = QrCodeWidget::from_data(invoice.pr.as_bytes()) { 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(); let mut job = LayoutJob::default();
ui.label(rt); 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) => { ZapState::Error(e) => {