diff --git a/src/lib.rs b/src/lib.rs index 33314ba..a111ee0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,8 +9,10 @@ mod profiles; mod route; mod services; mod stream_info; +mod sub; mod theme; mod widgets; +mod zap; #[cfg(target_os = "android")] use android_activity::AndroidApp; diff --git a/src/route/home.rs b/src/route/home.rs index f0a54f7..cff91fc 100644 --- a/src/route/home.rs +++ b/src/route/home.rs @@ -2,15 +2,16 @@ use crate::note_ref::NoteRef; use crate::note_view::NotesView; use crate::route::RouteServices; use crate::stream_info::{StreamInfo, StreamStatus}; +use crate::sub::SubRef; use crate::widgets; use crate::widgets::{sub_or_poll, NostrWidget}; use egui::{Id, Response, RichText, ScrollArea, Ui}; -use nostrdb::{Filter, Note, Subscription}; +use nostrdb::{Filter, Note}; use std::collections::HashSet; pub struct HomePage { events: HashSet, - sub: Option, + sub: Option, } impl HomePage { diff --git a/src/route/stream.rs b/src/route/stream.rs index 9febb9e..2a8b6c8 100644 --- a/src/route/stream.rs +++ b/src/route/stream.rs @@ -6,9 +6,10 @@ use crate::widgets::{ sub_or_poll, Chat, NostrWidget, PlaceholderRect, StreamPlayer, StreamTitle, WriteChat, }; use egui::{vec2, Align, Frame, Layout, Response, Stroke, Ui, Vec2, Widget}; -use nostrdb::{Filter, Note, Subscription}; +use nostrdb::{Filter, Note}; use crate::note_ref::NoteRef; +use crate::sub::SubRef; use std::borrow::Borrow; use std::collections::HashSet; @@ -19,7 +20,7 @@ pub struct StreamPage { new_msg: WriteChat, events: HashSet, - sub: Option, + sub: Option, } impl StreamPage { diff --git a/src/sub.rs b/src/sub.rs new file mode 100644 index 0000000..c6942d9 --- /dev/null +++ b/src/sub.rs @@ -0,0 +1,21 @@ +use log::info; +use nostrdb::{Ndb, Subscription}; + +pub struct SubRef { + pub sub: Subscription, + ndb: Ndb, +} + +impl SubRef { + pub fn new(sub: Subscription, ndb: Ndb) -> Self { + info!("Creating sub: {}", sub.id()); + SubRef { sub, ndb } + } +} + +impl Drop for SubRef { + fn drop(&mut self) { + self.ndb.unsubscribe(self.sub).expect("unsubscribe failed"); + info!("Closing sub: {}", self.sub.id()); + } +} diff --git a/src/theme.rs b/src/theme.rs index c441515..f6ef8c6 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -7,3 +7,4 @@ pub const PRIMARY: Color32 = Color32::from_rgb(248, 56, 217); pub const NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115); pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38); pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23); +pub const ZAP: Color32 = Color32::from_rgb(255, 141, 43); diff --git a/src/widgets/chat.rs b/src/widgets/chat.rs index 4df3e13..3b9fbca 100644 --- a/src/widgets/chat.rs +++ b/src/widgets/chat.rs @@ -1,18 +1,21 @@ use crate::link::NostrLink; use crate::note_ref::NoteRef; use crate::route::RouteServices; +use crate::sub::SubRef; use crate::widgets::chat_message::ChatMessage; +use crate::widgets::chat_zap::ChatZap; use crate::widgets::{sub_or_poll, NostrWidget}; +use crate::zap::Zap; use egui::{Frame, Margin, Response, ScrollArea, Ui}; use itertools::Itertools; -use nostrdb::{Filter, NoteKey, Subscription}; +use nostrdb::{Filter, NoteKey}; use std::collections::HashSet; pub struct Chat { link: NostrLink, stream: NoteKey, events: HashSet, - sub: Option, + sub: Option, } impl Chat { @@ -27,7 +30,7 @@ impl Chat { pub fn get_filter(&self) -> Filter { Filter::new() - .kinds([1_311]) + .kinds([1_311, 9_735]) .tags([self.link.to_tag_value()], 'a') .build() } @@ -57,9 +60,21 @@ impl NostrWidget for Chat { if let Ok(ev) = services.ctx.ndb.get_note_by_key(services.tx, ev.key) { - let profile = services.profile(ev.pubkey()); - ChatMessage::new(&stream, &ev, &profile) - .render(ui, services.ctx.img_cache); + match ev.kind() { + 1311 => { + let profile = services.profile(ev.pubkey()); + ChatMessage::new(&stream, &ev, &profile) + .render(ui, services.ctx.img_cache); + } + 9735 => { + if let Ok(zap) = Zap::from_receipt(ev) { + let profile = services.profile(&zap.sender); + ChatZap::new(&zap, &profile) + .render(ui, services.ctx.img_cache); + } + } + _ => {} + } } } }) diff --git a/src/widgets/chat_message.rs b/src/widgets/chat_message.rs index c3f0c7c..30ef563 100644 --- a/src/widgets/chat_message.rs +++ b/src/widgets/chat_message.rs @@ -26,7 +26,7 @@ impl<'a> ChatMessage<'a> { } } - pub fn render(&mut self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response { + pub fn render(self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response { ui.horizontal_wrapped(|ui| { let mut job = LayoutJob::default(); // TODO: avoid this somehow @@ -52,6 +52,9 @@ impl<'a> ChatMessage<'a> { .size(24.) .render(ui, img_cache); ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap)); + + // consume reset of space + ui.add_space(ui.available_size_before_wrap().x); }) .response } diff --git a/src/widgets/chat_zap.rs b/src/widgets/chat_zap.rs new file mode 100644 index 0000000..38d2958 --- /dev/null +++ b/src/widgets/chat_zap.rs @@ -0,0 +1,68 @@ +use crate::theme::{MARGIN_DEFAULT, ROUNDING_DEFAULT, ZAP}; +use crate::widgets::Avatar; +use crate::zap::Zap; +use eframe::emath::Align; +use eframe::epaint::text::{LayoutJob, TextFormat, TextWrapMode}; +use eframe::epaint::Color32; +use egui::{Frame, Label, Response, Stroke, Ui}; +use nostrdb::NdbProfile; +use notedeck::ImageCache; + +pub struct ChatZap<'a> { + zap: &'a Zap<'a>, + profile: &'a Option>, +} + +impl<'a> ChatZap<'a> { + pub fn new(zap: &'a Zap, profile: &'a Option>) -> Self { + Self { zap, profile } + } + + pub fn render(self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response { + Frame::default() + .rounding(ROUNDING_DEFAULT) + .inner_margin(MARGIN_DEFAULT) + .stroke(Stroke::new(1., ZAP)) + .show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + let mut job = LayoutJob::default(); + // TODO: avoid this somehow + job.wrap.break_anywhere = true; + + let name = self + .profile + .map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f)); + + let mut format = TextFormat::default(); + format.line_height = Some(24.0); + format.valign = Align::Center; + + format.color = ZAP; + job.append(name, 0.0, format.clone()); + format.color = Color32::WHITE; + job.append("zapped", 5.0, format.clone()); + format.color = ZAP; + job.append( + (self.zap.amount / 1000).to_string().as_str(), + 5.0, + format.clone(), + ); + format.color = Color32::WHITE; + job.append("sats", 5.0, format.clone()); + + if !self.zap.message.is_empty() { + job.append(&format!("\n{}", self.zap.message), 0.0, format.clone()); + } + + Avatar::from_profile(&self.profile) + .size(24.) + .render(ui, img_cache); + ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap)); + + // consume reset of space + ui.add_space(ui.available_size_before_wrap().x); + }); + }) + .response + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index eed309a..8ac8ca0 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -2,6 +2,7 @@ mod avatar; mod button; mod chat; mod chat_message; +mod chat_zap; mod header; mod placeholder_rect; mod profile; @@ -15,9 +16,10 @@ mod write_chat; use crate::note_ref::NoteRef; use crate::route::RouteServices; +use crate::sub::SubRef; use egui::{Response, Ui}; use enostr::RelayPool; -use nostrdb::{Filter, Ndb, Subscription, Transaction}; +use nostrdb::{Filter, Ndb, Transaction}; use std::collections::HashSet; /// A stateful widget which requests nostr data @@ -35,18 +37,18 @@ pub fn sub_or_poll( tx: &Transaction, pool: &mut RelayPool, store: &mut HashSet, - sub: &mut Option, + sub: &mut Option, filters: Vec, ) -> anyhow::Result<()> { if let Some(sub) = sub { - ndb.poll_for_notes(*sub, 500).into_iter().for_each(|e| { + ndb.poll_for_notes(sub.sub, 500).into_iter().for_each(|e| { if let Ok(note) = ndb.get_note_by_key(tx, e) { store.insert(NoteRef::from_note(¬e)); } }); } else { let s = ndb.subscribe(filters.as_slice())?; - sub.replace(s); + sub.replace(SubRef::new(s, ndb.clone())); ndb.query(tx, filters.as_slice(), 500)? .into_iter() .for_each(|e| { diff --git a/src/zap.rs b/src/zap.rs new file mode 100644 index 0000000..ce40953 --- /dev/null +++ b/src/zap.rs @@ -0,0 +1,56 @@ +use crate::note_util::NoteUtil; +use anyhow::{anyhow, bail, Result}; +use nostr::{Event, JsonUtil, Kind, TagStandard}; +use nostrdb::Note; + +pub struct Zap<'a> { + pub sender: [u8; 32], + pub receiver: [u8; 32], + pub zapper_service: &'a [u8; 32], + pub amount: u64, + pub message: String, +} + +impl<'a> Zap<'a> { + pub fn from_receipt(event: Note<'a>) -> Result { + if event.kind() != 9735 { + bail!("not a zap receipt"); + } + + let req_json = event + .get_tag_value("description") + .ok_or(anyhow!("missing description"))?; + let req = Event::from_json( + req_json + .variant() + .str() + .ok_or(anyhow!("empty description"))?, + )?; + + if req.kind != Kind::ZapRequest { + bail!("not a zap request"); + } + + let dest = req + .tags + .iter() + .find_map(|t| match t.as_standardized() { + Some(TagStandard::PublicKey { public_key, .. }) => Some(public_key.to_bytes()), + _ => None, + }) + .ok_or(anyhow!("missing p tag in zap request"))?; + + let amount = req.tags.iter().find_map(|t| match t.as_standardized() { + Some(TagStandard::Amount { millisats, .. }) => Some(*millisats), + _ => None, + }); + + Ok(Zap { + sender: req.pubkey.to_bytes(), + receiver: dest, + zapper_service: event.pubkey(), + amount: amount.unwrap_or(0u64), + message: req.content, + }) + } +}