notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 8af80d7d10d7eb171e10d6a523599ce4c6582821
parent e4bae57619ea0dd6eb4b2a571b0b44715dfe3cc9
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 17 Apr 2025 11:01:45 -0700

ui: move note and profile rendering to notedeck_ui

We want to render notes in other apps like dave, so lets move
our note rendering to notedeck_ui. We rework NoteAction so it doesn't
have anything specific to notedeck_columns

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 2++
Rcrates/notedeck_columns/src/abbrev.rs -> crates/notedeck/src/abbrev.rs | 0
Mcrates/notedeck/src/lib.rs | 10+++++++++-
Acrates/notedeck/src/name.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcrates/notedeck/src/note.rs | 171-------------------------------------------------------------------------------
Acrates/notedeck/src/note/action.rs | 33+++++++++++++++++++++++++++++++++
Acrates/notedeck/src/note/context.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck/src/note/mod.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck/src/profile.rs | 18++++++++++++++++++
Mcrates/notedeck_chrome/src/chrome.rs | 8+++++---
Mcrates/notedeck_columns/src/actionbar.rs | 226++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/notedeck_columns/src/app.rs | 3++-
Mcrates/notedeck_columns/src/lib.rs | 2--
Mcrates/notedeck_columns/src/nav.rs | 11++++++-----
Mcrates/notedeck_columns/src/profile.rs | 65+----------------------------------------------------------------
Mcrates/notedeck_columns/src/timeline/kind.rs | 2+-
Mcrates/notedeck_columns/src/timeline/route.rs | 9+++------
Mcrates/notedeck_columns/src/ui/accounts.rs | 2+-
Mcrates/notedeck_columns/src/ui/add_column.rs | 5+++--
Dcrates/notedeck_columns/src/ui/anim.rs | 138-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/column/header.rs | 29++++++++++++-----------------
Mcrates/notedeck_columns/src/ui/configure_deck.rs | 5++---
Mcrates/notedeck_columns/src/ui/edit_deck.rs | 6++----
Dcrates/notedeck_columns/src/ui/mention.rs | 110-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/mod.rs | 48++----------------------------------------------
Dcrates/notedeck_columns/src/ui/note/contents.rs | 558-------------------------------------------------------------------------------
Dcrates/notedeck_columns/src/ui/note/context.rs | 213-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/note/mod.rs | 758-------------------------------------------------------------------------------
Dcrates/notedeck_columns/src/ui/note/options.rs | 79-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/note/post.rs | 25+++++++++++++------------
Mcrates/notedeck_columns/src/ui/note/quote_repost.rs | 7++++---
Mcrates/notedeck_columns/src/ui/note/reply.rs | 24+++++++++++++-----------
Dcrates/notedeck_columns/src/ui/note/reply_description.rs | 182-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 14+++++---------
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 131++++++-------------------------------------------------------------------------
Dcrates/notedeck_columns/src/ui/profile/preview.rs | 171-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/relay.rs | 7++-----
Mcrates/notedeck_columns/src/ui/search/mod.rs | 10+++-------
Mcrates/notedeck_columns/src/ui/search_results.rs | 18+++++++++---------
Mcrates/notedeck_columns/src/ui/side_panel.rs | 9++++-----
Mcrates/notedeck_columns/src/ui/support.rs | 6++----
Mcrates/notedeck_columns/src/ui/thread.rs | 14++++----------
Mcrates/notedeck_columns/src/ui/timeline.rs | 30++++++++++++------------------
Dcrates/notedeck_columns/src/ui/username.rs | 94-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/widgets.rs | 37+------------------------------------
Mcrates/notedeck_ui/Cargo.toml | 2++
Mcrates/notedeck_ui/src/anim.rs | 2--
Mcrates/notedeck_ui/src/images.rs | 3+--
Mcrates/notedeck_ui/src/lib.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/notedeck_ui/src/mention.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/note/contents.rs | 542+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/note/context.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/note/mod.rs | 744+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/note/options.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/note/reply_description.rs | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_ui/src/profile/mod.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acrates/notedeck_ui/src/profile/name.rs | 19+++++++++++++++++++
Mcrates/notedeck_ui/src/profile/picture.rs | 5-----
Acrates/notedeck_ui/src/profile/preview.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/username.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/widgets.rs | 35+++++++++++++++++++++++++++++++++++
61 files changed, 2840 insertions(+), 3011 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3288,9 +3288,11 @@ dependencies = [ name = "notedeck_ui" version = "0.3.1" dependencies = [ + "bitflags 2.9.0", "egui", "egui_extras", "ehttp", + "enostr", "image", "nostrdb", "notedeck", diff --git a/crates/notedeck_columns/src/abbrev.rs b/crates/notedeck/src/abbrev.rs diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -1,3 +1,4 @@ +pub mod abbrev; mod accounts; mod app; mod args; @@ -9,10 +10,12 @@ pub mod fonts; mod frame_history; mod imgcache; mod muted; +pub mod name; pub mod note; mod notecache; mod persist; pub mod platform; +pub mod profile; pub mod relay_debug; pub mod relayspec; mod result; @@ -41,9 +44,14 @@ pub use imgcache::{ MediaCacheValue, TextureFrame, TexturedImage, }; pub use muted::{MuteFun, Muted}; -pub use note::{NoteRef, RootIdError, RootNoteId, RootNoteIdBuf}; +pub use name::NostrName; +pub use note::{ + BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, NoteRef, + RootIdError, RootNoteId, RootNoteIdBuf, ZapAction, +}; pub use notecache::{CachedNote, NoteCache}; pub use persist::*; +pub use profile::get_profile_url; pub use relay_debug::RelayDebugView; pub use relayspec::RelaySpec; pub use result::Result; diff --git a/crates/notedeck/src/name.rs b/crates/notedeck/src/name.rs @@ -0,0 +1,64 @@ +use nostrdb::ProfileRecord; + +pub struct NostrName<'a> { + pub username: Option<&'a str>, + pub display_name: Option<&'a str>, + pub nip05: Option<&'a str>, +} + +impl<'a> NostrName<'a> { + pub fn name(&self) -> &'a str { + if let Some(name) = self.username { + name + } else if let Some(name) = self.display_name { + name + } else { + self.nip05.unwrap_or("??") + } + } + + pub fn unknown() -> Self { + Self { + username: None, + display_name: None, + nip05: None, + } + } +} + +fn is_empty(s: &str) -> bool { + s.chars().all(|c| c.is_whitespace()) +} + +pub fn get_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> NostrName<'a> { + let Some(record) = record else { + return NostrName::unknown(); + }; + + let Some(profile) = record.record().profile() else { + return NostrName::unknown(); + }; + + let display_name = profile.display_name().filter(|n| !is_empty(n)); + let username = profile.name().filter(|n| !is_empty(n)); + + let nip05 = if let Some(raw_nip05) = profile.nip05() { + if let Some(at_pos) = raw_nip05.find('@') { + if raw_nip05.starts_with('_') { + raw_nip05.get(at_pos + 1..) + } else { + Some(raw_nip05) + } + } else { + None + } + } else { + None + }; + + NostrName { + username, + display_name, + nip05, + } +} diff --git a/crates/notedeck/src/note.rs b/crates/notedeck/src/note.rs @@ -1,171 +0,0 @@ -use crate::notecache::NoteCache; -use enostr::NoteId; -use nostrdb::{Ndb, Note, NoteKey, QueryResult, Transaction}; -use std::borrow::Borrow; -use std::cmp::Ordering; -use std::fmt; - -#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash)] -pub struct NoteRef { - pub key: NoteKey, - pub created_at: u64, -} - -#[derive(Clone, Copy, Eq, PartialEq, Hash)] -pub struct RootNoteIdBuf([u8; 32]); - -impl fmt::Debug for RootNoteIdBuf { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "RootNoteIdBuf({})", self.hex()) - } -} - -#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)] -pub struct RootNoteId<'a>(&'a [u8; 32]); - -impl RootNoteIdBuf { - pub fn to_note_id(self) -> NoteId { - NoteId::new(self.0) - } - - pub fn bytes(&self) -> &[u8; 32] { - &self.0 - } - - pub fn new( - ndb: &Ndb, - note_cache: &mut NoteCache, - txn: &Transaction, - id: &[u8; 32], - ) -> Result<RootNoteIdBuf, RootIdError> { - root_note_id_from_selected_id(ndb, note_cache, txn, id).map(|rnid| Self(*rnid.bytes())) - } - - pub fn hex(&self) -> String { - hex::encode(self.bytes()) - } - - pub fn new_unsafe(id: [u8; 32]) -> Self { - Self(id) - } - - pub fn borrow(&self) -> RootNoteId<'_> { - RootNoteId(self.bytes()) - } -} - -impl<'a> RootNoteId<'a> { - pub fn to_note_id(self) -> NoteId { - NoteId::new(*self.0) - } - - pub fn bytes(&self) -> &[u8; 32] { - self.0 - } - - pub fn hex(&self) -> String { - hex::encode(self.bytes()) - } - - pub fn to_owned(&self) -> RootNoteIdBuf { - RootNoteIdBuf::new_unsafe(*self.bytes()) - } - - pub fn new( - ndb: &Ndb, - note_cache: &mut NoteCache, - txn: &'a Transaction, - id: &'a [u8; 32], - ) -> Result<RootNoteId<'a>, RootIdError> { - root_note_id_from_selected_id(ndb, note_cache, txn, id) - } - - pub fn new_unsafe(id: &'a [u8; 32]) -> Self { - Self(id) - } -} - -impl Borrow<[u8; 32]> for RootNoteIdBuf { - fn borrow(&self) -> &[u8; 32] { - &self.0 - } -} - -impl Borrow<[u8; 32]> for RootNoteId<'_> { - fn borrow(&self) -> &[u8; 32] { - self.0 - } -} - -impl NoteRef { - pub fn new(key: NoteKey, created_at: u64) -> Self { - NoteRef { key, created_at } - } - - pub fn from_note(note: &Note<'_>) -> Self { - let created_at = note.created_at(); - let key = note.key().expect("todo: implement NoteBuf"); - NoteRef::new(key, created_at) - } - - pub fn from_query_result(qr: QueryResult<'_>) -> Self { - NoteRef { - key: qr.note_key, - created_at: qr.note.created_at(), - } - } -} - -impl Ord for NoteRef { - fn cmp(&self, other: &Self) -> Ordering { - match self.created_at.cmp(&other.created_at) { - Ordering::Equal => self.key.cmp(&other.key), - Ordering::Less => Ordering::Greater, - Ordering::Greater => Ordering::Less, - } - } -} - -impl PartialOrd for NoteRef { - fn partial_cmp(&self, other: &Self) -> Option<Ordering> { - Some(self.cmp(other)) - } -} - -#[derive(Debug, Copy, Clone)] -pub enum RootIdError { - NoteNotFound, - NoRootId, -} - -pub fn root_note_id_from_selected_id<'txn, 'a>( - ndb: &Ndb, - note_cache: &mut NoteCache, - txn: &'txn Transaction, - selected_note_id: &'a [u8; 32], -) -> Result<RootNoteId<'txn>, RootIdError> -where - 'a: 'txn, -{ - let selected_note_key = if let Ok(key) = ndb.get_notekey_by_id(txn, selected_note_id) { - key - } else { - return Err(RootIdError::NoteNotFound); - }; - - let note = if let Ok(note) = ndb.get_note_by_key(txn, selected_note_key) { - note - } else { - return Err(RootIdError::NoteNotFound); - }; - - note_cache - .cached_note_or_insert(selected_note_key, &note) - .reply - .borrow(note.tags()) - .root() - .map_or_else( - || Ok(RootNoteId::new_unsafe(selected_note_id)), - |rnid| Ok(RootNoteId::new_unsafe(rnid.id)), - ) -} diff --git a/crates/notedeck/src/note/action.rs b/crates/notedeck/src/note/action.rs @@ -0,0 +1,33 @@ +use super::context::ContextSelection; +use crate::zaps::NoteZapTargetOwned; +use enostr::{NoteId, Pubkey}; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum NoteAction { + /// User has clicked the quote reply action + Reply(NoteId), + + /// User has clicked the quote repost action + Quote(NoteId), + + /// User has clicked a hashtag + Hashtag(String), + + /// User has clicked a profile + Profile(Pubkey), + + /// User has clicked a note link + Note(NoteId), + + /// User has selected some context option + Context(ContextSelection), + + /// User has clicked the zap action + Zap(ZapAction), +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum ZapAction { + Send(NoteZapTargetOwned), + ClearError(NoteZapTargetOwned), +} diff --git a/crates/notedeck/src/note/context.rs b/crates/notedeck/src/note/context.rs @@ -0,0 +1,63 @@ +use enostr::{ClientMessage, NoteId, Pubkey, RelayPool}; +use nostrdb::{Note, NoteKey}; +use tracing::error; + +/// When broadcasting notes, this determines whether to broadcast +/// over the local network via multicast, or globally +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum BroadcastContext { + LocalNetwork, + Everywhere, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +#[allow(clippy::enum_variant_names)] +pub enum NoteContextSelection { + CopyText, + CopyPubkey, + CopyNoteId, + CopyNoteJSON, + Broadcast(BroadcastContext), +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct ContextSelection { + pub note_key: NoteKey, + pub action: NoteContextSelection, +} + +impl NoteContextSelection { + pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>, pool: &mut RelayPool) { + match self { + NoteContextSelection::Broadcast(context) => { + tracing::info!("Broadcasting note {}", hex::encode(note.id())); + match context { + BroadcastContext::LocalNetwork => { + pool.send_to(&ClientMessage::event(note).unwrap(), "multicast"); + } + + BroadcastContext::Everywhere => { + pool.send(&ClientMessage::event(note).unwrap()); + } + } + } + NoteContextSelection::CopyText => { + ui.ctx().copy_text(note.content().to_string()); + } + NoteContextSelection::CopyPubkey => { + if let Some(bech) = Pubkey::new(*note.pubkey()).to_bech() { + ui.ctx().copy_text(bech); + } + } + NoteContextSelection::CopyNoteId => { + if let Some(bech) = NoteId::new(*note.id()).to_bech() { + ui.ctx().copy_text(bech); + } + } + NoteContextSelection::CopyNoteJSON => match note.json() { + Ok(json) => ui.ctx().copy_text(json), + Err(err) => error!("error copying note json: {err}"), + }, + } + } +} diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -0,0 +1,187 @@ +mod action; +mod context; + +pub use action::{NoteAction, ZapAction}; +pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; + +use crate::{notecache::NoteCache, zaps::Zaps, Images}; +use enostr::{NoteId, RelayPool}; +use nostrdb::{Ndb, Note, NoteKey, QueryResult, Transaction}; +use std::borrow::Borrow; +use std::cmp::Ordering; +use std::fmt; + +/// Aggregates dependencies to reduce the number of parameters +/// passed to inner UI elements, minimizing prop drilling. +pub struct NoteContext<'d> { + pub ndb: &'d Ndb, + pub img_cache: &'d mut Images, + pub note_cache: &'d mut NoteCache, + pub zaps: &'d mut Zaps, + pub pool: &'d mut RelayPool, +} + +#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash)] +pub struct NoteRef { + pub key: NoteKey, + pub created_at: u64, +} + +#[derive(Clone, Copy, Eq, PartialEq, Hash)] +pub struct RootNoteIdBuf([u8; 32]); + +impl fmt::Debug for RootNoteIdBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RootNoteIdBuf({})", self.hex()) + } +} + +#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)] +pub struct RootNoteId<'a>(&'a [u8; 32]); + +impl RootNoteIdBuf { + pub fn to_note_id(self) -> NoteId { + NoteId::new(self.0) + } + + pub fn bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn new( + ndb: &Ndb, + note_cache: &mut NoteCache, + txn: &Transaction, + id: &[u8; 32], + ) -> Result<RootNoteIdBuf, RootIdError> { + root_note_id_from_selected_id(ndb, note_cache, txn, id).map(|rnid| Self(*rnid.bytes())) + } + + pub fn hex(&self) -> String { + hex::encode(self.bytes()) + } + + pub fn new_unsafe(id: [u8; 32]) -> Self { + Self(id) + } + + pub fn borrow(&self) -> RootNoteId<'_> { + RootNoteId(self.bytes()) + } +} + +impl<'a> RootNoteId<'a> { + pub fn to_note_id(self) -> NoteId { + NoteId::new(*self.0) + } + + pub fn bytes(&self) -> &[u8; 32] { + self.0 + } + + pub fn hex(&self) -> String { + hex::encode(self.bytes()) + } + + pub fn to_owned(&self) -> RootNoteIdBuf { + RootNoteIdBuf::new_unsafe(*self.bytes()) + } + + pub fn new( + ndb: &Ndb, + note_cache: &mut NoteCache, + txn: &'a Transaction, + id: &'a [u8; 32], + ) -> Result<RootNoteId<'a>, RootIdError> { + root_note_id_from_selected_id(ndb, note_cache, txn, id) + } + + pub fn new_unsafe(id: &'a [u8; 32]) -> Self { + Self(id) + } +} + +impl Borrow<[u8; 32]> for RootNoteIdBuf { + fn borrow(&self) -> &[u8; 32] { + &self.0 + } +} + +impl Borrow<[u8; 32]> for RootNoteId<'_> { + fn borrow(&self) -> &[u8; 32] { + self.0 + } +} + +impl NoteRef { + pub fn new(key: NoteKey, created_at: u64) -> Self { + NoteRef { key, created_at } + } + + pub fn from_note(note: &Note<'_>) -> Self { + let created_at = note.created_at(); + let key = note.key().expect("todo: implement NoteBuf"); + NoteRef::new(key, created_at) + } + + pub fn from_query_result(qr: QueryResult<'_>) -> Self { + NoteRef { + key: qr.note_key, + created_at: qr.note.created_at(), + } + } +} + +impl Ord for NoteRef { + fn cmp(&self, other: &Self) -> Ordering { + match self.created_at.cmp(&other.created_at) { + Ordering::Equal => self.key.cmp(&other.key), + Ordering::Less => Ordering::Greater, + Ordering::Greater => Ordering::Less, + } + } +} + +impl PartialOrd for NoteRef { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +#[derive(Debug, Copy, Clone)] +pub enum RootIdError { + NoteNotFound, + NoRootId, +} + +pub fn root_note_id_from_selected_id<'txn, 'a>( + ndb: &Ndb, + note_cache: &mut NoteCache, + txn: &'txn Transaction, + selected_note_id: &'a [u8; 32], +) -> Result<RootNoteId<'txn>, RootIdError> +where + 'a: 'txn, +{ + let selected_note_key = if let Ok(key) = ndb.get_notekey_by_id(txn, selected_note_id) { + key + } else { + return Err(RootIdError::NoteNotFound); + }; + + let note = if let Ok(note) = ndb.get_note_by_key(txn, selected_note_key) { + note + } else { + return Err(RootIdError::NoteNotFound); + }; + + note_cache + .cached_note_or_insert(selected_note_key, &note) + .reply + .borrow(note.tags()) + .root() + .map_or_else( + || Ok(RootNoteId::new_unsafe(selected_note_id)), + |rnid| Ok(RootNoteId::new_unsafe(rnid.id)), + ) +} diff --git a/crates/notedeck/src/profile.rs b/crates/notedeck/src/profile.rs @@ -0,0 +1,18 @@ +use nostrdb::ProfileRecord; + +pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { + unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture()))) +} + +pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str { + if let Some(url) = maybe_url { + url + } else { + no_pfp_url() + } +} + +#[inline] +pub fn no_pfp_url() -> &'static str { + "https://damus.io/img/no-profile.svg" +} diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -5,10 +5,12 @@ use crate::app::NotedeckApp; use egui::{vec2, Button, Label, Layout, RichText, ThemePreference, Widget}; use egui_extras::{Size, StripBuilder}; use nostrdb::{ProfileRecord, Transaction}; -use notedeck::{App, AppContext, NotedeckTextStyle, UserAccount, WalletType}; +use notedeck::{ + profile::get_profile_url, App, AppContext, NotedeckTextStyle, UserAccount, WalletType, +}; use notedeck_columns::Damus; use notedeck_dave::{Dave, DaveAvatar}; -use notedeck_ui::{profile::get_profile_url, AnimationHelper, ProfilePic}; +use notedeck_ui::{AnimationHelper, ProfilePic}; static ICON_WIDTH: f32 = 40.0; pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2; @@ -405,7 +407,7 @@ pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str { if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) { url } else { - ProfilePic::no_pfp_url() + notedeck::profile::no_pfp_url() } } diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -1,39 +1,17 @@ use crate::{ column::Columns, route::{Route, Router}, - timeline::{TimelineCache, TimelineKind}, - ui::note::NoteContextSelection, + timeline::{ThreadSelection, TimelineCache, TimelineKind}, }; -use enostr::{NoteId, Pubkey, RelayPool}; +use enostr::{Pubkey, RelayPool}; use nostrdb::{Ndb, NoteKey, Transaction}; use notedeck::{ - get_wallet_for_mut, Accounts, GlobalWallet, NoteCache, NoteZapTargetOwned, UnknownIds, - ZapTarget, ZappingError, Zaps, + get_wallet_for_mut, Accounts, GlobalWallet, NoteAction, NoteCache, NoteZapTargetOwned, + UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps, }; use tracing::error; -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct ContextSelection { - pub note_key: NoteKey, - pub action: NoteContextSelection, -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub enum NoteAction { - Reply(NoteId), - Quote(NoteId), - OpenTimeline(TimelineKind), - Context(ContextSelection), - Zap(ZapAction), -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub enum ZapAction { - Send(NoteZapTargetOwned), - ClearError(NoteZapTargetOwned), -} - pub struct NewNotes { pub id: TimelineKind, pub notes: Vec<NoteKey>, @@ -43,106 +21,128 @@ pub enum TimelineOpenResult { NewNotes(NewNotes), } -impl NoteAction { - #[allow(clippy::too_many_arguments)] - pub fn execute( - &self, - ndb: &Ndb, - router: &mut Router<Route>, - timeline_cache: &mut TimelineCache, - note_cache: &mut NoteCache, - pool: &mut RelayPool, - txn: &Transaction, - accounts: &mut Accounts, - global_wallet: &mut GlobalWallet, - zaps: &mut Zaps, - ui: &mut egui::Ui, - ) -> Option<TimelineOpenResult> { - match self { - NoteAction::Reply(note_id) => { - router.route_to(Route::reply(*note_id)); - None - } +/// The note action executor for notedeck_columns +#[allow(clippy::too_many_arguments)] +fn execute_note_action( + action: &NoteAction, + ndb: &Ndb, + router: &mut Router<Route>, + timeline_cache: &mut TimelineCache, + note_cache: &mut NoteCache, + pool: &mut RelayPool, + txn: &Transaction, + accounts: &mut Accounts, + global_wallet: &mut GlobalWallet, + zaps: &mut Zaps, + ui: &mut egui::Ui, +) -> Option<TimelineOpenResult> { + match action { + NoteAction::Reply(note_id) => { + router.route_to(Route::reply(*note_id)); + None + } - NoteAction::OpenTimeline(kind) => { - router.route_to(Route::Timeline(kind.to_owned())); - timeline_cache.open(ndb, note_cache, txn, pool, kind) - } + NoteAction::Profile(pubkey) => { + let kind = TimelineKind::Profile(*pubkey); + router.route_to(Route::Timeline(kind.clone())); + timeline_cache.open(ndb, note_cache, txn, pool, &kind) + } - NoteAction::Quote(note_id) => { - router.route_to(Route::quote(*note_id)); - None - } + NoteAction::Note(note_id) => 'ex: { + let Ok(thread_selection) = + ThreadSelection::from_note_id(ndb, note_cache, txn, *note_id) + else { + tracing::error!("No thread selection for {}?", hex::encode(note_id.bytes())); + break 'ex None; + }; + + let kind = TimelineKind::Thread(thread_selection); + router.route_to(Route::Timeline(kind.clone())); + // NOTE!!: you need the note_id to timeline root id thing - NoteAction::Zap(zap_action) => 's: { - let Some(cur_acc) = accounts.get_selected_account_mut() else { - break 's None; - }; - - let sender = cur_acc.key.pubkey; - - match zap_action { - ZapAction::Send(target) => { - if get_wallet_for_mut(accounts, global_wallet, sender.bytes()).is_some() { - send_zap(&sender, zaps, pool, target) - } else { - zaps.send_error( - sender.bytes(), - ZapTarget::Note(target.into()), - ZappingError::SenderNoWallet, - ); - } + timeline_cache.open(ndb, note_cache, txn, pool, &kind) + } + + NoteAction::Hashtag(htag) => { + let kind = TimelineKind::Hashtag(htag.clone()); + router.route_to(Route::Timeline(kind.clone())); + timeline_cache.open(ndb, note_cache, txn, pool, &kind) + } + + NoteAction::Quote(note_id) => { + router.route_to(Route::quote(*note_id)); + None + } + + NoteAction::Zap(zap_action) => 's: { + let Some(cur_acc) = accounts.get_selected_account_mut() else { + break 's None; + }; + + let sender = cur_acc.key.pubkey; + + match zap_action { + ZapAction::Send(target) => { + if get_wallet_for_mut(accounts, global_wallet, sender.bytes()).is_some() { + send_zap(&sender, zaps, pool, target) + } else { + zaps.send_error( + sender.bytes(), + ZapTarget::Note(target.into()), + ZappingError::SenderNoWallet, + ); } - ZapAction::ClearError(target) => clear_zap_error(&sender, zaps, target), } - - None + ZapAction::ClearError(target) => clear_zap_error(&sender, zaps, target), } - NoteAction::Context(context) => { - match ndb.get_note_by_key(txn, context.note_key) { - Err(err) => tracing::error!("{err}"), - Ok(note) => { - context.action.process(ui, &note, pool); - } + None + } + + NoteAction::Context(context) => { + match ndb.get_note_by_key(txn, context.note_key) { + Err(err) => tracing::error!("{err}"), + Ok(note) => { + context.action.process(ui, &note, pool); } - None } + None } } +} - /// Execute the NoteAction and process the TimelineOpenResult - #[allow(clippy::too_many_arguments)] - pub fn execute_and_process_result( - &self, - ndb: &Ndb, - columns: &mut Columns, - col: usize, - timeline_cache: &mut TimelineCache, - note_cache: &mut NoteCache, - pool: &mut RelayPool, - txn: &Transaction, - unknown_ids: &mut UnknownIds, - accounts: &mut Accounts, - global_wallet: &mut GlobalWallet, - zaps: &mut Zaps, - ui: &mut egui::Ui, +/// Execute a NoteAction and process the result +#[allow(clippy::too_many_arguments)] +pub fn execute_and_process_note_action( + action: &NoteAction, + ndb: &Ndb, + columns: &mut Columns, + col: usize, + timeline_cache: &mut TimelineCache, + note_cache: &mut NoteCache, + pool: &mut RelayPool, + txn: &Transaction, + unknown_ids: &mut UnknownIds, + accounts: &mut Accounts, + global_wallet: &mut GlobalWallet, + zaps: &mut Zaps, + ui: &mut egui::Ui, +) { + let router = columns.column_mut(col).router_mut(); + if let Some(br) = execute_note_action( + action, + ndb, + router, + timeline_cache, + note_cache, + pool, + txn, + accounts, + global_wallet, + zaps, + ui, ) { - let router = columns.column_mut(col).router_mut(); - if let Some(br) = self.execute( - ndb, - router, - timeline_cache, - note_cache, - pool, - txn, - accounts, - global_wallet, - zaps, - ui, - ) { - br.process(ndb, note_cache, txn, timeline_cache, unknown_ids); - } + br.process(ndb, note_cache, txn, timeline_cache, unknown_ids); } } diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -7,12 +7,13 @@ use crate::{ subscriptions::{SubKind, Subscriptions}, support::Support, timeline::{self, TimelineCache}, - ui::{self, note::NoteOptions, DesktopSidePanel}, + ui::{self, DesktopSidePanel}, view_state::ViewState, Result, }; use notedeck::{Accounts, AppContext, DataPath, DataPathType, FilterState, UnknownIds}; +use notedeck_ui::NoteOptions; use enostr::{ClientMessage, Keypair, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; use uuid::Uuid; diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs @@ -3,7 +3,6 @@ mod app; mod error; //mod note; //mod block; -mod abbrev; pub mod accounts; mod actionbar; pub mod app_creation; @@ -40,7 +39,6 @@ pub mod storage; pub use app::Damus; pub use error::Error; -pub use profile::NostrName; pub use route::Route; pub type Result<T> = std::result::Result<T, error::Error>; diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -1,6 +1,5 @@ use crate::{ accounts::render_accounts_route, - actionbar::NoteAction, app::{get_active_columns_mut, get_decks_mut}, column::ColumnsAction, deck_state::DeckState, @@ -16,19 +15,20 @@ use crate::{ column::NavTitle, configure_deck::ConfigureDeckView, edit_deck::{EditDeckResponse, EditDeckView}, - note::{contents::NoteContext, NewPostAction, PostAction, PostType}, + note::{NewPostAction, PostAction, PostType}, profile::EditProfileView, search::{FocusState, SearchView}, support::SupportView, wallet::{WalletAction, WalletView}, - RelayView, View, + RelayView, }, Damus, }; use egui_nav::{Nav, NavAction, NavResponse, NavUiType}; use nostrdb::Transaction; -use notedeck::{AccountsAction, AppContext, WalletState}; +use notedeck::{AccountsAction, AppContext, NoteAction, NoteContext, WalletState}; +use notedeck_ui::View; use tracing::error; #[allow(clippy::enum_variant_names)] @@ -184,7 +184,8 @@ impl RenderNavResponse { RenderNavAction::NoteAction(note_action) => { let txn = Transaction::new(ctx.ndb).expect("txn"); - note_action.execute_and_process_result( + crate::actionbar::execute_and_process_note_action( + note_action, ctx.ndb, get_active_columns_mut(ctx.accounts, &mut app.decks_cache), col, diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use enostr::{FullKeypair, Pubkey, RelayPool}; -use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder, ProfileRecord}; +use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder}; use tracing::info; @@ -10,69 +10,6 @@ use crate::{ route::{Route, Router}, }; -pub struct NostrName<'a> { - pub username: Option<&'a str>, - pub display_name: Option<&'a str>, - pub nip05: Option<&'a str>, -} - -impl<'a> NostrName<'a> { - pub fn name(&self) -> &'a str { - if let Some(name) = self.username { - name - } else if let Some(name) = self.display_name { - name - } else { - self.nip05.unwrap_or("??") - } - } - - pub fn unknown() -> Self { - Self { - username: None, - display_name: None, - nip05: None, - } - } -} - -fn is_empty(s: &str) -> bool { - s.chars().all(|c| c.is_whitespace()) -} - -pub fn get_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> NostrName<'a> { - let Some(record) = record else { - return NostrName::unknown(); - }; - - let Some(profile) = record.record().profile() else { - return NostrName::unknown(); - }; - - let display_name = profile.display_name().filter(|n| !is_empty(n)); - let username = profile.name().filter(|n| !is_empty(n)); - - let nip05 = if let Some(raw_nip05) = profile.nip05() { - if let Some(at_pos) = raw_nip05.find('@') { - if raw_nip05.starts_with('_') { - raw_nip05.get(at_pos + 1..) - } else { - Some(raw_nip05) - } - } else { - None - } - } else { - None - }; - - NostrName { - username, - display_name, - nip05, - } -} - pub struct SaveProfileChanges { pub kp: FullKeypair, pub state: ProfileState, diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs @@ -634,7 +634,7 @@ impl<'a> TitleNeedsDb<'a> { let m_name = profile .as_ref() .ok() - .map(|p| crate::profile::get_display_name(Some(p)).name()); + .map(|p| notedeck::name::get_display_name(Some(p)).name()); m_name.unwrap_or("Profile") } else { diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -2,15 +2,12 @@ use crate::{ nav::RenderNavAction, profile::ProfileAction, timeline::{TimelineCache, TimelineKind}, - ui::{ - self, - note::{contents::NoteContext, NoteOptions}, - profile::ProfileView, - }, + ui::{self, ProfileView}, }; use enostr::Pubkey; -use notedeck::{Accounts, MuteFun, UnknownIds}; +use notedeck::{Accounts, MuteFun, NoteContext, UnknownIds}; +use notedeck_ui::NoteOptions; #[allow(clippy::too_many_arguments)] pub fn render_timeline_route( diff --git a/crates/notedeck_columns/src/ui/accounts.rs b/crates/notedeck_columns/src/ui/accounts.rs @@ -5,7 +5,7 @@ use nostrdb::{Ndb, Transaction}; use notedeck::{Accounts, Images}; use notedeck_ui::colors::PINK; -use super::profile::preview::SimpleProfilePreview; +use notedeck_ui::profile::preview::SimpleProfilePreview; pub struct AccountsView<'a> { ndb: &'a Ndb, diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -13,14 +13,15 @@ use crate::{ login_manager::AcquireKeyState, route::Route, timeline::{kind::ListKind, PubkeySource, TimelineKind}, - ui::anim::ICON_EXPANSION_MULTIPLE, Damus, }; use notedeck::{AppContext, Images, NotedeckTextStyle, UserAccount}; +use notedeck_ui::anim::ICON_EXPANSION_MULTIPLE; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; -use super::{anim::AnimationHelper, padding, widgets::styled_button, ProfilePreview}; +use crate::ui::widgets::styled_button; +use notedeck_ui::{anim::AnimationHelper, padding, ProfilePreview}; pub enum AddColumnResponse { Timeline(TimelineKind), diff --git a/crates/notedeck_columns/src/ui/anim.rs b/crates/notedeck_columns/src/ui/anim.rs @@ -1,138 +0,0 @@ -use egui::{Pos2, Rect, Response, Sense}; - -pub fn hover_expand( - ui: &mut egui::Ui, - id: egui::Id, - size: f32, - expand_size: f32, - anim_speed: f32, -) -> (egui::Rect, f32, egui::Response) { - // Allocate space for the profile picture with a fixed size - let default_size = size + expand_size; - let (rect, response) = - ui.allocate_exact_size(egui::vec2(default_size, default_size), egui::Sense::click()); - - let val = ui - .ctx() - .animate_bool_with_time(id, response.hovered(), anim_speed); - - let size = size + val * expand_size; - (rect, size, response) -} - -pub fn hover_expand_small(ui: &mut egui::Ui, id: egui::Id) -> (egui::Rect, f32, egui::Response) { - let size = 10.0; - let expand_size = 5.0; - let anim_speed = 0.05; - - hover_expand(ui, id, size, expand_size, anim_speed) -} - -pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2; -pub static ANIM_SPEED: f32 = 0.05; -pub struct AnimationHelper { - rect: Rect, - center: Pos2, - response: Response, - animation_progress: f32, - expansion_multiple: f32, -} - -impl AnimationHelper { - pub fn new( - ui: &mut egui::Ui, - animation_name: impl std::hash::Hash, - max_size: egui::Vec2, - ) -> Self { - let id = ui.id().with(animation_name); - let (rect, response) = ui.allocate_exact_size(max_size, Sense::click()); - - let animation_progress = - ui.ctx() - .animate_bool_with_time(id, response.hovered(), ANIM_SPEED); - - Self { - rect, - center: rect.center(), - response, - animation_progress, - expansion_multiple: ICON_EXPANSION_MULTIPLE, - } - } - - pub fn no_animation(ui: &mut egui::Ui, size: egui::Vec2) -> Self { - let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); - - Self { - rect, - center: rect.center(), - response, - animation_progress: 0.0, - expansion_multiple: ICON_EXPANSION_MULTIPLE, - } - } - - pub fn new_from_rect( - ui: &mut egui::Ui, - animation_name: impl std::hash::Hash, - animation_rect: egui::Rect, - ) -> Self { - let id = ui.id().with(animation_name); - let response = ui.allocate_rect(animation_rect, Sense::click()); - - let animation_progress = - ui.ctx() - .animate_bool_with_time(id, response.hovered(), ANIM_SPEED); - - Self { - rect: animation_rect, - center: animation_rect.center(), - response, - animation_progress, - expansion_multiple: ICON_EXPANSION_MULTIPLE, - } - } - - pub fn scale_1d_pos(&self, min_object_size: f32) -> f32 { - let max_object_size = min_object_size * self.expansion_multiple; - - if self.response.is_pointer_button_down_on() { - min_object_size - } else { - min_object_size + ((max_object_size - min_object_size) * self.animation_progress) - } - } - - pub fn scale_radius(&self, min_diameter: f32) -> f32 { - self.scale_1d_pos((min_diameter - 1.0) / 2.0) - } - - pub fn get_animation_rect(&self) -> egui::Rect { - self.rect - } - - pub fn center(&self) -> Pos2 { - self.rect.center() - } - - pub fn take_animation_response(self) -> egui::Response { - self.response - } - - // Scale a minimum position from center to the current animation position - pub fn scale_from_center(&self, x_min: f32, y_min: f32) -> Pos2 { - Pos2::new( - self.center.x + self.scale_1d_pos(x_min), - self.center.y + self.scale_1d_pos(y_min), - ) - } - - pub fn scale_pos_from_center(&self, min_pos: Pos2) -> Pos2 { - self.scale_from_center(min_pos.x, min_pos.y) - } - - /// New method for min/max scaling when needed - pub fn scale_1d_pos_min_max(&self, min_object_size: f32, max_object_size: f32) -> f32 { - min_object_size + ((max_object_size - min_object_size) * self.animation_progress) - } -} diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -5,10 +5,7 @@ use crate::{ column::Columns, route::Route, timeline::{ColumnTitle, TimelineKind}, - ui::{ - self, - anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, - }, + ui::{self}, }; use egui::Margin; @@ -16,6 +13,10 @@ use egui::{RichText, Stroke, UiBuilder}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; use notedeck::{Images, NotedeckTextStyle}; +use notedeck_ui::{ + anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + ProfilePic, +}; pub struct NavTitle<'a> { ndb: &'a Ndb, @@ -43,7 +44,7 @@ impl<'a> NavTitle<'a> { } pub fn show(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> { - ui::padding(8.0, ui, |ui| { + notedeck_ui::padding(8.0, ui, |ui| { let mut rect = ui.available_rect_before_wrap(); rect.set_height(48.0); @@ -72,7 +73,7 @@ impl<'a> NavTitle<'a> { if let Some(back_resp) = &back_button_resp { if back_resp.hovered() || back_resp.clicked() { - ui::show_pointer(ui); + notedeck_ui::show_pointer(ui); } } else { // add some space where chevron would have been. this makes the ui @@ -220,7 +221,7 @@ impl<'a> NavTitle<'a> { } }); } else if move_resp.hovered() { - ui::show_pointer(ui); + notedeck_ui::show_pointer(ui); } ui.data(|d| d.get_temp(cur_id)).and_then(|val| { @@ -388,14 +389,12 @@ impl<'a> NavTitle<'a> { txn: &'txn Transaction, pubkey: &[u8; 32], pfp_size: f32, - ) -> Option<ui::ProfilePic<'me, 'txn>> { + ) -> Option<ProfilePic<'me, 'txn>> { self.ndb .get_profile_by_pubkey(txn, pubkey) .as_ref() .ok() - .and_then(move |p| { - Some(ui::ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size)) - }) + .and_then(move |p| Some(ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size))) } fn timeline_pfp(&mut self, ui: &mut egui::Ui, id: &TimelineKind, pfp_size: f32) { @@ -407,9 +406,7 @@ impl<'a> NavTitle<'a> { { ui.add(pfp); } else { - ui.add( - ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size), - ); + ui.add(ProfilePic::new(self.img_cache, notedeck::profile::no_pfp_url()).size(pfp_size)); } } @@ -472,9 +469,7 @@ impl<'a> NavTitle<'a> { if let Some(pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) { ui.add(pfp); } else { - ui.add( - ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size), - ); + ui.add(ProfilePic::new(self.img_cache, notedeck::profile::no_pfp_url()).size(pfp_size)); }; } diff --git a/crates/notedeck_columns/src/ui/configure_deck.rs b/crates/notedeck_columns/src/ui/configure_deck.rs @@ -1,10 +1,9 @@ use crate::{app_style::deck_icon_font_sized, deck_state::DeckState}; use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget}; use notedeck::{NamedFontFamily, NotedeckTextStyle}; -use notedeck_ui::colors::PINK; - -use super::{ +use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + colors::PINK, padding, }; diff --git a/crates/notedeck_columns/src/ui/edit_deck.rs b/crates/notedeck_columns/src/ui/edit_deck.rs @@ -2,10 +2,8 @@ use egui::Widget; use crate::deck_state::DeckState; -use super::{ - configure_deck::{ConfigureDeckResponse, ConfigureDeckView}, - padding, -}; +use super::configure_deck::{ConfigureDeckResponse, ConfigureDeckView}; +use notedeck_ui::padding; pub struct EditDeckView<'a> { config_view: ConfigureDeckView<'a>, diff --git a/crates/notedeck_columns/src/ui/mention.rs b/crates/notedeck_columns/src/ui/mention.rs @@ -1,110 +0,0 @@ -use crate::ui; -use crate::{actionbar::NoteAction, profile::get_display_name, timeline::TimelineKind}; -use egui::Sense; -use enostr::Pubkey; -use nostrdb::{Ndb, Transaction}; -use notedeck::Images; - -pub struct Mention<'a> { - ndb: &'a Ndb, - img_cache: &'a mut Images, - txn: &'a Transaction, - pk: &'a [u8; 32], - selectable: bool, - size: f32, -} - -impl<'a> Mention<'a> { - pub fn new( - ndb: &'a Ndb, - img_cache: &'a mut Images, - txn: &'a Transaction, - pk: &'a [u8; 32], - ) -> Self { - let size = 16.0; - let selectable = true; - Mention { - ndb, - img_cache, - txn, - pk, - selectable, - size, - } - } - - pub fn selectable(mut self, selectable: bool) -> Self { - self.selectable = selectable; - self - } - - pub fn size(mut self, size: f32) -> Self { - self.size = size; - self - } - - pub fn show(self, ui: &mut egui::Ui) -> egui::InnerResponse<Option<NoteAction>> { - mention_ui( - self.ndb, - self.img_cache, - self.txn, - self.pk, - ui, - self.size, - self.selectable, - ) - } -} - -impl egui::Widget for Mention<'_> { - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - self.show(ui).response - } -} - -#[allow(clippy::too_many_arguments)] -#[profiling::function] -fn mention_ui( - ndb: &Ndb, - img_cache: &mut Images, - txn: &Transaction, - pk: &[u8; 32], - ui: &mut egui::Ui, - size: f32, - selectable: bool, -) -> egui::InnerResponse<Option<NoteAction>> { - let link_color = ui.visuals().hyperlink_color; - - ui.horizontal(|ui| { - let profile = ndb.get_profile_by_pubkey(txn, pk).ok(); - - let name: String = format!("@{}", get_display_name(profile.as_ref()).name()); - - let resp = ui.add( - egui::Label::new(egui::RichText::new(name).color(link_color).size(size)) - .sense(Sense::click()) - .selectable(selectable), - ); - - let note_action = if resp.clicked() { - ui::show_pointer(ui); - Some(NoteAction::OpenTimeline(TimelineKind::profile( - Pubkey::new(*pk), - ))) - } else if resp.hovered() { - ui::show_pointer(ui); - None - } else { - None - }; - - if let Some(rec) = profile.as_ref() { - resp.on_hover_ui_at_pointer(|ui| { - ui.set_max_width(300.0); - ui.add(ui::ProfilePreview::new(rec, img_cache)); - }); - } - - note_action - }) -} diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -1,12 +1,10 @@ pub mod account_login_view; pub mod accounts; pub mod add_column; -pub mod anim; pub mod column; pub mod configure_deck; pub mod edit_deck; pub mod images; -pub mod mention; pub mod note; pub mod preview; pub mod profile; @@ -17,56 +15,14 @@ pub mod side_panel; pub mod support; pub mod thread; pub mod timeline; -pub mod username; pub mod wallet; pub mod widgets; pub use accounts::AccountsView; -pub use mention::Mention; -pub use note::{NoteResponse, NoteView, PostReplyView, PostView}; -pub use notedeck_ui::ProfilePic; +pub use note::{PostReplyView, PostView}; pub use preview::{Preview, PreviewApp, PreviewConfig}; -pub use profile::ProfilePreview; +pub use profile::ProfileView; pub use relay::RelayView; pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use thread::ThreadView; pub use timeline::TimelineView; -pub use username::Username; - -use egui::Margin; - -/// This is kind of like the Widget trait but is meant for larger top-level -/// views that are typically stateful. -/// -/// The Widget trait forces us to add mutable -/// implementations at the type level, which screws us when generating Previews -/// for a Widget. I would have just Widget instead of making this Trait otherwise. -/// -/// There is some precendent for this, it looks like there's a similar trait -/// in the egui demo library. -pub trait View { - fn ui(&mut self, ui: &mut egui::Ui); -} - -pub fn padding<R>( - amount: impl Into<Margin>, - ui: &mut egui::Ui, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> egui::InnerResponse<R> { - egui::Frame::new() - .inner_margin(amount) - .show(ui, add_contents) -} - -pub fn hline(ui: &egui::Ui) { - // pixel perfect horizontal line - let rect = ui.available_rect_before_wrap(); - #[allow(deprecated)] - let resize_y = ui.painter().round_to_pixel(rect.top()) - 0.5; - let stroke = ui.style().visuals.widgets.noninteractive.bg_stroke; - ui.painter().hline(rect.x_range(), resize_y, stroke); -} - -pub fn show_pointer(ui: &egui::Ui) { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); -} diff --git a/crates/notedeck_columns/src/ui/note/contents.rs b/crates/notedeck_columns/src/ui/note/contents.rs @@ -1,558 +0,0 @@ -use crate::ui::{ - self, - note::{NoteOptions, NoteResponse}, -}; -use crate::{actionbar::NoteAction, timeline::TimelineKind}; -use egui::{Button, Color32, Hyperlink, Image, Response, RichText, Sense, Window}; -use enostr::{KeypairUnowned, RelayPool}; -use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; -use notedeck_ui::images::ImageType; -use notedeck_ui::{ - gif::{handle_repaint, retrieve_latest_texture}, - images::render_images, -}; -use tracing::warn; - -use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteCache, Zaps}; - -/// Aggregates dependencies to reduce the number of parameters -/// passed to inner UI elements, minimizing prop drilling. -pub struct NoteContext<'d> { - pub ndb: &'d Ndb, - pub img_cache: &'d mut Images, - pub note_cache: &'d mut NoteCache, - pub zaps: &'d mut Zaps, - pub pool: &'d mut RelayPool, -} - -pub struct NoteContents<'a, 'd> { - note_context: &'a mut NoteContext<'d>, - cur_acc: &'a Option<KeypairUnowned<'a>>, - txn: &'a Transaction, - note: &'a Note<'a>, - options: NoteOptions, - action: Option<NoteAction>, -} - -impl<'a, 'd> NoteContents<'a, 'd> { - #[allow(clippy::too_many_arguments)] - pub fn new( - note_context: &'a mut NoteContext<'d>, - cur_acc: &'a Option<KeypairUnowned<'a>>, - txn: &'a Transaction, - note: &'a Note, - options: ui::note::NoteOptions, - ) -> Self { - NoteContents { - note_context, - cur_acc, - txn, - note, - options, - action: None, - } - } - - pub fn action(&self) -> &Option<NoteAction> { - &self.action - } -} - -impl egui::Widget for &mut NoteContents<'_, '_> { - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - let result = render_note_contents( - ui, - self.note_context, - self.cur_acc, - self.txn, - self.note, - self.options, - ); - self.action = result.action; - result.response - } -} - -/// Render an inline note preview with a border. These are used when -/// notes are references within a note -#[allow(clippy::too_many_arguments)] -#[profiling::function] -pub fn render_note_preview( - ui: &mut egui::Ui, - note_context: &mut NoteContext, - cur_acc: &Option<KeypairUnowned>, - txn: &Transaction, - id: &[u8; 32], - parent: NoteKey, - note_options: NoteOptions, -) -> NoteResponse { - let note = if let Ok(note) = note_context.ndb.get_note_by_id(txn, id) { - // TODO: support other preview kinds - if note.kind() == 1 { - note - } else { - return NoteResponse::new(ui.colored_label( - Color32::RED, - format!("TODO: can't preview kind {}", note.kind()), - )); - } - } else { - return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD")); - /* - return ui - .horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - ui.colored_label(link_color, "@"); - ui.colored_label(link_color, &id_str[4..16]); - }) - .response; - */ - }; - - egui::Frame::new() - .fill(ui.visuals().noninteractive().weak_bg_fill) - .inner_margin(egui::Margin::same(8)) - .outer_margin(egui::Margin::symmetric(0, 8)) - .corner_radius(egui::CornerRadius::same(10)) - .stroke(egui::Stroke::new( - 1.0, - ui.visuals().noninteractive().bg_stroke.color, - )) - .show(ui, |ui| { - ui::NoteView::new(note_context, cur_acc, &note, note_options) - .actionbar(false) - .small_pfp(true) - .wide(true) - .note_previews(false) - .options_button(true) - .parent(parent) - .is_preview(true) - .show(ui) - }) - .inner -} - -#[allow(clippy::too_many_arguments)] -#[profiling::function] -fn render_note_contents( - ui: &mut egui::Ui, - note_context: &mut NoteContext, - cur_acc: &Option<KeypairUnowned>, - txn: &Transaction, - note: &Note, - options: NoteOptions, -) -> NoteResponse { - let note_key = note.key().expect("todo: implement non-db notes"); - let selectable = options.has_selectable_text(); - let mut images: Vec<(String, MediaCacheType)> = vec![]; - let mut note_action: Option<NoteAction> = None; - let mut inline_note: Option<(&[u8; 32], &str)> = None; - let hide_media = options.has_hide_media(); - let link_color = ui.visuals().hyperlink_color; - - if !options.has_is_preview() { - // need this for the rect to take the full width of the column - let _ = ui.allocate_at_least(egui::vec2(ui.available_width(), 0.0), egui::Sense::click()); - } - - let response = ui.horizontal_wrapped(|ui| { - let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) { - blocks - } else { - warn!("missing note content blocks? '{}'", note.content()); - ui.weak(note.content()); - return; - }; - - ui.spacing_mut().item_spacing.x = 0.0; - - for block in blocks.iter(note) { - match block.blocktype() { - BlockType::MentionBech32 => match block.as_mention().unwrap() { - Mention::Profile(profile) => { - let act = ui::Mention::new( - note_context.ndb, - note_context.img_cache, - txn, - profile.pubkey(), - ) - .show(ui) - .inner; - if act.is_some() { - note_action = act; - } - } - - Mention::Pubkey(npub) => { - let act = ui::Mention::new( - note_context.ndb, - note_context.img_cache, - txn, - npub.pubkey(), - ) - .show(ui) - .inner; - if act.is_some() { - note_action = act; - } - } - - Mention::Note(note) if options.has_note_previews() => { - inline_note = Some((note.id(), block.as_str())); - } - - Mention::Event(note) if options.has_note_previews() => { - inline_note = Some((note.id(), block.as_str())); - } - - _ => { - ui.colored_label(link_color, format!("@{}", &block.as_str()[4..16])); - } - }, - - BlockType::Hashtag => { - let resp = ui.colored_label(link_color, format!("#{}", block.as_str())); - - if resp.clicked() { - note_action = Some(NoteAction::OpenTimeline(TimelineKind::Hashtag( - block.as_str().to_string(), - ))); - } else if resp.hovered() { - ui::show_pointer(ui); - } - } - - BlockType::Url => { - let mut found_supported = || -> bool { - let url = block.as_str(); - if let Some(cache_type) = - supported_mime_hosted_at_url(&mut note_context.img_cache.urls, url) - { - images.push((url.to_string(), cache_type)); - true - } else { - false - } - }; - if hide_media || !found_supported() { - ui.add(Hyperlink::from_label_and_url( - RichText::new(block.as_str()).color(link_color), - block.as_str(), - )); - } - } - - BlockType::Text => { - if options.has_scramble_text() { - ui.add(egui::Label::new(rot13(block.as_str())).selectable(selectable)); - } else { - ui.add(egui::Label::new(block.as_str()).selectable(selectable)); - } - } - - _ => { - ui.colored_label(link_color, block.as_str()); - } - } - } - }); - - let preview_note_action = if let Some((id, _block_str)) = inline_note { - render_note_preview(ui, note_context, cur_acc, txn, id, note_key, options).action - } else { - None - }; - - if !images.is_empty() && !options.has_textmode() { - ui.add_space(2.0); - let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); - image_carousel(ui, note_context.img_cache, images, carousel_id); - ui.add_space(2.0); - } - - let note_action = preview_note_action.or(note_action); - - NoteResponse::new(response.response).with_action(note_action) -} - -fn rot13(input: &str) -> String { - input - .chars() - .map(|c| { - if c.is_ascii_lowercase() { - // Rotate lowercase letters - (((c as u8 - b'a' + 13) % 26) + b'a') as char - } else if c.is_ascii_uppercase() { - // Rotate uppercase letters - (((c as u8 - b'A' + 13) % 26) + b'A') as char - } else { - // Leave other characters unchanged - c - } - }) - .collect() -} - -fn image_carousel( - ui: &mut egui::Ui, - img_cache: &mut Images, - images: Vec<(String, MediaCacheType)>, - carousel_id: egui::Id, -) { - // let's make sure everything is within our area - - let height = 360.0; - let width = ui.available_size().x; - let spinsz = if height > width { width } else { height }; - - let show_popup = ui.ctx().memory(|mem| { - mem.data - .get_temp(carousel_id.with("show_popup")) - .unwrap_or(false) - }); - - let current_image = show_popup.then(|| { - ui.ctx().memory(|mem| { - mem.data - .get_temp::<(String, MediaCacheType)>(carousel_id.with("current_image")) - .unwrap_or_else(|| (images[0].0.clone(), images[0].1.clone())) - }) - }); - - ui.add_sized([width, height], |ui: &mut egui::Ui| { - egui::ScrollArea::horizontal() - .id_salt(carousel_id) - .show(ui, |ui| { - ui.horizontal(|ui| { - for (image, cache_type) in images { - render_images( - ui, - img_cache, - &image, - ImageType::Content, - cache_type.clone(), - |ui| { - ui.allocate_space(egui::vec2(spinsz, spinsz)); - }, - |ui, _| { - ui.allocate_space(egui::vec2(spinsz, spinsz)); - }, - |ui, url, renderable_media, gifs| { - let texture = handle_repaint( - ui, - retrieve_latest_texture(&image, gifs, renderable_media), - ); - let img_resp = ui.add( - Button::image( - Image::new(texture) - .max_height(height) - .corner_radius(5.0) - .fit_to_original_size(1.0), - ) - .frame(false), - ); - - if img_resp.clicked() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(carousel_id.with("show_popup"), true); - mem.data.insert_temp( - carousel_id.with("current_image"), - (image.clone(), cache_type.clone()), - ); - }); - } - - copy_link(url, img_resp); - }, - ); - } - }) - .response - }) - .inner - }); - - if show_popup { - let current_image = current_image - .as_ref() - .expect("the image was actually clicked"); - let image = current_image.clone().0; - let cache_type = current_image.clone().1; - - Window::new("image_popup") - .title_bar(false) - .fixed_size(ui.ctx().screen_rect().size()) - .fixed_pos(ui.ctx().screen_rect().min) - .frame(egui::Frame::NONE) - .show(ui.ctx(), |ui| { - let screen_rect = ui.ctx().screen_rect(); - - // escape - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(carousel_id.with("show_popup"), false); - }); - } - - // background - ui.painter() - .rect_filled(screen_rect, 0.0, Color32::from_black_alpha(230)); - - // zoom init - let zoom_id = carousel_id.with("zoom_level"); - let mut zoom = ui - .ctx() - .memory(|mem| mem.data.get_temp(zoom_id).unwrap_or(1.0_f32)); - - // pan init - let pan_id = carousel_id.with("pan_offset"); - let mut pan_offset = ui - .ctx() - .memory(|mem| mem.data.get_temp(pan_id).unwrap_or(egui::Vec2::ZERO)); - - // zoom & scroll - if ui.input(|i| i.pointer.hover_pos()).is_some() { - let scroll_delta = ui.input(|i| i.smooth_scroll_delta); - if scroll_delta.y != 0.0 { - let zoom_factor = if scroll_delta.y > 0.0 { 1.05 } else { 0.95 }; - zoom *= zoom_factor; - zoom = zoom.clamp(0.1, 5.0); - - if zoom <= 1.0 { - pan_offset = egui::Vec2::ZERO; - } - - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(zoom_id, zoom); - mem.data.insert_temp(pan_id, pan_offset); - }); - } - } - - ui.centered_and_justified(|ui| { - render_images( - ui, - img_cache, - &image, - ImageType::Content, - cache_type.clone(), - |ui| { - ui.allocate_space(egui::vec2(spinsz, spinsz)); - }, - |ui, _| { - ui.allocate_space(egui::vec2(spinsz, spinsz)); - }, - |ui, url, renderable_media, gifs| { - let texture = handle_repaint( - ui, - retrieve_latest_texture(&image, gifs, renderable_media), - ); - - let texture_size = texture.size_vec2(); - let screen_size = screen_rect.size(); - let scale = (screen_size.x / texture_size.x) - .min(screen_size.y / texture_size.y) - .min(1.0); - let scaled_size = texture_size * scale * zoom; - - let visible_width = scaled_size.x.min(screen_size.x); - let visible_height = scaled_size.y.min(screen_size.y); - - let max_pan_x = ((scaled_size.x - visible_width) / 2.0).max(0.0); - let max_pan_y = ((scaled_size.y - visible_height) / 2.0).max(0.0); - - if max_pan_x > 0.0 { - pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x); - } else { - pan_offset.x = 0.0; - } - - if max_pan_y > 0.0 { - pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y); - } else { - pan_offset.y = 0.0; - } - - let (rect, response) = ui.allocate_exact_size( - egui::vec2(visible_width, visible_height), - egui::Sense::click_and_drag(), - ); - - let uv_min = egui::pos2( - 0.5 - (visible_width / scaled_size.x) / 2.0 - + pan_offset.x / scaled_size.x, - 0.5 - (visible_height / scaled_size.y) / 2.0 - + pan_offset.y / scaled_size.y, - ); - - let uv_max = egui::pos2( - uv_min.x + visible_width / scaled_size.x, - uv_min.y + visible_height / scaled_size.y, - ); - - let uv = egui::Rect::from_min_max(uv_min, uv_max); - - ui.painter() - .image(texture.id(), rect, uv, egui::Color32::WHITE); - let img_rect = ui.allocate_rect(rect, Sense::click()); - - if img_rect.clicked() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(carousel_id.with("show_popup"), true); - }); - } else if img_rect.clicked_elsewhere() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(carousel_id.with("show_popup"), false); - }); - } - - // Handle dragging for pan - if response.dragged() { - let delta = response.drag_delta(); - - pan_offset.x -= delta.x; - pan_offset.y -= delta.y; - - if max_pan_x > 0.0 { - pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x); - } else { - pan_offset.x = 0.0; - } - - if max_pan_y > 0.0 { - pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y); - } else { - pan_offset.y = 0.0; - } - - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(pan_id, pan_offset); - }); - } - - // reset zoom on double-click - if response.double_clicked() { - pan_offset = egui::Vec2::ZERO; - zoom = 1.0; - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(pan_id, pan_offset); - mem.data.insert_temp(zoom_id, zoom); - }); - } - - copy_link(url, response); - }, - ); - }); - }); - } -} - -fn copy_link(url: &str, img_resp: Response) { - img_resp.context_menu(|ui| { - if ui.button("Copy Link").clicked() { - ui.ctx().copy_text(url.to_owned()); - ui.close_menu(); - } - }); -} diff --git a/crates/notedeck_columns/src/ui/note/context.rs b/crates/notedeck_columns/src/ui/note/context.rs @@ -1,213 +0,0 @@ -use egui::{Rect, Vec2}; -use enostr::{ClientMessage, NoteId, Pubkey, RelayPool}; -use nostrdb::{Note, NoteKey}; -use tracing::error; - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum BroadcastContext { - LocalNetwork, - Everywhere, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -#[allow(clippy::enum_variant_names)] -pub enum NoteContextSelection { - CopyText, - CopyPubkey, - CopyNoteId, - CopyNoteJSON, - Broadcast(BroadcastContext), -} - -impl NoteContextSelection { - pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>, pool: &mut RelayPool) { - match self { - NoteContextSelection::Broadcast(context) => { - tracing::info!("Broadcasting note {}", hex::encode(note.id())); - match context { - BroadcastContext::LocalNetwork => { - pool.send_to(&ClientMessage::event(note).unwrap(), "multicast"); - } - - BroadcastContext::Everywhere => { - pool.send(&ClientMessage::event(note).unwrap()); - } - } - } - NoteContextSelection::CopyText => { - ui.ctx().copy_text(note.content().to_string()); - } - NoteContextSelection::CopyPubkey => { - if let Some(bech) = Pubkey::new(*note.pubkey()).to_bech() { - ui.ctx().copy_text(bech); - } - } - NoteContextSelection::CopyNoteId => { - if let Some(bech) = NoteId::new(*note.id()).to_bech() { - ui.ctx().copy_text(bech); - } - } - NoteContextSelection::CopyNoteJSON => match note.json() { - Ok(json) => ui.ctx().copy_text(json), - Err(err) => error!("error copying note json: {err}"), - }, - } - } -} - -pub struct NoteContextButton { - put_at: Option<Rect>, - note_key: NoteKey, -} - -impl egui::Widget for NoteContextButton { - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - let r = if let Some(r) = self.put_at { - r - } else { - let mut place = ui.available_rect_before_wrap(); - let size = Self::max_width(); - place.set_width(size); - place.set_height(size); - place - }; - - Self::show(ui, self.note_key, r) - } -} - -impl NoteContextButton { - pub fn new(note_key: NoteKey) -> Self { - let put_at: Option<Rect> = None; - NoteContextButton { note_key, put_at } - } - - pub fn place_at(mut self, rect: Rect) -> Self { - self.put_at = Some(rect); - self - } - - pub fn max_width() -> f32 { - Self::max_radius() * 3.0 + Self::max_distance_between_circles() * 2.0 - } - - pub fn size() -> Vec2 { - let width = Self::max_width(); - egui::vec2(width, width) - } - - fn max_radius() -> f32 { - 4.0 - } - - fn min_radius() -> f32 { - 2.0 - } - - fn max_distance_between_circles() -> f32 { - 2.0 - } - - fn expansion_multiple() -> f32 { - 2.0 - } - - fn min_distance_between_circles() -> f32 { - Self::max_distance_between_circles() / Self::expansion_multiple() - } - - #[profiling::function] - pub fn show(ui: &mut egui::Ui, note_key: NoteKey, put_at: Rect) -> egui::Response { - let id = ui.id().with(("more_options_anim", note_key)); - - let min_radius = Self::min_radius(); - let anim_speed = 0.05; - let response = ui.interact(put_at, id, egui::Sense::click()); - - let hovered = response.hovered(); - let animation_progress = ui.ctx().animate_bool_with_time(id, hovered, anim_speed); - - if hovered { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - - let min_distance = Self::min_distance_between_circles(); - let cur_distance = min_distance - + (Self::max_distance_between_circles() - min_distance) * animation_progress; - - let cur_radius = min_radius + (Self::max_radius() - min_radius) * animation_progress; - - let center = put_at.center(); - let left_circle_center = center - egui::vec2(cur_distance + cur_radius, 0.0); - let right_circle_center = center + egui::vec2(cur_distance + cur_radius, 0.0); - - let translated_radius = (cur_radius - 1.0) / 2.0; - - // This works in both themes - let color = ui.style().visuals.noninteractive().fg_stroke.color; - - // Draw circles - let painter = ui.painter_at(put_at); - painter.circle_filled(left_circle_center, translated_radius, color); - painter.circle_filled(center, translated_radius, color); - painter.circle_filled(right_circle_center, translated_radius, color); - - response - } - - #[profiling::function] - pub fn menu( - ui: &mut egui::Ui, - button_response: egui::Response, - ) -> Option<NoteContextSelection> { - let mut context_selection: Option<NoteContextSelection> = None; - - stationary_arbitrary_menu_button(ui, button_response, |ui| { - ui.set_max_width(200.0); - if ui.button("Copy text").clicked() { - context_selection = Some(NoteContextSelection::CopyText); - ui.close_menu(); - } - if ui.button("Copy user public key").clicked() { - context_selection = Some(NoteContextSelection::CopyPubkey); - ui.close_menu(); - } - if ui.button("Copy note id").clicked() { - context_selection = Some(NoteContextSelection::CopyNoteId); - ui.close_menu(); - } - if ui.button("Copy note json").clicked() { - context_selection = Some(NoteContextSelection::CopyNoteJSON); - ui.close_menu(); - } - if ui.button("Broadcast").clicked() { - context_selection = Some(NoteContextSelection::Broadcast( - BroadcastContext::Everywhere, - )); - ui.close_menu(); - } - if ui.button("Broadcast to local network").clicked() { - context_selection = Some(NoteContextSelection::Broadcast( - BroadcastContext::LocalNetwork, - )); - ui.close_menu(); - } - }); - - context_selection - } -} - -fn stationary_arbitrary_menu_button<R>( - ui: &mut egui::Ui, - button_response: egui::Response, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> egui::InnerResponse<Option<R>> { - let bar_id = ui.id(); - let mut bar_state = egui::menu::BarState::load(ui.ctx(), bar_id); - - let inner = bar_state.bar_menu(&button_response, add_contents); - - bar_state.store(ui.ctx(), bar_id); - egui::InnerResponse::new(inner.map(|r| r.inner), button_response) -} diff --git a/crates/notedeck_columns/src/ui/note/mod.rs b/crates/notedeck_columns/src/ui/note/mod.rs @@ -1,765 +1,7 @@ -pub mod contents; -pub mod context; -pub mod options; pub mod post; pub mod quote_repost; pub mod reply; -pub mod reply_description; -pub use contents::NoteContents; -use contents::NoteContext; -pub use context::{NoteContextButton, NoteContextSelection}; -use notedeck_ui::ImagePulseTint; -pub use options::NoteOptions; pub use post::{NewPostAction, PostAction, PostResponse, PostType, PostView}; pub use quote_repost::QuoteRepostView; pub use reply::PostReplyView; -pub use reply_description::reply_desc; - -use crate::{ - actionbar::{ContextSelection, NoteAction, ZapAction}, - profile::get_display_name, - timeline::{ThreadSelection, TimelineKind}, - ui::{self, View}, -}; - -use egui::emath::{pos2, Vec2}; -use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; -use enostr::{KeypairUnowned, NoteId, Pubkey}; -use nostrdb::{Ndb, Note, NoteKey, Transaction}; -use notedeck::{ - AnyZapState, CachedNote, NoteCache, NoteZapTarget, NoteZapTargetOwned, NotedeckTextStyle, - ZapTarget, Zaps, -}; - -use super::{profile::preview::one_line_display_name_widget, widgets::x_button}; - -pub struct NoteView<'a, 'd> { - note_context: &'a mut NoteContext<'d>, - cur_acc: &'a Option<KeypairUnowned<'a>>, - parent: Option<NoteKey>, - note: &'a nostrdb::Note<'a>, - flags: NoteOptions, -} - -pub struct NoteResponse { - pub response: egui::Response, - pub action: Option<NoteAction>, -} - -impl NoteResponse { - pub fn new(response: egui::Response) -> Self { - Self { - response, - action: None, - } - } - - pub fn with_action(mut self, action: Option<NoteAction>) -> Self { - self.action = action; - self - } -} - -impl View for NoteView<'_, '_> { - fn ui(&mut self, ui: &mut egui::Ui) { - self.show(ui); - } -} - -impl<'a, 'd> NoteView<'a, 'd> { - pub fn new( - note_context: &'a mut NoteContext<'d>, - cur_acc: &'a Option<KeypairUnowned<'a>>, - note: &'a nostrdb::Note<'a>, - mut flags: NoteOptions, - ) -> Self { - flags.set_actionbar(true); - flags.set_note_previews(true); - - let parent: Option<NoteKey> = None; - Self { - note_context, - cur_acc, - parent, - note, - flags, - } - } - - pub fn textmode(mut self, enable: bool) -> Self { - self.options_mut().set_textmode(enable); - self - } - - pub fn actionbar(mut self, enable: bool) -> Self { - self.options_mut().set_actionbar(enable); - self - } - - pub fn small_pfp(mut self, enable: bool) -> Self { - self.options_mut().set_small_pfp(enable); - self - } - - pub fn medium_pfp(mut self, enable: bool) -> Self { - self.options_mut().set_medium_pfp(enable); - self - } - - pub fn note_previews(mut self, enable: bool) -> Self { - self.options_mut().set_note_previews(enable); - self - } - - pub fn selectable_text(mut self, enable: bool) -> Self { - self.options_mut().set_selectable_text(enable); - self - } - - pub fn wide(mut self, enable: bool) -> Self { - self.options_mut().set_wide(enable); - self - } - - pub fn options_button(mut self, enable: bool) -> Self { - self.options_mut().set_options_button(enable); - self - } - - pub fn options(&self) -> NoteOptions { - self.flags - } - - pub fn options_mut(&mut self) -> &mut NoteOptions { - &mut self.flags - } - - pub fn parent(mut self, parent: NoteKey) -> Self { - self.parent = Some(parent); - self - } - - pub fn is_preview(mut self, is_preview: bool) -> Self { - self.options_mut().set_is_preview(is_preview); - self - } - - fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { - let note_key = self.note.key().expect("todo: implement non-db notes"); - let txn = self.note.txn().expect("todo: implement non-db notes"); - - ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { - let profile = self - .note_context - .ndb - .get_profile_by_pubkey(txn, self.note.pubkey()); - - //ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - - let cached_note = self - .note_context - .note_cache - .cached_note_or_insert_mut(note_key, self.note); - - let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); - ui.allocate_rect(rect, Sense::hover()); - ui.put(rect, |ui: &mut egui::Ui| { - render_reltime(ui, cached_note, false).response - }); - let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); - ui.allocate_rect(rect, Sense::hover()); - ui.put(rect, |ui: &mut egui::Ui| { - ui.add( - ui::Username::new(profile.as_ref().ok(), self.note.pubkey()) - .abbreviated(6) - .pk_colored(true), - ) - }); - - ui.add(&mut NoteContents::new( - self.note_context, - self.cur_acc, - txn, - self.note, - self.flags, - )); - //}); - }) - .response - } - - pub fn expand_size() -> i8 { - 5 - } - - fn pfp( - &mut self, - note_key: NoteKey, - profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, - ui: &mut egui::Ui, - ) -> egui::Response { - if !self.options().has_wide() { - ui.spacing_mut().item_spacing.x = 16.0; - } else { - ui.spacing_mut().item_spacing.x = 4.0; - } - - let pfp_size = self.options().pfp_size(); - - let sense = Sense::click(); - match profile - .as_ref() - .ok() - .and_then(|p| p.record().profile()?.picture()) - { - // these have different lifetimes and types, - // so the calls must be separate - Some(pic) => { - let anim_speed = 0.05; - let profile_key = profile.as_ref().unwrap().record().note_key(); - let note_key = note_key.as_u64(); - - let (rect, size, resp) = ui::anim::hover_expand( - ui, - egui::Id::new((profile_key, note_key)), - pfp_size as f32, - ui::NoteView::expand_size() as f32, - anim_speed, - ); - - ui.put( - rect, - ui::ProfilePic::new(self.note_context.img_cache, pic).size(size), - ) - .on_hover_ui_at_pointer(|ui| { - ui.set_max_width(300.0); - ui.add(ui::ProfilePreview::new( - profile.as_ref().unwrap(), - self.note_context.img_cache, - )); - }); - - if resp.hovered() || resp.clicked() { - ui::show_pointer(ui); - } - - resp - } - - None => { - // This has to match the expand size from the above case to - // prevent bounciness - let size = (pfp_size + ui::NoteView::expand_size()) as f32; - let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); - - ui.put( - rect, - ui::ProfilePic::new(self.note_context.img_cache, ui::ProfilePic::no_pfp_url()) - .size(pfp_size as f32), - ) - .interact(sense) - } - } - } - - pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { - if self.options().has_textmode() { - NoteResponse::new(self.textmode_ui(ui)) - } else { - let txn = self.note.txn().expect("txn"); - if let Some(note_to_repost) = get_reposted_note(self.note_context.ndb, txn, self.note) { - let profile = self - .note_context - .ndb - .get_profile_by_pubkey(txn, self.note.pubkey()); - - let style = NotedeckTextStyle::Small; - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.add_space(2.0); - ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); - }); - ui.add_space(6.0); - let resp = ui.add(one_line_display_name_widget( - ui.visuals(), - get_display_name(profile.as_ref().ok()), - style, - )); - if let Ok(rec) = &profile { - resp.on_hover_ui_at_pointer(|ui| { - ui.set_max_width(300.0); - ui.add(ui::ProfilePreview::new(rec, self.note_context.img_cache)); - }); - } - let color = ui.style().visuals.noninteractive().fg_stroke.color; - ui.add_space(4.0); - ui.label( - RichText::new("Reposted") - .color(color) - .text_style(style.text_style()), - ); - }); - NoteView::new(self.note_context, self.cur_acc, &note_to_repost, self.flags).show(ui) - } else { - self.show_standard(ui) - } - } - } - - #[profiling::function] - fn note_header( - ui: &mut egui::Ui, - note_cache: &mut NoteCache, - note: &Note, - profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, - ) { - let note_key = note.key().unwrap(); - - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); - - let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); - render_reltime(ui, cached_note, true); - }); - } - - #[profiling::function] - fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { - let note_key = self.note.key().expect("todo: support non-db notes"); - let txn = self.note.txn().expect("todo: support non-db notes"); - - let mut note_action: Option<NoteAction> = None; - - let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent); - let profile = self - .note_context - .ndb - .get_profile_by_pubkey(txn, self.note.pubkey()); - let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id); - - // wide design - let response = if self.options().has_wide() { - ui.vertical(|ui| { - ui.horizontal(|ui| { - if self.pfp(note_key, &profile, ui).clicked() { - note_action = Some(NoteAction::OpenTimeline(TimelineKind::profile( - Pubkey::new(*self.note.pubkey()), - ))); - }; - - let size = ui.available_size(); - ui.vertical(|ui| { - ui.add_sized( - [size.x, self.options().pfp_size() as f32], - |ui: &mut egui::Ui| { - ui.horizontal_centered(|ui| { - NoteView::note_header( - ui, - self.note_context.note_cache, - self.note, - &profile, - ); - }) - .response - }, - ); - - let note_reply = self - .note_context - .note_cache - .cached_note_or_insert_mut(note_key, self.note) - .reply - .borrow(self.note.tags()); - - if note_reply.reply().is_some() { - let action = ui - .horizontal(|ui| { - reply_desc( - ui, - self.cur_acc, - txn, - &note_reply, - self.note_context, - self.flags, - ) - }) - .inner; - - if action.is_some() { - note_action = action; - } - } - }); - }); - - let mut contents = - NoteContents::new(self.note_context, self.cur_acc, txn, self.note, self.flags); - - ui.add(&mut contents); - - if let Some(action) = contents.action() { - note_action = Some(action.clone()); - } - - if self.options().has_actionbar() { - if let Some(action) = render_note_actionbar( - ui, - self.note_context.zaps, - self.cur_acc.as_ref(), - self.note.id(), - self.note.pubkey(), - note_key, - ) - .inner - { - note_action = Some(action); - } - } - }) - .response - } else { - // main design - ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { - if self.pfp(note_key, &profile, ui).clicked() { - note_action = Some(NoteAction::OpenTimeline(TimelineKind::Profile( - Pubkey::new(*self.note.pubkey()), - ))); - }; - - ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - NoteView::note_header(ui, self.note_context.note_cache, self.note, &profile); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - - let note_reply = self - .note_context - .note_cache - .cached_note_or_insert_mut(note_key, self.note) - .reply - .borrow(self.note.tags()); - - if note_reply.reply().is_some() { - let action = reply_desc( - ui, - self.cur_acc, - txn, - &note_reply, - self.note_context, - self.flags, - ); - - if action.is_some() { - note_action = action; - } - } - }); - - let mut contents = NoteContents::new( - self.note_context, - self.cur_acc, - txn, - self.note, - self.flags, - ); - ui.add(&mut contents); - - if let Some(action) = contents.action() { - note_action = Some(action.clone()); - } - - if self.options().has_actionbar() { - if let Some(action) = render_note_actionbar( - ui, - self.note_context.zaps, - self.cur_acc.as_ref(), - self.note.id(), - self.note.pubkey(), - note_key, - ) - .inner - { - note_action = Some(action); - } - } - }); - }) - .response - }; - - if self.options().has_options_button() { - let context_pos = { - let size = NoteContextButton::max_width(); - let top_right = response.rect.right_top(); - let min = Pos2::new(top_right.x - size, top_right.y); - Rect::from_min_size(min, egui::vec2(size, size)) - }; - - let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); - if let Some(action) = NoteContextButton::menu(ui, resp.clone()) { - note_action = Some(NoteAction::Context(ContextSelection { note_key, action })); - } - } - - let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) { - if let Ok(selection) = ThreadSelection::from_note_id( - self.note_context.ndb, - self.note_context.note_cache, - self.note.txn().unwrap(), - NoteId::new(*self.note.id()), - ) { - Some(NoteAction::OpenTimeline(TimelineKind::Thread(selection))) - } else { - None - } - } else { - note_action - }; - - NoteResponse::new(response).with_action(note_action) - } -} - -fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> { - let new_note_id: &[u8; 32] = if note.kind() == 6 { - let mut res = None; - for tag in note.tags().iter() { - if tag.count() == 0 { - continue; - } - - if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) { - if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) { - res = Some(note_id); - break; - } - } - } - res? - } else { - return None; - }; - - let note = ndb.get_note_by_id(txn, new_note_id).ok(); - note.filter(|note| note.kind() == 1) -} - -fn note_hitbox_id( - note_key: NoteKey, - note_options: NoteOptions, - parent: Option<NoteKey>, -) -> egui::Id { - Id::new(("note_size", note_key, note_options, parent)) -} - -fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> { - ui.ctx() - .data_mut(|d| d.get_persisted(hitbox_id)) - .map(|note_size: Vec2| { - // The hitbox should extend the entire width of the - // container. The hitbox height was cached last layout. - let container_rect = ui.max_rect(); - let rect = Rect { - min: pos2(container_rect.min.x, container_rect.min.y), - max: pos2(container_rect.max.x, container_rect.min.y + note_size.y), - }; - - let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click()); - - response - .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox")); - - response - }) -} - -fn note_hitbox_clicked( - ui: &mut egui::Ui, - hitbox_id: egui::Id, - note_rect: &Rect, - maybe_hitbox: Option<Response>, -) -> bool { - // Stash the dimensions of the note content so we can render the - // hitbox in the next frame - ui.ctx().data_mut(|d| { - d.insert_persisted(hitbox_id, note_rect.size()); - }); - - // If there was an hitbox and it was clicked open the thread - match maybe_hitbox { - Some(hitbox) => hitbox.clicked(), - _ => false, - } -} - -#[profiling::function] -fn render_note_actionbar( - ui: &mut egui::Ui, - zaps: &Zaps, - cur_acc: Option<&KeypairUnowned>, - note_id: &[u8; 32], - note_pubkey: &[u8; 32], - note_key: NoteKey, -) -> egui::InnerResponse<Option<NoteAction>> { - ui.horizontal(|ui| 's: { - let reply_resp = reply_button(ui, note_key); - let quote_resp = quote_repost_button(ui, note_key); - - let zap_target = ZapTarget::Note(NoteZapTarget { - note_id, - zap_recipient: note_pubkey, - }); - - let zap_state = cur_acc.map_or_else( - || Ok(AnyZapState::None), - |kp| zaps.any_zap_state_for(kp.pubkey.bytes(), zap_target), - ); - let zap_resp = cur_acc - .filter(|k| k.secret_key.is_some()) - .map(|_| match &zap_state { - Ok(any_zap_state) => ui.add(zap_button(any_zap_state.clone(), note_id)), - Err(zapping_error) => { - let (rect, _) = - ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click()); - ui.add(x_button(rect)) - .on_hover_text(format!("{zapping_error}")) - } - }); - - let to_noteid = |id: &[u8; 32]| NoteId::new(*id); - - if reply_resp.clicked() { - break 's Some(NoteAction::Reply(to_noteid(note_id))); - } - - if quote_resp.clicked() { - break 's Some(NoteAction::Quote(to_noteid(note_id))); - } - - let Some(zap_resp) = zap_resp else { - break 's None; - }; - - if !zap_resp.clicked() { - break 's None; - } - - let target = NoteZapTargetOwned { - note_id: to_noteid(note_id), - zap_recipient: Pubkey::new(*note_pubkey), - }; - - if zap_state.is_err() { - break 's Some(NoteAction::Zap(ZapAction::ClearError(target))); - } - - Some(NoteAction::Zap(ZapAction::Send(target))) - }) -} - -fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) { - let color = ui.style().visuals.noninteractive().fg_stroke.color; - ui.add(Label::new(RichText::new(s).size(10.0).color(color))); -} - -#[profiling::function] -fn render_reltime( - ui: &mut egui::Ui, - note_cache: &mut CachedNote, - before: bool, -) -> egui::InnerResponse<()> { - ui.horizontal(|ui| { - if before { - secondary_label(ui, "⋅"); - } - - secondary_label(ui, note_cache.reltime_str_mut()); - - if !before { - secondary_label(ui, "⋅"); - } - }) -} - -fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { - let img_data = if ui.style().visuals.dark_mode { - egui::include_image!("../../../../../assets/icons/reply.png") - } else { - egui::include_image!("../../../../../assets/icons/reply-dark.png") - }; - - let (rect, size, resp) = - ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); - - // align rect to note contents - let expand_size = 5.0; // from hover_expand_small - let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); - - let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size)); - - resp.union(put_resp) -} - -fn repost_icon(dark_mode: bool) -> egui::Image<'static> { - let img_data = if dark_mode { - egui::include_image!("../../../../../assets/icons/repost_icon_4x.png") - } else { - egui::include_image!("../../../../../assets/icons/repost_light_4x.png") - }; - egui::Image::new(img_data) -} - -fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { - let size = 14.0; - let expand_size = 5.0; - let anim_speed = 0.05; - let id = ui.id().with(("repost_anim", note_key)); - - let (rect, size, resp) = ui::anim::hover_expand(ui, id, size, expand_size, anim_speed); - - let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0)); - - let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)); - - resp.union(put_resp) -} - -fn zap_button(state: AnyZapState, noteid: &[u8; 32]) -> impl egui::Widget + use<'_> { - move |ui: &mut egui::Ui| -> egui::Response { - let img_data = egui::include_image!("../../../../../assets/icons/zap_4x.png"); - - let (rect, size, resp) = ui::anim::hover_expand_small(ui, ui.id().with("zap")); - - let mut img = egui::Image::new(img_data).max_width(size); - let id = ui.id().with(("pulse", noteid)); - let ctx = ui.ctx().clone(); - - match state { - AnyZapState::None => { - if !ui.visuals().dark_mode { - img = img.tint(egui::Color32::BLACK); - } - } - AnyZapState::Pending => { - let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 }; - img = ImagePulseTint::new(&ctx, id, img, &[0xFF, 0xB7, 0x57], alpha_min, 255) - .with_speed(0.35) - .animate(); - } - AnyZapState::LocalOnly => { - img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57)); - } - AnyZapState::Confirmed => {} - } - - // align rect to note contents - let expand_size = 5.0; // from hover_expand_small - let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); - - let put_resp = ui.put(rect, img); - - resp.union(put_resp) - } -} diff --git a/crates/notedeck_columns/src/ui/note/options.rs b/crates/notedeck_columns/src/ui/note/options.rs @@ -1,79 +0,0 @@ -use crate::ui::ProfilePic; -use bitflags::bitflags; - -bitflags! { - // Attributes can be applied to flags types - #[repr(transparent)] - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub struct NoteOptions: u64 { - const actionbar = 0b0000000000000001; - const note_previews = 0b0000000000000010; - const small_pfp = 0b0000000000000100; - const medium_pfp = 0b0000000000001000; - const wide = 0b0000000000010000; - const selectable_text = 0b0000000000100000; - const textmode = 0b0000000001000000; - const options_button = 0b0000000010000000; - const hide_media = 0b0000000100000000; - - /// Scramble text so that its not distracting during development - const scramble_text = 0b0000001000000000; - - /// Whether the current note is a preview - const is_preview = 0b0000010000000000; - } -} - -impl Default for NoteOptions { - fn default() -> NoteOptions { - NoteOptions::options_button | NoteOptions::note_previews | NoteOptions::actionbar - } -} - -macro_rules! create_bit_methods { - ($fn_name:ident, $has_name:ident, $option:ident) => { - #[inline] - pub fn $fn_name(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::$option; - } else { - *self &= !NoteOptions::$option; - } - } - - #[inline] - pub fn $has_name(self) -> bool { - (self & NoteOptions::$option) == NoteOptions::$option - } - }; -} - -impl NoteOptions { - create_bit_methods!(set_small_pfp, has_small_pfp, small_pfp); - create_bit_methods!(set_medium_pfp, has_medium_pfp, medium_pfp); - create_bit_methods!(set_note_previews, has_note_previews, note_previews); - create_bit_methods!(set_selectable_text, has_selectable_text, selectable_text); - create_bit_methods!(set_textmode, has_textmode, textmode); - create_bit_methods!(set_actionbar, has_actionbar, actionbar); - create_bit_methods!(set_wide, has_wide, wide); - create_bit_methods!(set_options_button, has_options_button, options_button); - create_bit_methods!(set_hide_media, has_hide_media, hide_media); - create_bit_methods!(set_scramble_text, has_scramble_text, scramble_text); - create_bit_methods!(set_is_preview, has_is_preview, is_preview); - - pub fn new(is_universe_timeline: bool) -> Self { - let mut options = NoteOptions::default(); - options.set_hide_media(is_universe_timeline); - options - } - - pub fn pfp_size(&self) -> i8 { - if self.has_small_pfp() { - ProfilePic::small_size() - } else if self.has_medium_pfp() { - ProfilePic::medium_size() - } else { - ProfilePic::default_size() - } - } -} diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,28 +1,29 @@ -use crate::actionbar::NoteAction; use crate::draft::{Draft, Drafts, MentionHint}; use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; use crate::post::{downcast_post_buffer, MentionType, NewPost}; -use crate::profile::get_display_name; use crate::ui::search_results::SearchResultsView; use crate::ui::{self, Preview, PreviewConfig}; use crate::Result; -use egui::text::{CCursorRange, LayoutJob}; -use egui::text_edit::TextEditOutput; -use egui::widgets::text_edit::TextEdit; -use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense, TextBuffer}; + +use egui::{ + text::{CCursorRange, LayoutJob}, + text_edit::TextEditOutput, + vec2, + widgets::text_edit::TextEdit, + Frame, Layout, Margin, Pos2, ScrollArea, Sense, TextBuffer, +}; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; use notedeck_ui::{ gif::{handle_repaint, retrieve_latest_texture}, images::render_images, + note::render_note_preview, + NoteOptions, ProfilePic, }; -use notedeck::supported_mime_hosted_at_url; +use notedeck::{name::get_display_name, supported_mime_hosted_at_url, NoteAction, NoteContext}; use tracing::error; -use super::contents::{render_note_preview, NoteContext}; -use super::NoteOptions; - pub struct PostView<'a, 'd> { note_context: &'a mut NoteContext<'d>, draft: &'a mut Draft, @@ -133,14 +134,14 @@ impl<'a, 'd> PostView<'a, 'd> { .as_ref() .ok() .and_then(|p| { - Some(ui::ProfilePic::from_profile(self.note_context.img_cache, p)?.size(pfp_size)) + Some(ProfilePic::from_profile(self.note_context.img_cache, p)?.size(pfp_size)) }); if let Some(pfp) = poster_pfp { ui.add(pfp); } else { ui.add( - ui::ProfilePic::new(self.note_context.img_cache, ui::ProfilePic::no_pfp_url()) + ProfilePic::new(self.note_context.img_cache, notedeck::profile::no_pfp_url()) .size(pfp_size), ); } diff --git a/crates/notedeck_columns/src/ui/note/quote_repost.rs b/crates/notedeck_columns/src/ui/note/quote_repost.rs @@ -1,11 +1,12 @@ -use enostr::{FilledKeypair, NoteId}; - +use super::{PostResponse, PostType}; use crate::{ draft::Draft, ui::{self}, }; -use super::{contents::NoteContext, NoteOptions, PostResponse, PostType}; +use enostr::{FilledKeypair, NoteId}; +use notedeck::NoteContext; +use notedeck_ui::NoteOptions; pub struct QuoteRepostView<'a, 'd> { note_context: &'a mut NoteContext<'d>, diff --git a/crates/notedeck_columns/src/ui/note/reply.rs b/crates/notedeck_columns/src/ui/note/reply.rs @@ -1,10 +1,12 @@ use crate::draft::Draft; -use crate::ui; -use crate::ui::note::{PostAction, PostResponse, PostType}; -use enostr::{FilledKeypair, NoteId}; +use crate::ui::{ + self, + note::{PostAction, PostResponse, PostType}, +}; -use super::contents::NoteContext; -use super::NoteOptions; +use enostr::{FilledKeypair, NoteId}; +use notedeck::NoteContext; +use notedeck_ui::{NoteOptions, NoteView, ProfilePic}; pub struct PostReplyView<'a, 'd> { note_context: &'a mut NoteContext<'d>, @@ -56,15 +58,15 @@ impl<'a, 'd> PostReplyView<'a, 'd> { // to indent things so that the reply line is aligned let pfp_offset: i8 = ui::PostView::outer_margin() + ui::PostView::inner_margin() - + ui::ProfilePic::small_size() / 2; + + ProfilePic::small_size() / 2; let note_offset: i8 = - pfp_offset - ui::ProfilePic::medium_size() / 2 - ui::NoteView::expand_size() / 2; + pfp_offset - ProfilePic::medium_size() / 2 - NoteView::expand_size() / 2; let quoted_note = egui::Frame::NONE .outer_margin(egui::Margin::same(note_offset)) .show(ui, |ui| { - ui::NoteView::new( + NoteView::new( self.note_context, &Some(self.poster.into()), self.note, @@ -113,9 +115,9 @@ impl<'a, 'd> PostReplyView<'a, 'd> { // honestly don't know what the fuck I'm doing here. just trying // to get the line under the profile picture rect.min.y = avail_rect.min.y - + (ui::ProfilePic::medium_size() as f32 / 2.0 - + ui::ProfilePic::medium_size() as f32 - + ui::NoteView::expand_size() as f32 * 2.0) + + (ProfilePic::medium_size() as f32 / 2.0 + + ProfilePic::medium_size() as f32 + + NoteView::expand_size() as f32 * 2.0) + 1.0; // For some reason we need to nudge the reply line's height a diff --git a/crates/notedeck_columns/src/ui/note/reply_description.rs b/crates/notedeck_columns/src/ui/note/reply_description.rs @@ -1,182 +0,0 @@ -use crate::{ - actionbar::NoteAction, - ui::{self}, -}; -use egui::{Label, RichText, Sense}; -use enostr::KeypairUnowned; -use nostrdb::{Note, NoteReply, Transaction}; - -use super::{contents::NoteContext, NoteOptions}; - -#[must_use = "Please handle the resulting note action"] -#[profiling::function] -pub fn reply_desc( - ui: &mut egui::Ui, - cur_acc: &Option<KeypairUnowned>, - txn: &Transaction, - note_reply: &NoteReply, - note_context: &mut NoteContext, - note_options: NoteOptions, -) -> Option<NoteAction> { - let mut note_action: Option<NoteAction> = None; - let size = 10.0; - let selectable = false; - let visuals = ui.visuals(); - let color = visuals.noninteractive().fg_stroke.color; - let link_color = visuals.hyperlink_color; - - // note link renderer helper - let note_link = - |ui: &mut egui::Ui, note_context: &mut NoteContext, text: &str, note: &Note<'_>| { - let r = ui.add( - Label::new(RichText::new(text).size(size).color(link_color)) - .sense(Sense::click()) - .selectable(selectable), - ); - - if r.clicked() { - // TODO: jump to note - } - - if r.hovered() { - r.on_hover_ui_at_pointer(|ui| { - ui.set_max_width(400.0); - ui::NoteView::new(note_context, cur_acc, note, note_options) - .actionbar(false) - .wide(true) - .show(ui); - }); - } - }; - - ui.add(Label::new(RichText::new("replying to").size(size).color(color)).selectable(selectable)); - - let reply = note_reply.reply()?; - - let reply_note = if let Ok(reply_note) = note_context.ndb.get_note_by_id(txn, reply.id) { - reply_note - } else { - ui.add(Label::new(RichText::new("a note").size(size).color(color)).selectable(selectable)); - return None; - }; - - if note_reply.is_reply_to_root() { - // We're replying to the root, let's show this - let action = ui::Mention::new( - note_context.ndb, - note_context.img_cache, - txn, - reply_note.pubkey(), - ) - .size(size) - .selectable(selectable) - .show(ui) - .inner; - - if action.is_some() { - note_action = action; - } - - ui.add(Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable)); - - note_link(ui, note_context, "thread", &reply_note); - } else if let Some(root) = note_reply.root() { - // replying to another post in a thread, not the root - - if let Ok(root_note) = note_context.ndb.get_note_by_id(txn, root.id) { - if root_note.pubkey() == reply_note.pubkey() { - // simply "replying to bob's note" when replying to bob in his thread - let action = ui::Mention::new( - note_context.ndb, - note_context.img_cache, - txn, - reply_note.pubkey(), - ) - .size(size) - .selectable(selectable) - .show(ui) - .inner; - - if action.is_some() { - note_action = action; - } - - ui.add( - Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), - ); - - note_link(ui, note_context, "note", &reply_note); - } else { - // replying to bob in alice's thread - - let action = ui::Mention::new( - note_context.ndb, - note_context.img_cache, - txn, - reply_note.pubkey(), - ) - .size(size) - .selectable(selectable) - .show(ui) - .inner; - - if action.is_some() { - note_action = action; - } - - ui.add( - Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), - ); - - note_link(ui, note_context, "note", &reply_note); - - ui.add( - Label::new(RichText::new("in").size(size).color(color)).selectable(selectable), - ); - - let action = ui::Mention::new( - note_context.ndb, - note_context.img_cache, - txn, - root_note.pubkey(), - ) - .size(size) - .selectable(selectable) - .show(ui) - .inner; - - if action.is_some() { - note_action = action; - } - - ui.add( - Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), - ); - - note_link(ui, note_context, "thread", &root_note); - } - } else { - let action = ui::Mention::new( - note_context.ndb, - note_context.img_cache, - txn, - reply_note.pubkey(), - ) - .size(size) - .selectable(selectable) - .show(ui) - .inner; - - if action.is_some() { - note_action = action; - } - - ui.add( - Label::new(RichText::new("in someone's thread").size(size).color(color)) - .selectable(selectable), - ); - } - } - - note_action -} diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -1,13 +1,9 @@ use core::f32; -use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit}; -use notedeck::{Images, NotedeckTextStyle}; - use crate::profile_state::ProfileState; - -use super::banner; - -use notedeck_ui::{profile::unwrap_profile_url, ProfilePic}; +use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit}; +use notedeck::{profile::unwrap_profile_url, Images, NotedeckTextStyle}; +use notedeck_ui::{profile::banner, ProfilePic}; pub struct EditProfileView<'a> { state: &'a mut ProfileState, @@ -26,14 +22,14 @@ impl<'a> EditProfileView<'a> { banner(ui, Some(&self.state.banner), 188.0); let padding = 24.0; - crate::ui::padding(padding, ui, |ui| { + notedeck_ui::padding(padding, ui, |ui| { self.inner(ui, padding); }); ui.separator(); let mut save = false; - crate::ui::padding(padding, ui, |ui| { + notedeck_ui::padding(padding, ui, |ui| { ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { if ui .add(button("Save changes", 119.0).fill(notedeck_ui::colors::PINK)) diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -1,27 +1,23 @@ pub mod edit; -pub mod preview; pub use edit::EditProfileView; -use egui::load::TexturePoll; -use egui::{vec2, Color32, CornerRadius, Label, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; +use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{ProfileRecord, Transaction}; -pub use preview::ProfilePreview; use tracing::error; use crate::{ - actionbar::NoteAction, - profile::get_display_name, timeline::{TimelineCache, TimelineKind}, ui::timeline::{tabs_ui, TimelineTabView}, - NostrName, }; - -use notedeck::{Accounts, MuteFun, NotedeckTextStyle, UnknownIds}; -use notedeck_ui::{images, profile::get_profile_url, ProfilePic}; - -use super::note::contents::NoteContext; -use super::note::NoteOptions; +use notedeck::{ + name::get_display_name, profile::get_profile_url, Accounts, MuteFun, NoteAction, NoteContext, + NotedeckTextStyle, UnknownIds, +}; +use notedeck_ui::{ + profile::{about_section_widget, banner, display_name_widget}, + NoteOptions, ProfilePic, +}; pub struct ProfileView<'a, 'd> { pubkey: &'a Pubkey, @@ -137,7 +133,7 @@ impl<'a, 'd> ProfileView<'a, 'd> { ); let padding = 12.0; - crate::ui::padding(padding, ui, |ui| { + notedeck_ui::padding(padding, ui, |ui| { let mut pfp_rect = ui.available_rect_before_wrap(); let size = 80.0; pfp_rect.set_width(size); @@ -342,110 +338,3 @@ fn edit_profile_button() -> impl egui::Widget + 'static { resp } } - -fn display_name_widget<'a>( - name: &'a NostrName<'a>, - add_placeholder_space: bool, -) -> impl egui::Widget + 'a { - move |ui: &mut egui::Ui| -> egui::Response { - let disp_resp = name.display_name.map(|disp_name| { - ui.add( - Label::new( - RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()), - ) - .selectable(false), - ) - }); - - let (username_resp, nip05_resp) = ui - .horizontal(|ui| { - let username_resp = name.username.map(|username| { - ui.add( - Label::new( - RichText::new(format!("@{}", username)) - .size(16.0) - .color(notedeck_ui::colors::MID_GRAY), - ) - .selectable(false), - ) - }); - - let nip05_resp = name.nip05.map(|nip05| { - ui.image(egui::include_image!( - "../../../../../assets/icons/verified_4x.png" - )); - ui.add(Label::new( - RichText::new(nip05) - .size(16.0) - .color(notedeck_ui::colors::TEAL), - )) - }); - - (username_resp, nip05_resp) - }) - .inner; - - let resp = match (disp_resp, username_resp, nip05_resp) { - (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05), - (Some(disp), Some(username), None) => disp.union(username), - (Some(disp), None, None) => disp, - (None, Some(username), Some(nip05)) => username.union(nip05), - (None, Some(username), None) => username, - _ => ui.add(Label::new(RichText::new(name.name()))), - }; - - if add_placeholder_space { - ui.add_space(16.0); - } - - resp - } -} - -fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b -where - 'b: 'a, -{ - move |ui: &mut egui::Ui| { - if let Some(about) = profile.record().profile().and_then(|p| p.about()) { - let resp = ui.label(about); - ui.add_space(8.0); - resp - } else { - // need any Response so we dont need an Option - ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) - } - } -} - -fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> { - // TODO: cache banner - if !banner_url.is_empty() { - let texture_load_res = - egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size()); - if let Ok(texture_poll) = texture_load_res { - match texture_poll { - TexturePoll::Pending { .. } => {} - TexturePoll::Ready { texture, .. } => return Some(texture), - } - } - } - - None -} - -fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response { - ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| { - banner_url - .and_then(|url| banner_texture(ui, url)) - .map(|texture| { - images::aspect_fill( - ui, - Sense::hover(), - texture.id, - texture.size.x / texture.size.y, - ) - }) - .unwrap_or_else(|| ui.label("")) - }) -} diff --git a/crates/notedeck_columns/src/ui/profile/preview.rs b/crates/notedeck_columns/src/ui/profile/preview.rs @@ -1,171 +0,0 @@ -use crate::ui::ProfilePic; -use crate::NostrName; -use egui::{Frame, Label, RichText, Widget}; -use egui_extras::Size; -use nostrdb::ProfileRecord; - -use notedeck::{Images, NotedeckTextStyle}; - -use super::{about_section_widget, banner, display_name_widget, get_display_name}; -use notedeck_ui::profile::get_profile_url; - -pub struct ProfilePreview<'a, 'cache> { - profile: &'a ProfileRecord<'a>, - cache: &'cache mut Images, - banner_height: Size, -} - -impl<'a, 'cache> ProfilePreview<'a, 'cache> { - pub fn new(profile: &'a ProfileRecord<'a>, cache: &'cache mut Images) -> Self { - let banner_height = Size::exact(80.0); - ProfilePreview { - profile, - cache, - banner_height, - } - } - - pub fn banner_height(&mut self, size: Size) { - self.banner_height = size; - } - - fn body(self, ui: &mut egui::Ui) { - let padding = 12.0; - crate::ui::padding(padding, ui, |ui| { - let mut pfp_rect = ui.available_rect_before_wrap(); - let size = 80.0; - pfp_rect.set_width(size); - pfp_rect.set_height(size); - let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); - - ui.put( - pfp_rect, - ProfilePic::new(self.cache, get_profile_url(Some(self.profile))) - .size(size) - .border(ProfilePic::border_stroke(ui)), - ); - ui.add(display_name_widget( - &get_display_name(Some(self.profile)), - false, - )); - ui.add(about_section_widget(self.profile)); - }); - } -} - -impl egui::Widget for ProfilePreview<'_, '_> { - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - ui.vertical(|ui| { - banner( - ui, - self.profile.record().profile().and_then(|p| p.banner()), - 80.0, - ); - - self.body(ui); - }) - .response - } -} - -pub struct SimpleProfilePreview<'a, 'cache> { - profile: Option<&'a ProfileRecord<'a>>, - cache: &'cache mut Images, - is_nsec: bool, -} - -impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> { - pub fn new( - profile: Option<&'a ProfileRecord<'a>>, - cache: &'cache mut Images, - is_nsec: bool, - ) -> Self { - SimpleProfilePreview { - profile, - cache, - is_nsec, - } - } -} - -impl egui::Widget for SimpleProfilePreview<'_, '_> { - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - Frame::new() - .show(ui, |ui| { - ui.add(ProfilePic::new(self.cache, get_profile_url(self.profile)).size(48.0)); - ui.vertical(|ui| { - ui.add(display_name_widget(&get_display_name(self.profile), true)); - if !self.is_nsec { - ui.add( - Label::new( - RichText::new("Read only") - .size(notedeck::fonts::get_font_size( - ui.ctx(), - &NotedeckTextStyle::Tiny, - )) - .color(ui.visuals().warn_fg_color), - ) - .selectable(false), - ); - } - }); - }) - .response - } -} - -mod previews { - use super::*; - use crate::test_data::test_profile_record; - use crate::ui::{Preview, PreviewConfig}; - use notedeck::{App, AppContext}; - - pub struct ProfilePreviewPreview<'a> { - profile: ProfileRecord<'a>, - } - - impl ProfilePreviewPreview<'_> { - pub fn new() -> Self { - let profile = test_profile_record(); - ProfilePreviewPreview { profile } - } - } - - impl Default for ProfilePreviewPreview<'_> { - fn default() -> Self { - ProfilePreviewPreview::new() - } - } - - impl App for ProfilePreviewPreview<'_> { - fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) { - ProfilePreview::new(&self.profile, app.img_cache).ui(ui); - } - } - - impl<'a> Preview for ProfilePreview<'a, '_> { - /// A preview of the profile preview :D - type Prev = ProfilePreviewPreview<'a>; - - fn preview(_cfg: PreviewConfig) -> Self::Prev { - ProfilePreviewPreview::new() - } - } -} - -pub fn one_line_display_name_widget<'a>( - visuals: &egui::Visuals, - display_name: NostrName<'a>, - style: NotedeckTextStyle, -) -> impl egui::Widget + 'a { - let text_style = style.text_style(); - let color = visuals.noninteractive().fg_stroke.color; - - move |ui: &mut egui::Ui| -> egui::Response { - ui.label( - RichText::new(display_name.name()) - .text_style(text_style) - .color(color), - ) - } -} diff --git a/crates/notedeck_columns/src/ui/relay.rs b/crates/notedeck_columns/src/ui/relay.rs @@ -1,18 +1,15 @@ use std::collections::HashMap; use crate::relay_pool_manager::{RelayPoolManager, RelayStatus}; -use crate::ui::{Preview, PreviewConfig, View}; +use crate::ui::{Preview, PreviewConfig}; use egui::{ Align, Button, CornerRadius, Frame, Id, Image, Layout, Margin, Rgba, RichText, Ui, Vec2, }; -use notedeck_ui::colors::PINK; - use enostr::RelayPool; use notedeck::{Accounts, NotedeckTextStyle}; - +use notedeck_ui::{colors::PINK, padding, View}; use tracing::debug; -use super::padding; use super::widgets::styled_button; pub struct RelayView<'a> { diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -1,15 +1,11 @@ use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit}; use enostr::KeypairUnowned; -use super::{note::contents::NoteContext, padding}; -use crate::{ - actionbar::NoteAction, - ui::{note::NoteOptions, timeline::TimelineTabView}, -}; +use crate::ui::timeline::TimelineTabView; use egui_winit::clipboard::Clipboard; use nostrdb::{Filter, Transaction}; -use notedeck::{MuteFun, NoteRef}; -use notedeck_ui::icons::search_icon; +use notedeck::{MuteFun, NoteAction, NoteContext, NoteRef}; +use notedeck_ui::{icons::search_icon, padding, NoteOptions}; use std::time::{Duration, Instant}; use tracing::{error, info, warn}; diff --git a/crates/notedeck_columns/src/ui/search_results.rs b/crates/notedeck_columns/src/ui/search_results.rs @@ -1,15 +1,15 @@ use egui::{vec2, FontId, Layout, Pos2, Rect, ScrollArea, UiBuilder, Vec2b}; use nostrdb::{Ndb, ProfileRecord, Transaction}; -use notedeck::{fonts::get_font_size, Images, NotedeckTextStyle}; -use tracing::error; - -use crate::{ - profile::get_display_name, - ui::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, +use notedeck::{ + fonts::get_font_size, name::get_display_name, profile::get_profile_url, Images, + NotedeckTextStyle, }; - -use super::{widgets::x_button, ProfilePic}; -use notedeck_ui::profile::get_profile_url; +use notedeck_ui::{ + anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + widgets::x_button, + ProfilePic, +}; +use tracing::error; pub struct SearchResultsView<'a> { ndb: &'a Ndb, diff --git a/crates/notedeck_columns/src/ui/side_panel.rs b/crates/notedeck_columns/src/ui/side_panel.rs @@ -12,14 +12,13 @@ use crate::{ }; use notedeck::{Accounts, UserAccount}; -use notedeck_ui::colors; - -use super::{ +use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, - configure_deck::deck_icon, - View, + colors, View, }; +use super::configure_deck::deck_icon; + pub static SIDE_PANEL_WIDTH: f32 = 68.0; static ICON_WIDTH: f32 = 40.0; diff --git a/crates/notedeck_columns/src/ui/support.rs b/crates/notedeck_columns/src/ui/support.rs @@ -1,11 +1,9 @@ use egui::{vec2, Button, Label, Layout, RichText}; +use notedeck::{NamedFontFamily, NotedeckTextStyle}; +use notedeck_ui::{colors::PINK, padding}; use tracing::error; use crate::support::Support; -use notedeck_ui::colors::PINK; - -use super::padding; -use notedeck::{NamedFontFamily, NotedeckTextStyle}; pub struct SupportView<'a> { support: &'a mut Support, diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -1,17 +1,11 @@ -use crate::{ - actionbar::NoteAction, - timeline::{ThreadSelection, TimelineCache, TimelineKind}, -}; - use enostr::KeypairUnowned; use nostrdb::Transaction; -use notedeck::{MuteFun, RootNoteId, UnknownIds}; +use notedeck::{MuteFun, NoteAction, NoteContext, RootNoteId, UnknownIds}; +use notedeck_ui::NoteOptions; use tracing::error; -use super::{ - note::{contents::NoteContext, NoteOptions}, - timeline::TimelineTabView, -}; +use crate::timeline::{ThreadSelection, TimelineCache, TimelineKind}; +use crate::ui::timeline::TimelineTabView; pub struct ThreadView<'a, 'd> { timeline_cache: &'a mut TimelineCache, diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -1,23 +1,17 @@ -use std::f32::consts::PI; - -use crate::actionbar::NoteAction; -use crate::timeline::TimelineTab; -use crate::{ - timeline::{TimelineCache, TimelineKind, ViewFilter}, - ui, -}; use egui::containers::scroll_area::ScrollBarVisibility; use egui::{vec2, Direction, Layout, Pos2, Stroke}; use egui_tabs::TabColor; use enostr::KeypairUnowned; use nostrdb::Transaction; -use notedeck::note::root_note_id_from_selected_id; -use notedeck::MuteFun; +use std::f32::consts::PI; use tracing::{error, warn}; -use super::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}; -use super::note::contents::NoteContext; -use super::note::NoteOptions; +use crate::timeline::{TimelineCache, TimelineKind, TimelineTab, ViewFilter}; +use notedeck::{note::root_note_id_from_selected_id, MuteFun, NoteAction, NoteContext}; +use notedeck_ui::{ + anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + show_pointer, NoteOptions, NoteView, +}; pub struct TimelineView<'a, 'd> { timeline_id: &'a TimelineKind, @@ -134,7 +128,7 @@ fn timeline_ui( if goto_top_resp.clicked() { scroll_area = scroll_area.vertical_scroll_offset(0.0); } else if goto_top_resp.hovered() { - ui::show_pointer(ui); + show_pointer(ui); } } @@ -271,7 +265,7 @@ pub fn tabs_ui(ui: &mut egui::Ui, selected: usize, views: &[TimelineTab]) -> usi }); //ui.add_space(0.5); - ui::hline(ui); + notedeck_ui::hline(ui); let sel = tab_res.selected().unwrap_or_default(); @@ -395,8 +389,8 @@ impl<'a, 'd> TimelineTabView<'a, 'd> { }; if !muted { - ui::padding(8.0, ui, |ui| { - let resp = ui::NoteView::new( + notedeck_ui::padding(8.0, ui, |ui| { + let resp = NoteView::new( self.note_context, self.cur_acc, &note, @@ -409,7 +403,7 @@ impl<'a, 'd> TimelineTabView<'a, 'd> { } }); - ui::hline(ui); + notedeck_ui::hline(ui); } 1 diff --git a/crates/notedeck_columns/src/ui/username.rs b/crates/notedeck_columns/src/ui/username.rs @@ -1,94 +0,0 @@ -use egui::{Color32, RichText, Widget}; -use nostrdb::ProfileRecord; -use notedeck::fonts::NamedFontFamily; - -pub struct Username<'a> { - profile: Option<&'a ProfileRecord<'a>>, - pk: &'a [u8; 32], - pk_colored: bool, - abbrev: usize, -} - -impl<'a> Username<'a> { - pub fn pk_colored(mut self, pk_colored: bool) -> Self { - self.pk_colored = pk_colored; - self - } - - pub fn abbreviated(mut self, amount: usize) -> Self { - self.abbrev = amount; - self - } - - pub fn new(profile: Option<&'a ProfileRecord>, pk: &'a [u8; 32]) -> Self { - let pk_colored = false; - let abbrev: usize = 1000; - Username { - profile, - pk, - pk_colored, - abbrev, - } - } -} - -impl Widget for Username<'_> { - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - - let color = if self.pk_colored { - Some(pk_color(self.pk)) - } else { - None - }; - - if let Some(profile) = self.profile { - if let Some(prof) = profile.record().profile() { - if prof.display_name().is_some() && prof.display_name().unwrap() != "" { - ui_abbreviate_name(ui, prof.display_name().unwrap(), self.abbrev, color); - } else if let Some(name) = prof.name() { - ui_abbreviate_name(ui, name, self.abbrev, color); - } - } - } else { - let mut txt = RichText::new("nostrich").family(NamedFontFamily::Medium.as_family()); - if let Some(col) = color { - txt = txt.color(col) - } - ui.label(txt); - } - }) - .response - } -} - -fn colored_name(name: &str, color: Option<Color32>) -> RichText { - let mut txt = RichText::new(name).family(NamedFontFamily::Medium.as_family()); - - if let Some(color) = color { - txt = txt.color(color); - } - - txt -} - -fn ui_abbreviate_name(ui: &mut egui::Ui, name: &str, len: usize, color: Option<Color32>) { - let should_abbrev = name.len() > len; - let name = if should_abbrev { - let closest = crate::abbrev::floor_char_boundary(name, len); - &name[..closest] - } else { - name - }; - - ui.label(colored_name(name, color)); - - if should_abbrev { - ui.label(colored_name("..", color)); - } -} - -fn pk_color(pk: &[u8; 32]) -> Color32 { - Color32::from_rgb(pk[8], pk[10], pk[12]) -} diff --git a/crates/notedeck_columns/src/ui/widgets.rs b/crates/notedeck_columns/src/ui/widgets.rs @@ -1,41 +1,6 @@ -use egui::{emath::GuiRounding, Button, Pos2, Stroke, Widget}; +use egui::{Button, Widget}; use notedeck::NotedeckTextStyle; -use super::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}; - -pub fn x_button(rect: egui::Rect) -> impl egui::Widget { - move |ui: &mut egui::Ui| -> egui::Response { - let max_width = rect.width(); - let helper = AnimationHelper::new_from_rect(ui, "user_search_close", rect); - - let fill_color = ui.visuals().text_color(); - - let radius = max_width / (2.0 * ICON_EXPANSION_MULTIPLE); - - let painter = ui.painter(); - let ppp = ui.ctx().pixels_per_point(); - let nw_edge = helper - .scale_pos_from_center(Pos2::new(-radius, radius)) - .round_to_pixel_center(ppp); - let se_edge = helper - .scale_pos_from_center(Pos2::new(radius, -radius)) - .round_to_pixel_center(ppp); - let sw_edge = helper - .scale_pos_from_center(Pos2::new(-radius, -radius)) - .round_to_pixel_center(ppp); - let ne_edge = helper - .scale_pos_from_center(Pos2::new(radius, radius)) - .round_to_pixel_center(ppp); - - let line_width = helper.scale_1d_pos(2.0); - - painter.line_segment([nw_edge, se_edge], Stroke::new(line_width, fill_color)); - painter.line_segment([ne_edge, sw_edge], Stroke::new(line_width, fill_color)); - - helper.take_animation_response() - } -} - /// Sized and styled to match the figma design pub fn styled_button(text: &str, fill_color: egui::Color32) -> impl Widget + '_ { move |ui: &mut egui::Ui| -> egui::Response { diff --git a/crates/notedeck_ui/Cargo.toml b/crates/notedeck_ui/Cargo.toml @@ -14,3 +14,5 @@ profiling = { workspace = true } tokio = { workspace = true } notedeck = { workspace = true } image = { workspace = true } +bitflags = { workspace = true } +enostr = { workspace = true } diff --git a/crates/notedeck_ui/src/anim.rs b/crates/notedeck_ui/src/anim.rs @@ -1,6 +1,5 @@ use egui::{Pos2, Rect, Response, Sense}; -/* pub fn hover_expand( ui: &mut egui::Ui, id: egui::Id, @@ -28,7 +27,6 @@ pub fn hover_expand_small(ui: &mut egui::Ui, id: egui::Id) -> (egui::Rect, f32, hover_expand(ui, id, size, expand_size, anim_speed) } -*/ pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2; pub static ANIM_SPEED: f32 = 0.05; diff --git a/crates/notedeck_ui/src/images.rs b/crates/notedeck_ui/src/images.rs @@ -1,4 +1,3 @@ -use crate::ProfilePic; use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; use image::codecs::gif::GifDecoder; use image::imageops::FilterType; @@ -474,7 +473,7 @@ fn render_media_cache( let no_pfp = crate::images::fetch_img( cache, ui.ctx(), - ProfilePic::no_pfp_url(), + notedeck::profile::no_pfp_url(), ImageType::Profile(128), cache_type, ); diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs @@ -1,10 +1,55 @@ -mod anim; +pub mod anim; pub mod colors; pub mod constants; pub mod gif; pub mod icons; pub mod images; +pub mod mention; +pub mod note; pub mod profile; +mod username; +pub mod widgets; pub use anim::{AnimationHelper, ImagePulseTint}; -pub use profile::ProfilePic; +pub use mention::Mention; +pub use note::{NoteContents, NoteOptions, NoteView}; +pub use profile::{ProfilePic, ProfilePreview}; +pub use username::Username; + +use egui::Margin; + +/// This is kind of like the Widget trait but is meant for larger top-level +/// views that are typically stateful. +/// +/// The Widget trait forces us to add mutable +/// implementations at the type level, which screws us when generating Previews +/// for a Widget. I would have just Widget instead of making this Trait otherwise. +/// +/// There is some precendent for this, it looks like there's a similar trait +/// in the egui demo library. +pub trait View { + fn ui(&mut self, ui: &mut egui::Ui); +} + +pub fn padding<R>( + amount: impl Into<Margin>, + ui: &mut egui::Ui, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse<R> { + egui::Frame::new() + .inner_margin(amount) + .show(ui, add_contents) +} + +pub fn hline(ui: &egui::Ui) { + // pixel perfect horizontal line + let rect = ui.available_rect_before_wrap(); + #[allow(deprecated)] + let resize_y = ui.painter().round_to_pixel(rect.top()) - 0.5; + let stroke = ui.style().visuals.widgets.noninteractive.bg_stroke; + ui.painter().hline(rect.x_range(), resize_y, stroke); +} + +pub fn show_pointer(ui: &egui::Ui) { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); +} diff --git a/crates/notedeck_ui/src/mention.rs b/crates/notedeck_ui/src/mention.rs @@ -0,0 +1,107 @@ +use crate::{show_pointer, ProfilePreview}; +use egui::Sense; +use enostr::Pubkey; +use nostrdb::{Ndb, Transaction}; +use notedeck::{name::get_display_name, Images, NoteAction}; + +pub struct Mention<'a> { + ndb: &'a Ndb, + img_cache: &'a mut Images, + txn: &'a Transaction, + pk: &'a [u8; 32], + selectable: bool, + size: f32, +} + +impl<'a> Mention<'a> { + pub fn new( + ndb: &'a Ndb, + img_cache: &'a mut Images, + txn: &'a Transaction, + pk: &'a [u8; 32], + ) -> Self { + let size = 16.0; + let selectable = true; + Mention { + ndb, + img_cache, + txn, + pk, + selectable, + size, + } + } + + pub fn selectable(mut self, selectable: bool) -> Self { + self.selectable = selectable; + self + } + + pub fn size(mut self, size: f32) -> Self { + self.size = size; + self + } + + pub fn show(self, ui: &mut egui::Ui) -> egui::InnerResponse<Option<NoteAction>> { + mention_ui( + self.ndb, + self.img_cache, + self.txn, + self.pk, + ui, + self.size, + self.selectable, + ) + } +} + +impl egui::Widget for Mention<'_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + self.show(ui).response + } +} + +#[allow(clippy::too_many_arguments)] +#[profiling::function] +fn mention_ui( + ndb: &Ndb, + img_cache: &mut Images, + txn: &Transaction, + pk: &[u8; 32], + ui: &mut egui::Ui, + size: f32, + selectable: bool, +) -> egui::InnerResponse<Option<NoteAction>> { + let link_color = ui.visuals().hyperlink_color; + + ui.horizontal(|ui| { + let profile = ndb.get_profile_by_pubkey(txn, pk).ok(); + + let name: String = format!("@{}", get_display_name(profile.as_ref()).name()); + + let resp = ui.add( + egui::Label::new(egui::RichText::new(name).color(link_color).size(size)) + .sense(Sense::click()) + .selectable(selectable), + ); + + let note_action = if resp.clicked() { + show_pointer(ui); + Some(NoteAction::Profile(Pubkey::new(*pk))) + } else if resp.hovered() { + show_pointer(ui); + None + } else { + None + }; + + if let Some(rec) = profile.as_ref() { + resp.on_hover_ui_at_pointer(|ui| { + ui.set_max_width(300.0); + ui.add(ProfilePreview::new(rec, img_cache)); + }); + } + + note_action + }) +} diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -0,0 +1,542 @@ +use crate::{ + gif::{handle_repaint, retrieve_latest_texture}, + images::{render_images, ImageType}, + note::{NoteAction, NoteOptions, NoteResponse, NoteView}, +}; + +use egui::{Button, Color32, Hyperlink, Image, Response, RichText, Sense, Window}; +use enostr::KeypairUnowned; +use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction}; +use tracing::warn; + +use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteContext}; + +pub struct NoteContents<'a, 'd> { + note_context: &'a mut NoteContext<'d>, + cur_acc: &'a Option<KeypairUnowned<'a>>, + txn: &'a Transaction, + note: &'a Note<'a>, + options: NoteOptions, + action: Option<NoteAction>, +} + +impl<'a, 'd> NoteContents<'a, 'd> { + #[allow(clippy::too_many_arguments)] + pub fn new( + note_context: &'a mut NoteContext<'d>, + cur_acc: &'a Option<KeypairUnowned<'a>>, + txn: &'a Transaction, + note: &'a Note, + options: NoteOptions, + ) -> Self { + NoteContents { + note_context, + cur_acc, + txn, + note, + options, + action: None, + } + } + + pub fn action(&self) -> &Option<NoteAction> { + &self.action + } +} + +impl egui::Widget for &mut NoteContents<'_, '_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let result = render_note_contents( + ui, + self.note_context, + self.cur_acc, + self.txn, + self.note, + self.options, + ); + self.action = result.action; + result.response + } +} + +/// Render an inline note preview with a border. These are used when +/// notes are references within a note +#[allow(clippy::too_many_arguments)] +#[profiling::function] +pub fn render_note_preview( + ui: &mut egui::Ui, + note_context: &mut NoteContext, + cur_acc: &Option<KeypairUnowned>, + txn: &Transaction, + id: &[u8; 32], + parent: NoteKey, + note_options: NoteOptions, +) -> NoteResponse { + let note = if let Ok(note) = note_context.ndb.get_note_by_id(txn, id) { + // TODO: support other preview kinds + if note.kind() == 1 { + note + } else { + return NoteResponse::new(ui.colored_label( + Color32::RED, + format!("TODO: can't preview kind {}", note.kind()), + )); + } + } else { + return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD")); + /* + return ui + .horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.colored_label(link_color, "@"); + ui.colored_label(link_color, &id_str[4..16]); + }) + .response; + */ + }; + + egui::Frame::new() + .fill(ui.visuals().noninteractive().weak_bg_fill) + .inner_margin(egui::Margin::same(8)) + .outer_margin(egui::Margin::symmetric(0, 8)) + .corner_radius(egui::CornerRadius::same(10)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().noninteractive().bg_stroke.color, + )) + .show(ui, |ui| { + NoteView::new(note_context, cur_acc, &note, note_options) + .actionbar(false) + .small_pfp(true) + .wide(true) + .note_previews(false) + .options_button(true) + .parent(parent) + .is_preview(true) + .show(ui) + }) + .inner +} + +#[allow(clippy::too_many_arguments)] +#[profiling::function] +pub fn render_note_contents( + ui: &mut egui::Ui, + note_context: &mut NoteContext, + cur_acc: &Option<KeypairUnowned>, + txn: &Transaction, + note: &Note, + options: NoteOptions, +) -> NoteResponse { + let note_key = note.key().expect("todo: implement non-db notes"); + let selectable = options.has_selectable_text(); + let mut images: Vec<(String, MediaCacheType)> = vec![]; + let mut note_action: Option<NoteAction> = None; + let mut inline_note: Option<(&[u8; 32], &str)> = None; + let hide_media = options.has_hide_media(); + let link_color = ui.visuals().hyperlink_color; + + if !options.has_is_preview() { + // need this for the rect to take the full width of the column + let _ = ui.allocate_at_least(egui::vec2(ui.available_width(), 0.0), egui::Sense::click()); + } + + let response = ui.horizontal_wrapped(|ui| { + let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) { + blocks + } else { + warn!("missing note content blocks? '{}'", note.content()); + ui.weak(note.content()); + return; + }; + + ui.spacing_mut().item_spacing.x = 0.0; + + for block in blocks.iter(note) { + match block.blocktype() { + BlockType::MentionBech32 => match block.as_mention().unwrap() { + Mention::Profile(profile) => { + let act = crate::Mention::new( + note_context.ndb, + note_context.img_cache, + txn, + profile.pubkey(), + ) + .show(ui) + .inner; + if act.is_some() { + note_action = act; + } + } + + Mention::Pubkey(npub) => { + let act = crate::Mention::new( + note_context.ndb, + note_context.img_cache, + txn, + npub.pubkey(), + ) + .show(ui) + .inner; + if act.is_some() { + note_action = act; + } + } + + Mention::Note(note) if options.has_note_previews() => { + inline_note = Some((note.id(), block.as_str())); + } + + Mention::Event(note) if options.has_note_previews() => { + inline_note = Some((note.id(), block.as_str())); + } + + _ => { + ui.colored_label(link_color, format!("@{}", &block.as_str()[4..16])); + } + }, + + BlockType::Hashtag => { + let resp = ui.colored_label(link_color, format!("#{}", block.as_str())); + + if resp.clicked() { + note_action = Some(NoteAction::Hashtag(block.as_str().to_string())); + } else if resp.hovered() { + crate::show_pointer(ui); + } + } + + BlockType::Url => { + let mut found_supported = || -> bool { + let url = block.as_str(); + if let Some(cache_type) = + supported_mime_hosted_at_url(&mut note_context.img_cache.urls, url) + { + images.push((url.to_string(), cache_type)); + true + } else { + false + } + }; + if hide_media || !found_supported() { + ui.add(Hyperlink::from_label_and_url( + RichText::new(block.as_str()).color(link_color), + block.as_str(), + )); + } + } + + BlockType::Text => { + if options.has_scramble_text() { + ui.add(egui::Label::new(rot13(block.as_str())).selectable(selectable)); + } else { + ui.add(egui::Label::new(block.as_str()).selectable(selectable)); + } + } + + _ => { + ui.colored_label(link_color, block.as_str()); + } + } + } + }); + + let preview_note_action = if let Some((id, _block_str)) = inline_note { + render_note_preview(ui, note_context, cur_acc, txn, id, note_key, options).action + } else { + None + }; + + if !images.is_empty() && !options.has_textmode() { + ui.add_space(2.0); + let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); + image_carousel(ui, note_context.img_cache, images, carousel_id); + ui.add_space(2.0); + } + + let note_action = preview_note_action.or(note_action); + + NoteResponse::new(response.response).with_action(note_action) +} + +fn rot13(input: &str) -> String { + input + .chars() + .map(|c| { + if c.is_ascii_lowercase() { + // Rotate lowercase letters + (((c as u8 - b'a' + 13) % 26) + b'a') as char + } else if c.is_ascii_uppercase() { + // Rotate uppercase letters + (((c as u8 - b'A' + 13) % 26) + b'A') as char + } else { + // Leave other characters unchanged + c + } + }) + .collect() +} + +fn image_carousel( + ui: &mut egui::Ui, + img_cache: &mut Images, + images: Vec<(String, MediaCacheType)>, + carousel_id: egui::Id, +) { + // let's make sure everything is within our area + + let height = 360.0; + let width = ui.available_size().x; + let spinsz = if height > width { width } else { height }; + + let show_popup = ui.ctx().memory(|mem| { + mem.data + .get_temp(carousel_id.with("show_popup")) + .unwrap_or(false) + }); + + let current_image = show_popup.then(|| { + ui.ctx().memory(|mem| { + mem.data + .get_temp::<(String, MediaCacheType)>(carousel_id.with("current_image")) + .unwrap_or_else(|| (images[0].0.clone(), images[0].1.clone())) + }) + }); + + ui.add_sized([width, height], |ui: &mut egui::Ui| { + egui::ScrollArea::horizontal() + .id_salt(carousel_id) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (image, cache_type) in images { + render_images( + ui, + img_cache, + &image, + ImageType::Content, + cache_type.clone(), + |ui| { + ui.allocate_space(egui::vec2(spinsz, spinsz)); + }, + |ui, _| { + ui.allocate_space(egui::vec2(spinsz, spinsz)); + }, + |ui, url, renderable_media, gifs| { + let texture = handle_repaint( + ui, + retrieve_latest_texture(&image, gifs, renderable_media), + ); + let img_resp = ui.add( + Button::image( + Image::new(texture) + .max_height(height) + .corner_radius(5.0) + .fit_to_original_size(1.0), + ) + .frame(false), + ); + + if img_resp.clicked() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(carousel_id.with("show_popup"), true); + mem.data.insert_temp( + carousel_id.with("current_image"), + (image.clone(), cache_type.clone()), + ); + }); + } + + copy_link(url, img_resp); + }, + ); + } + }) + .response + }) + .inner + }); + + if show_popup { + let current_image = current_image + .as_ref() + .expect("the image was actually clicked"); + let image = current_image.clone().0; + let cache_type = current_image.clone().1; + + Window::new("image_popup") + .title_bar(false) + .fixed_size(ui.ctx().screen_rect().size()) + .fixed_pos(ui.ctx().screen_rect().min) + .frame(egui::Frame::NONE) + .show(ui.ctx(), |ui| { + let screen_rect = ui.ctx().screen_rect(); + + // escape + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(carousel_id.with("show_popup"), false); + }); + } + + // background + ui.painter() + .rect_filled(screen_rect, 0.0, Color32::from_black_alpha(230)); + + // zoom init + let zoom_id = carousel_id.with("zoom_level"); + let mut zoom = ui + .ctx() + .memory(|mem| mem.data.get_temp(zoom_id).unwrap_or(1.0_f32)); + + // pan init + let pan_id = carousel_id.with("pan_offset"); + let mut pan_offset = ui + .ctx() + .memory(|mem| mem.data.get_temp(pan_id).unwrap_or(egui::Vec2::ZERO)); + + // zoom & scroll + if ui.input(|i| i.pointer.hover_pos()).is_some() { + let scroll_delta = ui.input(|i| i.smooth_scroll_delta); + if scroll_delta.y != 0.0 { + let zoom_factor = if scroll_delta.y > 0.0 { 1.05 } else { 0.95 }; + zoom *= zoom_factor; + zoom = zoom.clamp(0.1, 5.0); + + if zoom <= 1.0 { + pan_offset = egui::Vec2::ZERO; + } + + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(zoom_id, zoom); + mem.data.insert_temp(pan_id, pan_offset); + }); + } + } + + ui.centered_and_justified(|ui| { + render_images( + ui, + img_cache, + &image, + ImageType::Content, + cache_type.clone(), + |ui| { + ui.allocate_space(egui::vec2(spinsz, spinsz)); + }, + |ui, _| { + ui.allocate_space(egui::vec2(spinsz, spinsz)); + }, + |ui, url, renderable_media, gifs| { + let texture = handle_repaint( + ui, + retrieve_latest_texture(&image, gifs, renderable_media), + ); + + let texture_size = texture.size_vec2(); + let screen_size = screen_rect.size(); + let scale = (screen_size.x / texture_size.x) + .min(screen_size.y / texture_size.y) + .min(1.0); + let scaled_size = texture_size * scale * zoom; + + let visible_width = scaled_size.x.min(screen_size.x); + let visible_height = scaled_size.y.min(screen_size.y); + + let max_pan_x = ((scaled_size.x - visible_width) / 2.0).max(0.0); + let max_pan_y = ((scaled_size.y - visible_height) / 2.0).max(0.0); + + if max_pan_x > 0.0 { + pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x); + } else { + pan_offset.x = 0.0; + } + + if max_pan_y > 0.0 { + pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y); + } else { + pan_offset.y = 0.0; + } + + let (rect, response) = ui.allocate_exact_size( + egui::vec2(visible_width, visible_height), + egui::Sense::click_and_drag(), + ); + + let uv_min = egui::pos2( + 0.5 - (visible_width / scaled_size.x) / 2.0 + + pan_offset.x / scaled_size.x, + 0.5 - (visible_height / scaled_size.y) / 2.0 + + pan_offset.y / scaled_size.y, + ); + + let uv_max = egui::pos2( + uv_min.x + visible_width / scaled_size.x, + uv_min.y + visible_height / scaled_size.y, + ); + + let uv = egui::Rect::from_min_max(uv_min, uv_max); + + ui.painter() + .image(texture.id(), rect, uv, egui::Color32::WHITE); + let img_rect = ui.allocate_rect(rect, Sense::click()); + + if img_rect.clicked() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(carousel_id.with("show_popup"), true); + }); + } else if img_rect.clicked_elsewhere() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(carousel_id.with("show_popup"), false); + }); + } + + // Handle dragging for pan + if response.dragged() { + let delta = response.drag_delta(); + + pan_offset.x -= delta.x; + pan_offset.y -= delta.y; + + if max_pan_x > 0.0 { + pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x); + } else { + pan_offset.x = 0.0; + } + + if max_pan_y > 0.0 { + pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y); + } else { + pan_offset.y = 0.0; + } + + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(pan_id, pan_offset); + }); + } + + // reset zoom on double-click + if response.double_clicked() { + pan_offset = egui::Vec2::ZERO; + zoom = 1.0; + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(pan_id, pan_offset); + mem.data.insert_temp(zoom_id, zoom); + }); + } + + copy_link(url, response); + }, + ); + }); + }); + } +} + +fn copy_link(url: &str, img_resp: Response) { + img_resp.context_menu(|ui| { + if ui.button("Copy Link").clicked() { + ui.ctx().copy_text(url.to_owned()); + ui.close_menu(); + } + }); +} diff --git a/crates/notedeck_ui/src/note/context.rs b/crates/notedeck_ui/src/note/context.rs @@ -0,0 +1,160 @@ +use egui::{Rect, Vec2}; +use nostrdb::NoteKey; +use notedeck::{BroadcastContext, NoteContextSelection}; + +pub struct NoteContextButton { + put_at: Option<Rect>, + note_key: NoteKey, +} + +impl egui::Widget for NoteContextButton { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let r = if let Some(r) = self.put_at { + r + } else { + let mut place = ui.available_rect_before_wrap(); + let size = Self::max_width(); + place.set_width(size); + place.set_height(size); + place + }; + + Self::show(ui, self.note_key, r) + } +} + +impl NoteContextButton { + pub fn new(note_key: NoteKey) -> Self { + let put_at: Option<Rect> = None; + NoteContextButton { note_key, put_at } + } + + pub fn place_at(mut self, rect: Rect) -> Self { + self.put_at = Some(rect); + self + } + + pub fn max_width() -> f32 { + Self::max_radius() * 3.0 + Self::max_distance_between_circles() * 2.0 + } + + pub fn size() -> Vec2 { + let width = Self::max_width(); + egui::vec2(width, width) + } + + fn max_radius() -> f32 { + 4.0 + } + + fn min_radius() -> f32 { + 2.0 + } + + fn max_distance_between_circles() -> f32 { + 2.0 + } + + fn expansion_multiple() -> f32 { + 2.0 + } + + fn min_distance_between_circles() -> f32 { + Self::max_distance_between_circles() / Self::expansion_multiple() + } + + #[profiling::function] + pub fn show(ui: &mut egui::Ui, note_key: NoteKey, put_at: Rect) -> egui::Response { + let id = ui.id().with(("more_options_anim", note_key)); + + let min_radius = Self::min_radius(); + let anim_speed = 0.05; + let response = ui.interact(put_at, id, egui::Sense::click()); + + let hovered = response.hovered(); + let animation_progress = ui.ctx().animate_bool_with_time(id, hovered, anim_speed); + + if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + let min_distance = Self::min_distance_between_circles(); + let cur_distance = min_distance + + (Self::max_distance_between_circles() - min_distance) * animation_progress; + + let cur_radius = min_radius + (Self::max_radius() - min_radius) * animation_progress; + + let center = put_at.center(); + let left_circle_center = center - egui::vec2(cur_distance + cur_radius, 0.0); + let right_circle_center = center + egui::vec2(cur_distance + cur_radius, 0.0); + + let translated_radius = (cur_radius - 1.0) / 2.0; + + // This works in both themes + let color = ui.style().visuals.noninteractive().fg_stroke.color; + + // Draw circles + let painter = ui.painter_at(put_at); + painter.circle_filled(left_circle_center, translated_radius, color); + painter.circle_filled(center, translated_radius, color); + painter.circle_filled(right_circle_center, translated_radius, color); + + response + } + + #[profiling::function] + pub fn menu( + ui: &mut egui::Ui, + button_response: egui::Response, + ) -> Option<NoteContextSelection> { + let mut context_selection: Option<NoteContextSelection> = None; + + stationary_arbitrary_menu_button(ui, button_response, |ui| { + ui.set_max_width(200.0); + if ui.button("Copy text").clicked() { + context_selection = Some(NoteContextSelection::CopyText); + ui.close_menu(); + } + if ui.button("Copy user public key").clicked() { + context_selection = Some(NoteContextSelection::CopyPubkey); + ui.close_menu(); + } + if ui.button("Copy note id").clicked() { + context_selection = Some(NoteContextSelection::CopyNoteId); + ui.close_menu(); + } + if ui.button("Copy note json").clicked() { + context_selection = Some(NoteContextSelection::CopyNoteJSON); + ui.close_menu(); + } + if ui.button("Broadcast").clicked() { + context_selection = Some(NoteContextSelection::Broadcast( + BroadcastContext::Everywhere, + )); + ui.close_menu(); + } + if ui.button("Broadcast to local network").clicked() { + context_selection = Some(NoteContextSelection::Broadcast( + BroadcastContext::LocalNetwork, + )); + ui.close_menu(); + } + }); + + context_selection + } +} + +fn stationary_arbitrary_menu_button<R>( + ui: &mut egui::Ui, + button_response: egui::Response, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse<Option<R>> { + let bar_id = ui.id(); + let mut bar_state = egui::menu::BarState::load(ui.ctx(), bar_id); + + let inner = bar_state.bar_menu(&button_response, add_contents); + + bar_state.store(ui.ctx(), bar_id); + egui::InnerResponse::new(inner.map(|r| r.inner), button_response) +} diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -0,0 +1,744 @@ +pub mod contents; +pub mod context; +pub mod options; +pub mod reply_description; + +use crate::{ + profile::name::one_line_display_name_widget, widgets::x_button, ImagePulseTint, ProfilePic, + ProfilePreview, Username, +}; + +pub use contents::{render_note_contents, render_note_preview, NoteContents}; +pub use context::NoteContextButton; +pub use options::NoteOptions; +pub use reply_description::reply_desc; + +use egui::emath::{pos2, Vec2}; +use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; +use enostr::{KeypairUnowned, NoteId, Pubkey}; +use nostrdb::{Ndb, Note, NoteKey, Transaction}; +use notedeck::{ + name::get_display_name, + note::{NoteAction, NoteContext, ZapAction}, + AnyZapState, CachedNote, ContextSelection, NoteCache, NoteZapTarget, NoteZapTargetOwned, + NotedeckTextStyle, ZapTarget, Zaps, +}; + +pub struct NoteView<'a, 'd> { + note_context: &'a mut NoteContext<'d>, + cur_acc: &'a Option<KeypairUnowned<'a>>, + parent: Option<NoteKey>, + note: &'a nostrdb::Note<'a>, + flags: NoteOptions, +} + +pub struct NoteResponse { + pub response: egui::Response, + pub action: Option<NoteAction>, +} + +impl NoteResponse { + pub fn new(response: egui::Response) -> Self { + Self { + response, + action: None, + } + } + + pub fn with_action(mut self, action: Option<NoteAction>) -> Self { + self.action = action; + self + } +} + +/* +impl View for NoteView<'_, '_> { + fn ui(&mut self, ui: &mut egui::Ui) { + self.show(ui); + } +} +*/ + +impl<'a, 'd> NoteView<'a, 'd> { + pub fn new( + note_context: &'a mut NoteContext<'d>, + cur_acc: &'a Option<KeypairUnowned<'a>>, + note: &'a nostrdb::Note<'a>, + mut flags: NoteOptions, + ) -> Self { + flags.set_actionbar(true); + flags.set_note_previews(true); + + let parent: Option<NoteKey> = None; + Self { + note_context, + cur_acc, + parent, + note, + flags, + } + } + + pub fn textmode(mut self, enable: bool) -> Self { + self.options_mut().set_textmode(enable); + self + } + + pub fn actionbar(mut self, enable: bool) -> Self { + self.options_mut().set_actionbar(enable); + self + } + + pub fn small_pfp(mut self, enable: bool) -> Self { + self.options_mut().set_small_pfp(enable); + self + } + + pub fn medium_pfp(mut self, enable: bool) -> Self { + self.options_mut().set_medium_pfp(enable); + self + } + + pub fn note_previews(mut self, enable: bool) -> Self { + self.options_mut().set_note_previews(enable); + self + } + + pub fn selectable_text(mut self, enable: bool) -> Self { + self.options_mut().set_selectable_text(enable); + self + } + + pub fn wide(mut self, enable: bool) -> Self { + self.options_mut().set_wide(enable); + self + } + + pub fn options_button(mut self, enable: bool) -> Self { + self.options_mut().set_options_button(enable); + self + } + + pub fn options(&self) -> NoteOptions { + self.flags + } + + pub fn options_mut(&mut self) -> &mut NoteOptions { + &mut self.flags + } + + pub fn parent(mut self, parent: NoteKey) -> Self { + self.parent = Some(parent); + self + } + + pub fn is_preview(mut self, is_preview: bool) -> Self { + self.options_mut().set_is_preview(is_preview); + self + } + + fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { + let note_key = self.note.key().expect("todo: implement non-db notes"); + let txn = self.note.txn().expect("todo: implement non-db notes"); + + ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(txn, self.note.pubkey()); + + //ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + + let cached_note = self + .note_context + .note_cache + .cached_note_or_insert_mut(note_key, self.note); + + let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); + ui.allocate_rect(rect, Sense::hover()); + ui.put(rect, |ui: &mut egui::Ui| { + render_reltime(ui, cached_note, false).response + }); + let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); + ui.allocate_rect(rect, Sense::hover()); + ui.put(rect, |ui: &mut egui::Ui| { + ui.add( + Username::new(profile.as_ref().ok(), self.note.pubkey()) + .abbreviated(6) + .pk_colored(true), + ) + }); + + ui.add(&mut NoteContents::new( + self.note_context, + self.cur_acc, + txn, + self.note, + self.flags, + )); + //}); + }) + .response + } + + pub fn expand_size() -> i8 { + 5 + } + + fn pfp( + &mut self, + note_key: NoteKey, + profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, + ui: &mut egui::Ui, + ) -> egui::Response { + if !self.options().has_wide() { + ui.spacing_mut().item_spacing.x = 16.0; + } else { + ui.spacing_mut().item_spacing.x = 4.0; + } + + let pfp_size = self.options().pfp_size(); + + let sense = Sense::click(); + match profile + .as_ref() + .ok() + .and_then(|p| p.record().profile()?.picture()) + { + // these have different lifetimes and types, + // so the calls must be separate + Some(pic) => { + let anim_speed = 0.05; + let profile_key = profile.as_ref().unwrap().record().note_key(); + let note_key = note_key.as_u64(); + + let (rect, size, resp) = crate::anim::hover_expand( + ui, + egui::Id::new((profile_key, note_key)), + pfp_size as f32, + NoteView::expand_size() as f32, + anim_speed, + ); + + ui.put( + rect, + ProfilePic::new(self.note_context.img_cache, pic).size(size), + ) + .on_hover_ui_at_pointer(|ui| { + ui.set_max_width(300.0); + ui.add(ProfilePreview::new( + profile.as_ref().unwrap(), + self.note_context.img_cache, + )); + }); + + if resp.hovered() || resp.clicked() { + crate::show_pointer(ui); + } + + resp + } + + None => { + // This has to match the expand size from the above case to + // prevent bounciness + let size = (pfp_size + NoteView::expand_size()) as f32; + let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); + + ui.put( + rect, + ProfilePic::new(self.note_context.img_cache, notedeck::profile::no_pfp_url()) + .size(pfp_size as f32), + ) + .interact(sense) + } + } + } + + pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { + if self.options().has_textmode() { + NoteResponse::new(self.textmode_ui(ui)) + } else { + let txn = self.note.txn().expect("txn"); + if let Some(note_to_repost) = get_reposted_note(self.note_context.ndb, txn, self.note) { + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(txn, self.note.pubkey()); + + let style = NotedeckTextStyle::Small; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.add_space(2.0); + ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); + }); + ui.add_space(6.0); + let resp = ui.add(one_line_display_name_widget( + ui.visuals(), + get_display_name(profile.as_ref().ok()), + style, + )); + if let Ok(rec) = &profile { + resp.on_hover_ui_at_pointer(|ui| { + ui.set_max_width(300.0); + ui.add(ProfilePreview::new(rec, self.note_context.img_cache)); + }); + } + let color = ui.style().visuals.noninteractive().fg_stroke.color; + ui.add_space(4.0); + ui.label( + RichText::new("Reposted") + .color(color) + .text_style(style.text_style()), + ); + }); + NoteView::new(self.note_context, self.cur_acc, &note_to_repost, self.flags).show(ui) + } else { + self.show_standard(ui) + } + } + } + + #[profiling::function] + fn note_header( + ui: &mut egui::Ui, + note_cache: &mut NoteCache, + note: &Note, + profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, + ) { + let note_key = note.key().unwrap(); + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.add(Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); + + let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); + render_reltime(ui, cached_note, true); + }); + } + + #[profiling::function] + fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { + let note_key = self.note.key().expect("todo: support non-db notes"); + let txn = self.note.txn().expect("todo: support non-db notes"); + + let mut note_action: Option<NoteAction> = None; + + let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent); + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(txn, self.note.pubkey()); + let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id); + + // wide design + let response = if self.options().has_wide() { + ui.vertical(|ui| { + ui.horizontal(|ui| { + if self.pfp(note_key, &profile, ui).clicked() { + note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey()))); + }; + + let size = ui.available_size(); + ui.vertical(|ui| { + ui.add_sized( + [size.x, self.options().pfp_size() as f32], + |ui: &mut egui::Ui| { + ui.horizontal_centered(|ui| { + NoteView::note_header( + ui, + self.note_context.note_cache, + self.note, + &profile, + ); + }) + .response + }, + ); + + let note_reply = self + .note_context + .note_cache + .cached_note_or_insert_mut(note_key, self.note) + .reply + .borrow(self.note.tags()); + + if note_reply.reply().is_some() { + let action = ui + .horizontal(|ui| { + reply_desc( + ui, + self.cur_acc, + txn, + &note_reply, + self.note_context, + self.flags, + ) + }) + .inner; + + if action.is_some() { + note_action = action; + } + } + }); + }); + + let mut contents = + NoteContents::new(self.note_context, self.cur_acc, txn, self.note, self.flags); + + ui.add(&mut contents); + + if let Some(action) = contents.action() { + note_action = Some(action.clone()); + } + + if self.options().has_actionbar() { + if let Some(action) = render_note_actionbar( + ui, + self.note_context.zaps, + self.cur_acc.as_ref(), + self.note.id(), + self.note.pubkey(), + note_key, + ) + .inner + { + note_action = Some(action); + } + } + }) + .response + } else { + // main design + ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { + if self.pfp(note_key, &profile, ui).clicked() { + note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey()))); + }; + + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + NoteView::note_header(ui, self.note_context.note_cache, self.note, &profile); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + + let note_reply = self + .note_context + .note_cache + .cached_note_or_insert_mut(note_key, self.note) + .reply + .borrow(self.note.tags()); + + if note_reply.reply().is_some() { + let action = reply_desc( + ui, + self.cur_acc, + txn, + &note_reply, + self.note_context, + self.flags, + ); + + if action.is_some() { + note_action = action; + } + } + }); + + let mut contents = NoteContents::new( + self.note_context, + self.cur_acc, + txn, + self.note, + self.flags, + ); + ui.add(&mut contents); + + if let Some(action) = contents.action() { + note_action = Some(action.clone()); + } + + if self.options().has_actionbar() { + if let Some(action) = render_note_actionbar( + ui, + self.note_context.zaps, + self.cur_acc.as_ref(), + self.note.id(), + self.note.pubkey(), + note_key, + ) + .inner + { + note_action = Some(action); + } + } + }); + }) + .response + }; + + if self.options().has_options_button() { + let context_pos = { + let size = NoteContextButton::max_width(); + let top_right = response.rect.right_top(); + let min = Pos2::new(top_right.x - size, top_right.y); + Rect::from_min_size(min, egui::vec2(size, size)) + }; + + let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); + if let Some(action) = NoteContextButton::menu(ui, resp.clone()) { + note_action = Some(NoteAction::Context(ContextSelection { note_key, action })); + } + } + + let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) { + Some(NoteAction::Note(NoteId::new(*self.note.id()))) + } else { + note_action + }; + + NoteResponse::new(response).with_action(note_action) + } +} + +fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> { + let new_note_id: &[u8; 32] = if note.kind() == 6 { + let mut res = None; + for tag in note.tags().iter() { + if tag.count() == 0 { + continue; + } + + if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) { + if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) { + res = Some(note_id); + break; + } + } + } + res? + } else { + return None; + }; + + let note = ndb.get_note_by_id(txn, new_note_id).ok(); + note.filter(|note| note.kind() == 1) +} + +fn note_hitbox_id( + note_key: NoteKey, + note_options: NoteOptions, + parent: Option<NoteKey>, +) -> egui::Id { + Id::new(("note_size", note_key, note_options, parent)) +} + +fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> { + ui.ctx() + .data_mut(|d| d.get_persisted(hitbox_id)) + .map(|note_size: Vec2| { + // The hitbox should extend the entire width of the + // container. The hitbox height was cached last layout. + let container_rect = ui.max_rect(); + let rect = Rect { + min: pos2(container_rect.min.x, container_rect.min.y), + max: pos2(container_rect.max.x, container_rect.min.y + note_size.y), + }; + + let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click()); + + response + .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox")); + + response + }) +} + +fn note_hitbox_clicked( + ui: &mut egui::Ui, + hitbox_id: egui::Id, + note_rect: &Rect, + maybe_hitbox: Option<Response>, +) -> bool { + // Stash the dimensions of the note content so we can render the + // hitbox in the next frame + ui.ctx().data_mut(|d| { + d.insert_persisted(hitbox_id, note_rect.size()); + }); + + // If there was an hitbox and it was clicked open the thread + match maybe_hitbox { + Some(hitbox) => hitbox.clicked(), + _ => false, + } +} + +#[profiling::function] +fn render_note_actionbar( + ui: &mut egui::Ui, + zaps: &Zaps, + cur_acc: Option<&KeypairUnowned>, + note_id: &[u8; 32], + note_pubkey: &[u8; 32], + note_key: NoteKey, +) -> egui::InnerResponse<Option<NoteAction>> { + ui.horizontal(|ui| 's: { + let reply_resp = reply_button(ui, note_key); + let quote_resp = quote_repost_button(ui, note_key); + + let zap_target = ZapTarget::Note(NoteZapTarget { + note_id, + zap_recipient: note_pubkey, + }); + + let zap_state = cur_acc.map_or_else( + || Ok(AnyZapState::None), + |kp| zaps.any_zap_state_for(kp.pubkey.bytes(), zap_target), + ); + let zap_resp = cur_acc + .filter(|k| k.secret_key.is_some()) + .map(|_| match &zap_state { + Ok(any_zap_state) => ui.add(zap_button(any_zap_state.clone(), note_id)), + Err(zapping_error) => { + let (rect, _) = + ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click()); + ui.add(x_button(rect)) + .on_hover_text(format!("{zapping_error}")) + } + }); + + let to_noteid = |id: &[u8; 32]| NoteId::new(*id); + + if reply_resp.clicked() { + break 's Some(NoteAction::Reply(to_noteid(note_id))); + } + + if quote_resp.clicked() { + break 's Some(NoteAction::Quote(to_noteid(note_id))); + } + + let Some(zap_resp) = zap_resp else { + break 's None; + }; + + if !zap_resp.clicked() { + break 's None; + } + + let target = NoteZapTargetOwned { + note_id: to_noteid(note_id), + zap_recipient: Pubkey::new(*note_pubkey), + }; + + if zap_state.is_err() { + break 's Some(NoteAction::Zap(ZapAction::ClearError(target))); + } + + Some(NoteAction::Zap(ZapAction::Send(target))) + }) +} + +fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) { + let color = ui.style().visuals.noninteractive().fg_stroke.color; + ui.add(Label::new(RichText::new(s).size(10.0).color(color))); +} + +#[profiling::function] +fn render_reltime( + ui: &mut egui::Ui, + note_cache: &mut CachedNote, + before: bool, +) -> egui::InnerResponse<()> { + ui.horizontal(|ui| { + if before { + secondary_label(ui, "⋅"); + } + + secondary_label(ui, note_cache.reltime_str_mut()); + + if !before { + secondary_label(ui, "⋅"); + } + }) +} + +fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { + let img_data = if ui.style().visuals.dark_mode { + egui::include_image!("../../../../assets/icons/reply.png") + } else { + egui::include_image!("../../../../assets/icons/reply-dark.png") + }; + + let (rect, size, resp) = + crate::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); + + // align rect to note contents + let expand_size = 5.0; // from hover_expand_small + let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); + + let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size)); + + resp.union(put_resp) +} + +fn repost_icon(dark_mode: bool) -> egui::Image<'static> { + let img_data = if dark_mode { + egui::include_image!("../../../../assets/icons/repost_icon_4x.png") + } else { + egui::include_image!("../../../../assets/icons/repost_light_4x.png") + }; + egui::Image::new(img_data) +} + +fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { + let size = 14.0; + let expand_size = 5.0; + let anim_speed = 0.05; + let id = ui.id().with(("repost_anim", note_key)); + + let (rect, size, resp) = crate::anim::hover_expand(ui, id, size, expand_size, anim_speed); + + let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0)); + + let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)); + + resp.union(put_resp) +} + +fn zap_button(state: AnyZapState, noteid: &[u8; 32]) -> impl egui::Widget + use<'_> { + move |ui: &mut egui::Ui| -> egui::Response { + let img_data = egui::include_image!("../../../../assets/icons/zap_4x.png"); + + let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap")); + + let mut img = egui::Image::new(img_data).max_width(size); + let id = ui.id().with(("pulse", noteid)); + let ctx = ui.ctx().clone(); + + match state { + AnyZapState::None => { + if !ui.visuals().dark_mode { + img = img.tint(egui::Color32::BLACK); + } + } + AnyZapState::Pending => { + let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 }; + img = ImagePulseTint::new(&ctx, id, img, &[0xFF, 0xB7, 0x57], alpha_min, 255) + .with_speed(0.35) + .animate(); + } + AnyZapState::LocalOnly => { + img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57)); + } + AnyZapState::Confirmed => {} + } + + // align rect to note contents + let expand_size = 5.0; // from hover_expand_small + let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); + + let put_resp = ui.put(rect, img); + + resp.union(put_resp) + } +} diff --git a/crates/notedeck_ui/src/note/options.rs b/crates/notedeck_ui/src/note/options.rs @@ -0,0 +1,79 @@ +use crate::ProfilePic; +use bitflags::bitflags; + +bitflags! { + // Attributes can be applied to flags types + #[repr(transparent)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct NoteOptions: u64 { + const actionbar = 0b0000000000000001; + const note_previews = 0b0000000000000010; + const small_pfp = 0b0000000000000100; + const medium_pfp = 0b0000000000001000; + const wide = 0b0000000000010000; + const selectable_text = 0b0000000000100000; + const textmode = 0b0000000001000000; + const options_button = 0b0000000010000000; + const hide_media = 0b0000000100000000; + + /// Scramble text so that its not distracting during development + const scramble_text = 0b0000001000000000; + + /// Whether the current note is a preview + const is_preview = 0b0000010000000000; + } +} + +impl Default for NoteOptions { + fn default() -> NoteOptions { + NoteOptions::options_button | NoteOptions::note_previews | NoteOptions::actionbar + } +} + +macro_rules! create_bit_methods { + ($fn_name:ident, $has_name:ident, $option:ident) => { + #[inline] + pub fn $fn_name(&mut self, enable: bool) { + if enable { + *self |= NoteOptions::$option; + } else { + *self &= !NoteOptions::$option; + } + } + + #[inline] + pub fn $has_name(self) -> bool { + (self & NoteOptions::$option) == NoteOptions::$option + } + }; +} + +impl NoteOptions { + create_bit_methods!(set_small_pfp, has_small_pfp, small_pfp); + create_bit_methods!(set_medium_pfp, has_medium_pfp, medium_pfp); + create_bit_methods!(set_note_previews, has_note_previews, note_previews); + create_bit_methods!(set_selectable_text, has_selectable_text, selectable_text); + create_bit_methods!(set_textmode, has_textmode, textmode); + create_bit_methods!(set_actionbar, has_actionbar, actionbar); + create_bit_methods!(set_wide, has_wide, wide); + create_bit_methods!(set_options_button, has_options_button, options_button); + create_bit_methods!(set_hide_media, has_hide_media, hide_media); + create_bit_methods!(set_scramble_text, has_scramble_text, scramble_text); + create_bit_methods!(set_is_preview, has_is_preview, is_preview); + + pub fn new(is_universe_timeline: bool) -> Self { + let mut options = NoteOptions::default(); + options.set_hide_media(is_universe_timeline); + options + } + + pub fn pfp_size(&self) -> i8 { + if self.has_small_pfp() { + ProfilePic::small_size() + } else if self.has_medium_pfp() { + ProfilePic::medium_size() + } else { + ProfilePic::default_size() + } + } +} diff --git a/crates/notedeck_ui/src/note/reply_description.rs b/crates/notedeck_ui/src/note/reply_description.rs @@ -0,0 +1,180 @@ +use egui::{Label, RichText, Sense}; +use nostrdb::{Note, NoteReply, Transaction}; + +use super::NoteOptions; +use crate::{note::NoteView, Mention}; +use enostr::KeypairUnowned; +use notedeck::{NoteAction, NoteContext}; + +#[must_use = "Please handle the resulting note action"] +#[profiling::function] +pub fn reply_desc( + ui: &mut egui::Ui, + cur_acc: &Option<KeypairUnowned>, + txn: &Transaction, + note_reply: &NoteReply, + note_context: &mut NoteContext, + note_options: NoteOptions, +) -> Option<NoteAction> { + let mut note_action: Option<NoteAction> = None; + let size = 10.0; + let selectable = false; + let visuals = ui.visuals(); + let color = visuals.noninteractive().fg_stroke.color; + let link_color = visuals.hyperlink_color; + + // note link renderer helper + let note_link = + |ui: &mut egui::Ui, note_context: &mut NoteContext, text: &str, note: &Note<'_>| { + let r = ui.add( + Label::new(RichText::new(text).size(size).color(link_color)) + .sense(Sense::click()) + .selectable(selectable), + ); + + if r.clicked() { + // TODO: jump to note + } + + if r.hovered() { + r.on_hover_ui_at_pointer(|ui| { + ui.set_max_width(400.0); + NoteView::new(note_context, cur_acc, note, note_options) + .actionbar(false) + .wide(true) + .show(ui); + }); + } + }; + + ui.add(Label::new(RichText::new("replying to").size(size).color(color)).selectable(selectable)); + + let reply = note_reply.reply()?; + + let reply_note = if let Ok(reply_note) = note_context.ndb.get_note_by_id(txn, reply.id) { + reply_note + } else { + ui.add(Label::new(RichText::new("a note").size(size).color(color)).selectable(selectable)); + return None; + }; + + if note_reply.is_reply_to_root() { + // We're replying to the root, let's show this + let action = Mention::new( + note_context.ndb, + note_context.img_cache, + txn, + reply_note.pubkey(), + ) + .size(size) + .selectable(selectable) + .show(ui) + .inner; + + if action.is_some() { + note_action = action; + } + + ui.add(Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable)); + + note_link(ui, note_context, "thread", &reply_note); + } else if let Some(root) = note_reply.root() { + // replying to another post in a thread, not the root + + if let Ok(root_note) = note_context.ndb.get_note_by_id(txn, root.id) { + if root_note.pubkey() == reply_note.pubkey() { + // simply "replying to bob's note" when replying to bob in his thread + let action = Mention::new( + note_context.ndb, + note_context.img_cache, + txn, + reply_note.pubkey(), + ) + .size(size) + .selectable(selectable) + .show(ui) + .inner; + + if action.is_some() { + note_action = action; + } + + ui.add( + Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), + ); + + note_link(ui, note_context, "note", &reply_note); + } else { + // replying to bob in alice's thread + + let action = Mention::new( + note_context.ndb, + note_context.img_cache, + txn, + reply_note.pubkey(), + ) + .size(size) + .selectable(selectable) + .show(ui) + .inner; + + if action.is_some() { + note_action = action; + } + + ui.add( + Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), + ); + + note_link(ui, note_context, "note", &reply_note); + + ui.add( + Label::new(RichText::new("in").size(size).color(color)).selectable(selectable), + ); + + let action = Mention::new( + note_context.ndb, + note_context.img_cache, + txn, + root_note.pubkey(), + ) + .size(size) + .selectable(selectable) + .show(ui) + .inner; + + if action.is_some() { + note_action = action; + } + + ui.add( + Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), + ); + + note_link(ui, note_context, "thread", &root_note); + } + } else { + let action = Mention::new( + note_context.ndb, + note_context.img_cache, + txn, + reply_note.pubkey(), + ) + .size(size) + .selectable(selectable) + .show(ui) + .inner; + + if action.is_some() { + note_action = action; + } + + ui.add( + Label::new(RichText::new("in someone's thread").size(size).color(color)) + .selectable(selectable), + ); + } + } + + note_action +} diff --git a/crates/notedeck_ui/src/profile/mod.rs b/crates/notedeck_ui/src/profile/mod.rs @@ -1,17 +1,116 @@ use nostrdb::ProfileRecord; +pub mod name; pub mod picture; +pub mod preview; pub use picture::ProfilePic; +pub use preview::ProfilePreview; -pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { - unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture()))) +use egui::{load::TexturePoll, Label, RichText}; +use notedeck::{NostrName, NotedeckTextStyle}; + +pub fn display_name_widget<'a>( + name: &'a NostrName<'a>, + add_placeholder_space: bool, +) -> impl egui::Widget + 'a { + move |ui: &mut egui::Ui| -> egui::Response { + let disp_resp = name.display_name.map(|disp_name| { + ui.add( + Label::new( + RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()), + ) + .selectable(false), + ) + }); + + let (username_resp, nip05_resp) = ui + .horizontal(|ui| { + let username_resp = name.username.map(|username| { + ui.add( + Label::new( + RichText::new(format!("@{}", username)) + .size(16.0) + .color(crate::colors::MID_GRAY), + ) + .selectable(false), + ) + }); + + let nip05_resp = name.nip05.map(|nip05| { + ui.image(egui::include_image!( + "../../../../assets/icons/verified_4x.png" + )); + ui.add(Label::new( + RichText::new(nip05).size(16.0).color(crate::colors::TEAL), + )) + }); + + (username_resp, nip05_resp) + }) + .inner; + + let resp = match (disp_resp, username_resp, nip05_resp) { + (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05), + (Some(disp), Some(username), None) => disp.union(username), + (Some(disp), None, None) => disp, + (None, Some(username), Some(nip05)) => username.union(nip05), + (None, Some(username), None) => username, + _ => ui.add(Label::new(RichText::new(name.name()))), + }; + + if add_placeholder_space { + ui.add_space(16.0); + } + + resp + } +} + +pub fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b +where + 'b: 'a, +{ + move |ui: &mut egui::Ui| { + if let Some(about) = profile.record().profile().and_then(|p| p.about()) { + let resp = ui.label(about); + ui.add_space(8.0); + resp + } else { + // need any Response so we dont need an Option + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) + } + } } -pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str { - if let Some(url) = maybe_url { - url - } else { - ProfilePic::no_pfp_url() +pub fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> { + // TODO: cache banner + if !banner_url.is_empty() { + let texture_load_res = + egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size()); + if let Ok(texture_poll) = texture_load_res { + match texture_poll { + TexturePoll::Pending { .. } => {} + TexturePoll::Ready { texture, .. } => return Some(texture), + } + } } + + None +} + +pub fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response { + ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| { + banner_url + .and_then(|url| banner_texture(ui, url)) + .map(|texture| { + crate::images::aspect_fill( + ui, + egui::Sense::hover(), + texture.id, + texture.size.x / texture.size.y, + ) + }) + .unwrap_or_else(|| ui.label("")) + }) } diff --git a/crates/notedeck_ui/src/profile/name.rs b/crates/notedeck_ui/src/profile/name.rs @@ -0,0 +1,19 @@ +use egui::RichText; +use notedeck::{NostrName, NotedeckTextStyle}; + +pub fn one_line_display_name_widget<'a>( + visuals: &egui::Visuals, + display_name: NostrName<'a>, + style: NotedeckTextStyle, +) -> impl egui::Widget + 'a { + let text_style = style.text_style(); + let color = visuals.noninteractive().fg_stroke.color; + + move |ui: &mut egui::Ui| -> egui::Response { + ui.label( + RichText::new(display_name.name()) + .text_style(text_style) + .color(color), + ) + } +} diff --git a/crates/notedeck_ui/src/profile/picture.rs b/crates/notedeck_ui/src/profile/picture.rs @@ -59,11 +59,6 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { } #[inline] - pub fn no_pfp_url() -> &'static str { - "https://damus.io/img/no-profile.svg" - } - - #[inline] pub fn size(mut self, size: f32) -> Self { self.size = size; self diff --git a/crates/notedeck_ui/src/profile/preview.rs b/crates/notedeck_ui/src/profile/preview.rs @@ -0,0 +1,113 @@ +use crate::ProfilePic; +use egui::{Frame, Label, RichText}; +use egui_extras::Size; +use nostrdb::ProfileRecord; + +use notedeck::{name::get_display_name, profile::get_profile_url, Images, NotedeckTextStyle}; + +use super::{about_section_widget, banner, display_name_widget}; + +pub struct ProfilePreview<'a, 'cache> { + profile: &'a ProfileRecord<'a>, + cache: &'cache mut Images, + banner_height: Size, +} + +impl<'a, 'cache> ProfilePreview<'a, 'cache> { + pub fn new(profile: &'a ProfileRecord<'a>, cache: &'cache mut Images) -> Self { + let banner_height = Size::exact(80.0); + ProfilePreview { + profile, + cache, + banner_height, + } + } + + pub fn banner_height(&mut self, size: Size) { + self.banner_height = size; + } + + fn body(self, ui: &mut egui::Ui) { + let padding = 12.0; + crate::padding(padding, ui, |ui| { + let mut pfp_rect = ui.available_rect_before_wrap(); + let size = 80.0; + pfp_rect.set_width(size); + pfp_rect.set_height(size); + let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); + + ui.put( + pfp_rect, + ProfilePic::new(self.cache, get_profile_url(Some(self.profile))) + .size(size) + .border(ProfilePic::border_stroke(ui)), + ); + ui.add(display_name_widget( + &get_display_name(Some(self.profile)), + false, + )); + ui.add(about_section_widget(self.profile)); + }); + } +} + +impl egui::Widget for ProfilePreview<'_, '_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + ui.vertical(|ui| { + banner( + ui, + self.profile.record().profile().and_then(|p| p.banner()), + 80.0, + ); + + self.body(ui); + }) + .response + } +} + +pub struct SimpleProfilePreview<'a, 'cache> { + profile: Option<&'a ProfileRecord<'a>>, + cache: &'cache mut Images, + is_nsec: bool, +} + +impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> { + pub fn new( + profile: Option<&'a ProfileRecord<'a>>, + cache: &'cache mut Images, + is_nsec: bool, + ) -> Self { + SimpleProfilePreview { + profile, + cache, + is_nsec, + } + } +} + +impl egui::Widget for SimpleProfilePreview<'_, '_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + Frame::new() + .show(ui, |ui| { + ui.add(ProfilePic::new(self.cache, get_profile_url(self.profile)).size(48.0)); + ui.vertical(|ui| { + ui.add(display_name_widget(&get_display_name(self.profile), true)); + if !self.is_nsec { + ui.add( + Label::new( + RichText::new("Read only") + .size(notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Tiny, + )) + .color(ui.visuals().warn_fg_color), + ) + .selectable(false), + ); + } + }); + }) + .response + } +} diff --git a/crates/notedeck_ui/src/username.rs b/crates/notedeck_ui/src/username.rs @@ -0,0 +1,94 @@ +use egui::{Color32, RichText, Widget}; +use nostrdb::ProfileRecord; +use notedeck::fonts::NamedFontFamily; + +pub struct Username<'a> { + profile: Option<&'a ProfileRecord<'a>>, + pk: &'a [u8; 32], + pk_colored: bool, + abbrev: usize, +} + +impl<'a> Username<'a> { + pub fn pk_colored(mut self, pk_colored: bool) -> Self { + self.pk_colored = pk_colored; + self + } + + pub fn abbreviated(mut self, amount: usize) -> Self { + self.abbrev = amount; + self + } + + pub fn new(profile: Option<&'a ProfileRecord>, pk: &'a [u8; 32]) -> Self { + let pk_colored = false; + let abbrev: usize = 1000; + Username { + profile, + pk, + pk_colored, + abbrev, + } + } +} + +impl Widget for Username<'_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + + let color = if self.pk_colored { + Some(pk_color(self.pk)) + } else { + None + }; + + if let Some(profile) = self.profile { + if let Some(prof) = profile.record().profile() { + if prof.display_name().is_some() && prof.display_name().unwrap() != "" { + ui_abbreviate_name(ui, prof.display_name().unwrap(), self.abbrev, color); + } else if let Some(name) = prof.name() { + ui_abbreviate_name(ui, name, self.abbrev, color); + } + } + } else { + let mut txt = RichText::new("nostrich").family(NamedFontFamily::Medium.as_family()); + if let Some(col) = color { + txt = txt.color(col) + } + ui.label(txt); + } + }) + .response + } +} + +fn colored_name(name: &str, color: Option<Color32>) -> RichText { + let mut txt = RichText::new(name).family(NamedFontFamily::Medium.as_family()); + + if let Some(color) = color { + txt = txt.color(color); + } + + txt +} + +fn ui_abbreviate_name(ui: &mut egui::Ui, name: &str, len: usize, color: Option<Color32>) { + let should_abbrev = name.len() > len; + let name = if should_abbrev { + let closest = notedeck::abbrev::floor_char_boundary(name, len); + &name[..closest] + } else { + name + }; + + ui.label(colored_name(name, color)); + + if should_abbrev { + ui.label(colored_name("..", color)); + } +} + +fn pk_color(pk: &[u8; 32]) -> Color32 { + Color32::from_rgb(pk[8], pk[10], pk[12]) +} diff --git a/crates/notedeck_ui/src/widgets.rs b/crates/notedeck_ui/src/widgets.rs @@ -0,0 +1,35 @@ +use crate::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}; +use egui::{emath::GuiRounding, Pos2, Stroke}; + +pub fn x_button(rect: egui::Rect) -> impl egui::Widget { + move |ui: &mut egui::Ui| -> egui::Response { + let max_width = rect.width(); + let helper = AnimationHelper::new_from_rect(ui, "user_search_close", rect); + + let fill_color = ui.visuals().text_color(); + + let radius = max_width / (2.0 * ICON_EXPANSION_MULTIPLE); + + let painter = ui.painter(); + let ppp = ui.ctx().pixels_per_point(); + let nw_edge = helper + .scale_pos_from_center(Pos2::new(-radius, radius)) + .round_to_pixel_center(ppp); + let se_edge = helper + .scale_pos_from_center(Pos2::new(radius, -radius)) + .round_to_pixel_center(ppp); + let sw_edge = helper + .scale_pos_from_center(Pos2::new(-radius, -radius)) + .round_to_pixel_center(ppp); + let ne_edge = helper + .scale_pos_from_center(Pos2::new(radius, radius)) + .round_to_pixel_center(ppp); + + let line_width = helper.scale_1d_pos(2.0); + + painter.line_segment([nw_edge, se_edge], Stroke::new(line_width, fill_color)); + painter.line_segment([ne_edge, sw_edge], Stroke::new(line_width, fill_color)); + + helper.take_animation_response() + } +}