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:
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, ¬e)
- .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, ¬e)
+ .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, ¬e, 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, ¬e, 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, ¬e, 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, ¬e_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,
- ¬e_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,
- ¬e_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,
¬e,
@@ -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, ¬e, 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, ¬e_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,
+ ¬e_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,
+ ¬e_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()
+ }
+}