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>
This commit is contained in:
William Casarin
2025-04-17 11:01:45 -07:00
parent e4bae57619
commit 8af80d7d10
53 changed files with 1436 additions and 1607 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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}"),
},
}
}
}

View File

@@ -1,10 +1,26 @@
use crate::notecache::NoteCache;
use enostr::NoteId;
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,

View File

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

View File

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

View File

@@ -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;
};
NoteAction::Zap(zap_action) => 's: {
let Some(cur_acc) = accounts.get_selected_account_mut() else {
break 's 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
let sender = cur_acc.key.pubkey;
timeline_cache.open(ndb, note_cache, txn, pool, &kind)
}
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),
}
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)
}
None
}
NoteAction::Quote(note_id) => {
router.route_to(Route::quote(*note_id));
None
}
NoteAction::Context(context) => {
match ndb.get_note_by_key(txn, context.note_key) {
Err(err) => tracing::error!("{err}"),
Ok(note) => {
context.action.process(ui, &note, pool);
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,
);
}
}
None
ZapAction::ClearError(target) => clear_zap_error(&sender, zaps, target),
}
None
}
NoteAction::Context(context) => {
match ndb.get_note_by_key(txn, context.note_key) {
Err(err) => tracing::error!("{err}"),
Ok(note) => {
context.action.process(ui, &note, pool);
}
}
None
}
}
}
/// 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,765 +1,7 @@
pub mod contents;
pub mod context;
pub mod options;
pub mod post;
pub mod quote_repost;
pub mod reply;
pub mod reply_description;
pub use contents::NoteContents;
use contents::NoteContext;
pub use context::{NoteContextButton, NoteContextSelection};
use notedeck_ui::ImagePulseTint;
pub use options::NoteOptions;
pub use post::{NewPostAction, PostAction, PostResponse, PostType, PostView};
pub use quote_repost::QuoteRepostView;
pub use reply::PostReplyView;
pub use reply_description::reply_desc;
use crate::{
actionbar::{ContextSelection, NoteAction, ZapAction},
profile::get_display_name,
timeline::{ThreadSelection, TimelineKind},
ui::{self, View},
};
use egui::emath::{pos2, Vec2};
use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense};
use enostr::{KeypairUnowned, NoteId, Pubkey};
use nostrdb::{Ndb, Note, NoteKey, Transaction};
use notedeck::{
AnyZapState, CachedNote, NoteCache, NoteZapTarget, NoteZapTargetOwned, NotedeckTextStyle,
ZapTarget, Zaps,
};
use super::{profile::preview::one_line_display_name_widget, widgets::x_button};
pub struct NoteView<'a, 'd> {
note_context: &'a mut NoteContext<'d>,
cur_acc: &'a Option<KeypairUnowned<'a>>,
parent: Option<NoteKey>,
note: &'a nostrdb::Note<'a>,
flags: NoteOptions,
}
pub struct NoteResponse {
pub response: egui::Response,
pub action: Option<NoteAction>,
}
impl NoteResponse {
pub fn new(response: egui::Response) -> Self {
Self {
response,
action: None,
}
}
pub fn with_action(mut self, action: Option<NoteAction>) -> Self {
self.action = action;
self
}
}
impl View for NoteView<'_, '_> {
fn ui(&mut self, ui: &mut egui::Ui) {
self.show(ui);
}
}
impl<'a, 'd> NoteView<'a, 'd> {
pub fn new(
note_context: &'a mut NoteContext<'d>,
cur_acc: &'a Option<KeypairUnowned<'a>>,
note: &'a nostrdb::Note<'a>,
mut flags: NoteOptions,
) -> Self {
flags.set_actionbar(true);
flags.set_note_previews(true);
let parent: Option<NoteKey> = None;
Self {
note_context,
cur_acc,
parent,
note,
flags,
}
}
pub fn textmode(mut self, enable: bool) -> Self {
self.options_mut().set_textmode(enable);
self
}
pub fn actionbar(mut self, enable: bool) -> Self {
self.options_mut().set_actionbar(enable);
self
}
pub fn small_pfp(mut self, enable: bool) -> Self {
self.options_mut().set_small_pfp(enable);
self
}
pub fn medium_pfp(mut self, enable: bool) -> Self {
self.options_mut().set_medium_pfp(enable);
self
}
pub fn note_previews(mut self, enable: bool) -> Self {
self.options_mut().set_note_previews(enable);
self
}
pub fn selectable_text(mut self, enable: bool) -> Self {
self.options_mut().set_selectable_text(enable);
self
}
pub fn wide(mut self, enable: bool) -> Self {
self.options_mut().set_wide(enable);
self
}
pub fn options_button(mut self, enable: bool) -> Self {
self.options_mut().set_options_button(enable);
self
}
pub fn options(&self) -> NoteOptions {
self.flags
}
pub fn options_mut(&mut self) -> &mut NoteOptions {
&mut self.flags
}
pub fn parent(mut self, parent: NoteKey) -> Self {
self.parent = Some(parent);
self
}
pub fn is_preview(mut self, is_preview: bool) -> Self {
self.options_mut().set_is_preview(is_preview);
self
}
fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
let note_key = self.note.key().expect("todo: implement non-db notes");
let txn = self.note.txn().expect("todo: implement non-db notes");
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
//ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let cached_note = self
.note_context
.note_cache
.cached_note_or_insert_mut(note_key, self.note);
let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0));
ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| {
render_reltime(ui, cached_note, false).response
});
let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0));
ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| {
ui.add(
ui::Username::new(profile.as_ref().ok(), self.note.pubkey())
.abbreviated(6)
.pk_colored(true),
)
});
ui.add(&mut NoteContents::new(
self.note_context,
self.cur_acc,
txn,
self.note,
self.flags,
));
//});
})
.response
}
pub fn expand_size() -> i8 {
5
}
fn pfp(
&mut self,
note_key: NoteKey,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
ui: &mut egui::Ui,
) -> egui::Response {
if !self.options().has_wide() {
ui.spacing_mut().item_spacing.x = 16.0;
} else {
ui.spacing_mut().item_spacing.x = 4.0;
}
let pfp_size = self.options().pfp_size();
let sense = Sense::click();
match profile
.as_ref()
.ok()
.and_then(|p| p.record().profile()?.picture())
{
// these have different lifetimes and types,
// so the calls must be separate
Some(pic) => {
let anim_speed = 0.05;
let profile_key = profile.as_ref().unwrap().record().note_key();
let note_key = note_key.as_u64();
let (rect, size, resp) = ui::anim::hover_expand(
ui,
egui::Id::new((profile_key, note_key)),
pfp_size as f32,
ui::NoteView::expand_size() as f32,
anim_speed,
);
ui.put(
rect,
ui::ProfilePic::new(self.note_context.img_cache, pic).size(size),
)
.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ui::ProfilePreview::new(
profile.as_ref().unwrap(),
self.note_context.img_cache,
));
});
if resp.hovered() || resp.clicked() {
ui::show_pointer(ui);
}
resp
}
None => {
// This has to match the expand size from the above case to
// prevent bounciness
let size = (pfp_size + ui::NoteView::expand_size()) as f32;
let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense);
ui.put(
rect,
ui::ProfilePic::new(self.note_context.img_cache, ui::ProfilePic::no_pfp_url())
.size(pfp_size as f32),
)
.interact(sense)
}
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
if self.options().has_textmode() {
NoteResponse::new(self.textmode_ui(ui))
} else {
let txn = self.note.txn().expect("txn");
if let Some(note_to_repost) = get_reposted_note(self.note_context.ndb, txn, self.note) {
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
let style = NotedeckTextStyle::Small;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.add_space(2.0);
ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode));
});
ui.add_space(6.0);
let resp = ui.add(one_line_display_name_widget(
ui.visuals(),
get_display_name(profile.as_ref().ok()),
style,
));
if let Ok(rec) = &profile {
resp.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ui::ProfilePreview::new(rec, self.note_context.img_cache));
});
}
let color = ui.style().visuals.noninteractive().fg_stroke.color;
ui.add_space(4.0);
ui.label(
RichText::new("Reposted")
.color(color)
.text_style(style.text_style()),
);
});
NoteView::new(self.note_context, self.cur_acc, &note_to_repost, self.flags).show(ui)
} else {
self.show_standard(ui)
}
}
}
#[profiling::function]
fn note_header(
ui: &mut egui::Ui,
note_cache: &mut NoteCache,
note: &Note,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
) {
let note_key = note.key().unwrap();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20));
let cached_note = note_cache.cached_note_or_insert_mut(note_key, note);
render_reltime(ui, cached_note, true);
});
}
#[profiling::function]
fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse {
let note_key = self.note.key().expect("todo: support non-db notes");
let txn = self.note.txn().expect("todo: support non-db notes");
let mut note_action: Option<NoteAction> = None;
let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent);
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id);
// wide design
let response = if self.options().has_wide() {
ui.vertical(|ui| {
ui.horizontal(|ui| {
if self.pfp(note_key, &profile, ui).clicked() {
note_action = Some(NoteAction::OpenTimeline(TimelineKind::profile(
Pubkey::new(*self.note.pubkey()),
)));
};
let size = ui.available_size();
ui.vertical(|ui| {
ui.add_sized(
[size.x, self.options().pfp_size() as f32],
|ui: &mut egui::Ui| {
ui.horizontal_centered(|ui| {
NoteView::note_header(
ui,
self.note_context.note_cache,
self.note,
&profile,
);
})
.response
},
);
let note_reply = self
.note_context
.note_cache
.cached_note_or_insert_mut(note_key, self.note)
.reply
.borrow(self.note.tags());
if note_reply.reply().is_some() {
let action = ui
.horizontal(|ui| {
reply_desc(
ui,
self.cur_acc,
txn,
&note_reply,
self.note_context,
self.flags,
)
})
.inner;
if action.is_some() {
note_action = action;
}
}
});
});
let mut contents =
NoteContents::new(self.note_context, self.cur_acc, txn, self.note, self.flags);
ui.add(&mut contents);
if let Some(action) = contents.action() {
note_action = Some(action.clone());
}
if self.options().has_actionbar() {
if let Some(action) = render_note_actionbar(
ui,
self.note_context.zaps,
self.cur_acc.as_ref(),
self.note.id(),
self.note.pubkey(),
note_key,
)
.inner
{
note_action = Some(action);
}
}
})
.response
} else {
// main design
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
if self.pfp(note_key, &profile, ui).clicked() {
note_action = Some(NoteAction::OpenTimeline(TimelineKind::Profile(
Pubkey::new(*self.note.pubkey()),
)));
};
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
NoteView::note_header(ui, self.note_context.note_cache, self.note, &profile);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let note_reply = self
.note_context
.note_cache
.cached_note_or_insert_mut(note_key, self.note)
.reply
.borrow(self.note.tags());
if note_reply.reply().is_some() {
let action = reply_desc(
ui,
self.cur_acc,
txn,
&note_reply,
self.note_context,
self.flags,
);
if action.is_some() {
note_action = action;
}
}
});
let mut contents = NoteContents::new(
self.note_context,
self.cur_acc,
txn,
self.note,
self.flags,
);
ui.add(&mut contents);
if let Some(action) = contents.action() {
note_action = Some(action.clone());
}
if self.options().has_actionbar() {
if let Some(action) = render_note_actionbar(
ui,
self.note_context.zaps,
self.cur_acc.as_ref(),
self.note.id(),
self.note.pubkey(),
note_key,
)
.inner
{
note_action = Some(action);
}
}
});
})
.response
};
if self.options().has_options_button() {
let context_pos = {
let size = NoteContextButton::max_width();
let top_right = response.rect.right_top();
let min = Pos2::new(top_right.x - size, top_right.y);
Rect::from_min_size(min, egui::vec2(size, size))
};
let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos));
if let Some(action) = NoteContextButton::menu(ui, resp.clone()) {
note_action = Some(NoteAction::Context(ContextSelection { note_key, action }));
}
}
let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) {
if let Ok(selection) = ThreadSelection::from_note_id(
self.note_context.ndb,
self.note_context.note_cache,
self.note.txn().unwrap(),
NoteId::new(*self.note.id()),
) {
Some(NoteAction::OpenTimeline(TimelineKind::Thread(selection)))
} else {
None
}
} else {
note_action
};
NoteResponse::new(response).with_action(note_action)
}
}
fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
let new_note_id: &[u8; 32] = if note.kind() == 6 {
let mut res = None;
for tag in note.tags().iter() {
if tag.count() == 0 {
continue;
}
if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) {
if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) {
res = Some(note_id);
break;
}
}
}
res?
} else {
return None;
};
let note = ndb.get_note_by_id(txn, new_note_id).ok();
note.filter(|note| note.kind() == 1)
}
fn note_hitbox_id(
note_key: NoteKey,
note_options: NoteOptions,
parent: Option<NoteKey>,
) -> egui::Id {
Id::new(("note_size", note_key, note_options, parent))
}
fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> {
ui.ctx()
.data_mut(|d| d.get_persisted(hitbox_id))
.map(|note_size: Vec2| {
// The hitbox should extend the entire width of the
// container. The hitbox height was cached last layout.
let container_rect = ui.max_rect();
let rect = Rect {
min: pos2(container_rect.min.x, container_rect.min.y),
max: pos2(container_rect.max.x, container_rect.min.y + note_size.y),
};
let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click());
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox"));
response
})
}
fn note_hitbox_clicked(
ui: &mut egui::Ui,
hitbox_id: egui::Id,
note_rect: &Rect,
maybe_hitbox: Option<Response>,
) -> bool {
// Stash the dimensions of the note content so we can render the
// hitbox in the next frame
ui.ctx().data_mut(|d| {
d.insert_persisted(hitbox_id, note_rect.size());
});
// If there was an hitbox and it was clicked open the thread
match maybe_hitbox {
Some(hitbox) => hitbox.clicked(),
_ => false,
}
}
#[profiling::function]
fn render_note_actionbar(
ui: &mut egui::Ui,
zaps: &Zaps,
cur_acc: Option<&KeypairUnowned>,
note_id: &[u8; 32],
note_pubkey: &[u8; 32],
note_key: NoteKey,
) -> egui::InnerResponse<Option<NoteAction>> {
ui.horizontal(|ui| 's: {
let reply_resp = reply_button(ui, note_key);
let quote_resp = quote_repost_button(ui, note_key);
let zap_target = ZapTarget::Note(NoteZapTarget {
note_id,
zap_recipient: note_pubkey,
});
let zap_state = cur_acc.map_or_else(
|| Ok(AnyZapState::None),
|kp| zaps.any_zap_state_for(kp.pubkey.bytes(), zap_target),
);
let zap_resp = cur_acc
.filter(|k| k.secret_key.is_some())
.map(|_| match &zap_state {
Ok(any_zap_state) => ui.add(zap_button(any_zap_state.clone(), note_id)),
Err(zapping_error) => {
let (rect, _) =
ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
ui.add(x_button(rect))
.on_hover_text(format!("{zapping_error}"))
}
});
let to_noteid = |id: &[u8; 32]| NoteId::new(*id);
if reply_resp.clicked() {
break 's Some(NoteAction::Reply(to_noteid(note_id)));
}
if quote_resp.clicked() {
break 's Some(NoteAction::Quote(to_noteid(note_id)));
}
let Some(zap_resp) = zap_resp else {
break 's None;
};
if !zap_resp.clicked() {
break 's None;
}
let target = NoteZapTargetOwned {
note_id: to_noteid(note_id),
zap_recipient: Pubkey::new(*note_pubkey),
};
if zap_state.is_err() {
break 's Some(NoteAction::Zap(ZapAction::ClearError(target)));
}
Some(NoteAction::Zap(ZapAction::Send(target)))
})
}
fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) {
let color = ui.style().visuals.noninteractive().fg_stroke.color;
ui.add(Label::new(RichText::new(s).size(10.0).color(color)));
}
#[profiling::function]
fn render_reltime(
ui: &mut egui::Ui,
note_cache: &mut CachedNote,
before: bool,
) -> egui::InnerResponse<()> {
ui.horizontal(|ui| {
if before {
secondary_label(ui, "");
}
secondary_label(ui, note_cache.reltime_str_mut());
if !before {
secondary_label(ui, "");
}
})
}
fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
let img_data = if ui.style().visuals.dark_mode {
egui::include_image!("../../../../../assets/icons/reply.png")
} else {
egui::include_image!("../../../../../assets/icons/reply-dark.png")
};
let (rect, size, resp) =
ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key)));
// align rect to note contents
let expand_size = 5.0; // from hover_expand_small
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size));
resp.union(put_resp)
}
fn repost_icon(dark_mode: bool) -> egui::Image<'static> {
let img_data = if dark_mode {
egui::include_image!("../../../../../assets/icons/repost_icon_4x.png")
} else {
egui::include_image!("../../../../../assets/icons/repost_light_4x.png")
};
egui::Image::new(img_data)
}
fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
let size = 14.0;
let expand_size = 5.0;
let anim_speed = 0.05;
let id = ui.id().with(("repost_anim", note_key));
let (rect, size, resp) = ui::anim::hover_expand(ui, id, size, expand_size, anim_speed);
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0));
let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size));
resp.union(put_resp)
}
fn zap_button(state: AnyZapState, noteid: &[u8; 32]) -> impl egui::Widget + use<'_> {
move |ui: &mut egui::Ui| -> egui::Response {
let img_data = egui::include_image!("../../../../../assets/icons/zap_4x.png");
let (rect, size, resp) = ui::anim::hover_expand_small(ui, ui.id().with("zap"));
let mut img = egui::Image::new(img_data).max_width(size);
let id = ui.id().with(("pulse", noteid));
let ctx = ui.ctx().clone();
match state {
AnyZapState::None => {
if !ui.visuals().dark_mode {
img = img.tint(egui::Color32::BLACK);
}
}
AnyZapState::Pending => {
let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 };
img = ImagePulseTint::new(&ctx, id, img, &[0xFF, 0xB7, 0x57], alpha_min, 255)
.with_speed(0.35)
.animate();
}
AnyZapState::LocalOnly => {
img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57));
}
AnyZapState::Confirmed => {}
}
// align rect to note contents
let expand_size = 5.0; // from hover_expand_small
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
let put_resp = ui.put(rect, img);
resp.union(put_resp)
}
}

View File

@@ -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),
);
}

View File

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

View File

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

View File

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

View File

@@ -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(""))
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,17 @@
use std::f32::consts::PI;
use crate::actionbar::NoteAction;
use crate::timeline::TimelineTab;
use crate::{
timeline::{TimelineCache, TimelineKind, ViewFilter},
ui,
};
use egui::containers::scroll_area::ScrollBarVisibility;
use egui::{vec2, Direction, Layout, Pos2, Stroke};
use egui_tabs::TabColor;
use enostr::KeypairUnowned;
use nostrdb::Transaction;
use notedeck::note::root_note_id_from_selected_id;
use notedeck::MuteFun;
use std::f32::consts::PI;
use tracing::{error, warn};
use super::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE};
use super::note::contents::NoteContext;
use super::note::NoteOptions;
use crate::timeline::{TimelineCache, TimelineKind, TimelineTab, ViewFilter};
use notedeck::{note::root_note_id_from_selected_id, MuteFun, NoteAction, NoteContext};
use notedeck_ui::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
show_pointer, NoteOptions, NoteView,
};
pub struct TimelineView<'a, 'd> {
timeline_id: &'a TimelineKind,
@@ -134,7 +128,7 @@ fn timeline_ui(
if goto_top_resp.clicked() {
scroll_area = scroll_area.vertical_scroll_offset(0.0);
} else if goto_top_resp.hovered() {
ui::show_pointer(ui);
show_pointer(ui);
}
}
@@ -271,7 +265,7 @@ pub fn tabs_ui(ui: &mut egui::Ui, selected: usize, views: &[TimelineTab]) -> usi
});
//ui.add_space(0.5);
ui::hline(ui);
notedeck_ui::hline(ui);
let sel = tab_res.selected().unwrap_or_default();
@@ -395,8 +389,8 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
};
if !muted {
ui::padding(8.0, ui, |ui| {
let resp = ui::NoteView::new(
notedeck_ui::padding(8.0, ui, |ui| {
let resp = NoteView::new(
self.note_context,
self.cur_acc,
&note,
@@ -409,7 +403,7 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
}
});
ui::hline(ui);
notedeck_ui::hline(ui);
}
1

View File

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

View File

@@ -14,3 +14,5 @@ profiling = { workspace = true }
tokio = { workspace = true }
notedeck = { workspace = true }
image = { workspace = true }
bitflags = { workspace = true }
enostr = { workspace = true }

View File

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

View File

@@ -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,
);

View File

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

View File

@@ -1,9 +1,8 @@
use crate::ui;
use crate::{actionbar::NoteAction, profile::get_display_name, timeline::TimelineKind};
use crate::{show_pointer, ProfilePreview};
use egui::Sense;
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
use notedeck::Images;
use notedeck::{name::get_display_name, Images, NoteAction};
pub struct Mention<'a> {
ndb: &'a Ndb,
@@ -87,12 +86,10 @@ fn mention_ui(
);
let note_action = if resp.clicked() {
ui::show_pointer(ui);
Some(NoteAction::OpenTimeline(TimelineKind::profile(
Pubkey::new(*pk),
)))
show_pointer(ui);
Some(NoteAction::Profile(Pubkey::new(*pk)))
} else if resp.hovered() {
ui::show_pointer(ui);
show_pointer(ui);
None
} else {
None
@@ -101,7 +98,7 @@ fn mention_ui(
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));
ui.add(ProfilePreview::new(rec, img_cache));
});
}

View File

@@ -1,29 +1,15 @@
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::{
use crate::{
gif::{handle_repaint, retrieve_latest_texture},
images::render_images,
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, 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,
}
use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteContext};
pub struct NoteContents<'a, 'd> {
note_context: &'a mut NoteContext<'d>,
@@ -41,7 +27,7 @@ impl<'a, 'd> NoteContents<'a, 'd> {
cur_acc: &'a Option<KeypairUnowned<'a>>,
txn: &'a Transaction,
note: &'a Note,
options: ui::note::NoteOptions,
options: NoteOptions,
) -> Self {
NoteContents {
note_context,
@@ -119,7 +105,7 @@ pub fn render_note_preview(
ui.visuals().noninteractive().bg_stroke.color,
))
.show(ui, |ui| {
ui::NoteView::new(note_context, cur_acc, &note, note_options)
NoteView::new(note_context, cur_acc, &note, note_options)
.actionbar(false)
.small_pfp(true)
.wide(true)
@@ -134,7 +120,7 @@ pub fn render_note_preview(
#[allow(clippy::too_many_arguments)]
#[profiling::function]
fn render_note_contents(
pub fn render_note_contents(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
cur_acc: &Option<KeypairUnowned>,
@@ -170,7 +156,7 @@ fn render_note_contents(
match block.blocktype() {
BlockType::MentionBech32 => match block.as_mention().unwrap() {
Mention::Profile(profile) => {
let act = ui::Mention::new(
let act = crate::Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
@@ -184,7 +170,7 @@ fn render_note_contents(
}
Mention::Pubkey(npub) => {
let act = ui::Mention::new(
let act = crate::Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
@@ -214,11 +200,9 @@ fn render_note_contents(
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(),
)));
note_action = Some(NoteAction::Hashtag(block.as_str().to_string()));
} else if resp.hovered() {
ui::show_pointer(ui);
crate::show_pointer(ui);
}
}

View File

@@ -1,59 +1,6 @@
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}"),
},
}
}
}
use nostrdb::NoteKey;
use notedeck::{BroadcastContext, NoteContextSelection};
pub struct NoteContextButton {
put_at: Option<Rect>,

View File

@@ -0,0 +1,744 @@
pub mod contents;
pub mod context;
pub mod options;
pub mod reply_description;
use crate::{
profile::name::one_line_display_name_widget, widgets::x_button, ImagePulseTint, ProfilePic,
ProfilePreview, Username,
};
pub use contents::{render_note_contents, render_note_preview, NoteContents};
pub use context::NoteContextButton;
pub use options::NoteOptions;
pub use reply_description::reply_desc;
use egui::emath::{pos2, Vec2};
use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense};
use enostr::{KeypairUnowned, NoteId, Pubkey};
use nostrdb::{Ndb, Note, NoteKey, Transaction};
use notedeck::{
name::get_display_name,
note::{NoteAction, NoteContext, ZapAction},
AnyZapState, CachedNote, ContextSelection, NoteCache, NoteZapTarget, NoteZapTargetOwned,
NotedeckTextStyle, ZapTarget, Zaps,
};
pub struct NoteView<'a, 'd> {
note_context: &'a mut NoteContext<'d>,
cur_acc: &'a Option<KeypairUnowned<'a>>,
parent: Option<NoteKey>,
note: &'a nostrdb::Note<'a>,
flags: NoteOptions,
}
pub struct NoteResponse {
pub response: egui::Response,
pub action: Option<NoteAction>,
}
impl NoteResponse {
pub fn new(response: egui::Response) -> Self {
Self {
response,
action: None,
}
}
pub fn with_action(mut self, action: Option<NoteAction>) -> Self {
self.action = action;
self
}
}
/*
impl View for NoteView<'_, '_> {
fn ui(&mut self, ui: &mut egui::Ui) {
self.show(ui);
}
}
*/
impl<'a, 'd> NoteView<'a, 'd> {
pub fn new(
note_context: &'a mut NoteContext<'d>,
cur_acc: &'a Option<KeypairUnowned<'a>>,
note: &'a nostrdb::Note<'a>,
mut flags: NoteOptions,
) -> Self {
flags.set_actionbar(true);
flags.set_note_previews(true);
let parent: Option<NoteKey> = None;
Self {
note_context,
cur_acc,
parent,
note,
flags,
}
}
pub fn textmode(mut self, enable: bool) -> Self {
self.options_mut().set_textmode(enable);
self
}
pub fn actionbar(mut self, enable: bool) -> Self {
self.options_mut().set_actionbar(enable);
self
}
pub fn small_pfp(mut self, enable: bool) -> Self {
self.options_mut().set_small_pfp(enable);
self
}
pub fn medium_pfp(mut self, enable: bool) -> Self {
self.options_mut().set_medium_pfp(enable);
self
}
pub fn note_previews(mut self, enable: bool) -> Self {
self.options_mut().set_note_previews(enable);
self
}
pub fn selectable_text(mut self, enable: bool) -> Self {
self.options_mut().set_selectable_text(enable);
self
}
pub fn wide(mut self, enable: bool) -> Self {
self.options_mut().set_wide(enable);
self
}
pub fn options_button(mut self, enable: bool) -> Self {
self.options_mut().set_options_button(enable);
self
}
pub fn options(&self) -> NoteOptions {
self.flags
}
pub fn options_mut(&mut self) -> &mut NoteOptions {
&mut self.flags
}
pub fn parent(mut self, parent: NoteKey) -> Self {
self.parent = Some(parent);
self
}
pub fn is_preview(mut self, is_preview: bool) -> Self {
self.options_mut().set_is_preview(is_preview);
self
}
fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
let note_key = self.note.key().expect("todo: implement non-db notes");
let txn = self.note.txn().expect("todo: implement non-db notes");
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
//ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let cached_note = self
.note_context
.note_cache
.cached_note_or_insert_mut(note_key, self.note);
let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0));
ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| {
render_reltime(ui, cached_note, false).response
});
let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0));
ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| {
ui.add(
Username::new(profile.as_ref().ok(), self.note.pubkey())
.abbreviated(6)
.pk_colored(true),
)
});
ui.add(&mut NoteContents::new(
self.note_context,
self.cur_acc,
txn,
self.note,
self.flags,
));
//});
})
.response
}
pub fn expand_size() -> i8 {
5
}
fn pfp(
&mut self,
note_key: NoteKey,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
ui: &mut egui::Ui,
) -> egui::Response {
if !self.options().has_wide() {
ui.spacing_mut().item_spacing.x = 16.0;
} else {
ui.spacing_mut().item_spacing.x = 4.0;
}
let pfp_size = self.options().pfp_size();
let sense = Sense::click();
match profile
.as_ref()
.ok()
.and_then(|p| p.record().profile()?.picture())
{
// these have different lifetimes and types,
// so the calls must be separate
Some(pic) => {
let anim_speed = 0.05;
let profile_key = profile.as_ref().unwrap().record().note_key();
let note_key = note_key.as_u64();
let (rect, size, resp) = crate::anim::hover_expand(
ui,
egui::Id::new((profile_key, note_key)),
pfp_size as f32,
NoteView::expand_size() as f32,
anim_speed,
);
ui.put(
rect,
ProfilePic::new(self.note_context.img_cache, pic).size(size),
)
.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ProfilePreview::new(
profile.as_ref().unwrap(),
self.note_context.img_cache,
));
});
if resp.hovered() || resp.clicked() {
crate::show_pointer(ui);
}
resp
}
None => {
// This has to match the expand size from the above case to
// prevent bounciness
let size = (pfp_size + NoteView::expand_size()) as f32;
let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense);
ui.put(
rect,
ProfilePic::new(self.note_context.img_cache, notedeck::profile::no_pfp_url())
.size(pfp_size as f32),
)
.interact(sense)
}
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
if self.options().has_textmode() {
NoteResponse::new(self.textmode_ui(ui))
} else {
let txn = self.note.txn().expect("txn");
if let Some(note_to_repost) = get_reposted_note(self.note_context.ndb, txn, self.note) {
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
let style = NotedeckTextStyle::Small;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.add_space(2.0);
ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode));
});
ui.add_space(6.0);
let resp = ui.add(one_line_display_name_widget(
ui.visuals(),
get_display_name(profile.as_ref().ok()),
style,
));
if let Ok(rec) = &profile {
resp.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ProfilePreview::new(rec, self.note_context.img_cache));
});
}
let color = ui.style().visuals.noninteractive().fg_stroke.color;
ui.add_space(4.0);
ui.label(
RichText::new("Reposted")
.color(color)
.text_style(style.text_style()),
);
});
NoteView::new(self.note_context, self.cur_acc, &note_to_repost, self.flags).show(ui)
} else {
self.show_standard(ui)
}
}
}
#[profiling::function]
fn note_header(
ui: &mut egui::Ui,
note_cache: &mut NoteCache,
note: &Note,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
) {
let note_key = note.key().unwrap();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.add(Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20));
let cached_note = note_cache.cached_note_or_insert_mut(note_key, note);
render_reltime(ui, cached_note, true);
});
}
#[profiling::function]
fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse {
let note_key = self.note.key().expect("todo: support non-db notes");
let txn = self.note.txn().expect("todo: support non-db notes");
let mut note_action: Option<NoteAction> = None;
let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent);
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id);
// wide design
let response = if self.options().has_wide() {
ui.vertical(|ui| {
ui.horizontal(|ui| {
if self.pfp(note_key, &profile, ui).clicked() {
note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey())));
};
let size = ui.available_size();
ui.vertical(|ui| {
ui.add_sized(
[size.x, self.options().pfp_size() as f32],
|ui: &mut egui::Ui| {
ui.horizontal_centered(|ui| {
NoteView::note_header(
ui,
self.note_context.note_cache,
self.note,
&profile,
);
})
.response
},
);
let note_reply = self
.note_context
.note_cache
.cached_note_or_insert_mut(note_key, self.note)
.reply
.borrow(self.note.tags());
if note_reply.reply().is_some() {
let action = ui
.horizontal(|ui| {
reply_desc(
ui,
self.cur_acc,
txn,
&note_reply,
self.note_context,
self.flags,
)
})
.inner;
if action.is_some() {
note_action = action;
}
}
});
});
let mut contents =
NoteContents::new(self.note_context, self.cur_acc, txn, self.note, self.flags);
ui.add(&mut contents);
if let Some(action) = contents.action() {
note_action = Some(action.clone());
}
if self.options().has_actionbar() {
if let Some(action) = render_note_actionbar(
ui,
self.note_context.zaps,
self.cur_acc.as_ref(),
self.note.id(),
self.note.pubkey(),
note_key,
)
.inner
{
note_action = Some(action);
}
}
})
.response
} else {
// main design
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
if self.pfp(note_key, &profile, ui).clicked() {
note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey())));
};
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
NoteView::note_header(ui, self.note_context.note_cache, self.note, &profile);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let note_reply = self
.note_context
.note_cache
.cached_note_or_insert_mut(note_key, self.note)
.reply
.borrow(self.note.tags());
if note_reply.reply().is_some() {
let action = reply_desc(
ui,
self.cur_acc,
txn,
&note_reply,
self.note_context,
self.flags,
);
if action.is_some() {
note_action = action;
}
}
});
let mut contents = NoteContents::new(
self.note_context,
self.cur_acc,
txn,
self.note,
self.flags,
);
ui.add(&mut contents);
if let Some(action) = contents.action() {
note_action = Some(action.clone());
}
if self.options().has_actionbar() {
if let Some(action) = render_note_actionbar(
ui,
self.note_context.zaps,
self.cur_acc.as_ref(),
self.note.id(),
self.note.pubkey(),
note_key,
)
.inner
{
note_action = Some(action);
}
}
});
})
.response
};
if self.options().has_options_button() {
let context_pos = {
let size = NoteContextButton::max_width();
let top_right = response.rect.right_top();
let min = Pos2::new(top_right.x - size, top_right.y);
Rect::from_min_size(min, egui::vec2(size, size))
};
let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos));
if let Some(action) = NoteContextButton::menu(ui, resp.clone()) {
note_action = Some(NoteAction::Context(ContextSelection { note_key, action }));
}
}
let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) {
Some(NoteAction::Note(NoteId::new(*self.note.id())))
} else {
note_action
};
NoteResponse::new(response).with_action(note_action)
}
}
fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
let new_note_id: &[u8; 32] = if note.kind() == 6 {
let mut res = None;
for tag in note.tags().iter() {
if tag.count() == 0 {
continue;
}
if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) {
if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) {
res = Some(note_id);
break;
}
}
}
res?
} else {
return None;
};
let note = ndb.get_note_by_id(txn, new_note_id).ok();
note.filter(|note| note.kind() == 1)
}
fn note_hitbox_id(
note_key: NoteKey,
note_options: NoteOptions,
parent: Option<NoteKey>,
) -> egui::Id {
Id::new(("note_size", note_key, note_options, parent))
}
fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> {
ui.ctx()
.data_mut(|d| d.get_persisted(hitbox_id))
.map(|note_size: Vec2| {
// The hitbox should extend the entire width of the
// container. The hitbox height was cached last layout.
let container_rect = ui.max_rect();
let rect = Rect {
min: pos2(container_rect.min.x, container_rect.min.y),
max: pos2(container_rect.max.x, container_rect.min.y + note_size.y),
};
let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click());
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox"));
response
})
}
fn note_hitbox_clicked(
ui: &mut egui::Ui,
hitbox_id: egui::Id,
note_rect: &Rect,
maybe_hitbox: Option<Response>,
) -> bool {
// Stash the dimensions of the note content so we can render the
// hitbox in the next frame
ui.ctx().data_mut(|d| {
d.insert_persisted(hitbox_id, note_rect.size());
});
// If there was an hitbox and it was clicked open the thread
match maybe_hitbox {
Some(hitbox) => hitbox.clicked(),
_ => false,
}
}
#[profiling::function]
fn render_note_actionbar(
ui: &mut egui::Ui,
zaps: &Zaps,
cur_acc: Option<&KeypairUnowned>,
note_id: &[u8; 32],
note_pubkey: &[u8; 32],
note_key: NoteKey,
) -> egui::InnerResponse<Option<NoteAction>> {
ui.horizontal(|ui| 's: {
let reply_resp = reply_button(ui, note_key);
let quote_resp = quote_repost_button(ui, note_key);
let zap_target = ZapTarget::Note(NoteZapTarget {
note_id,
zap_recipient: note_pubkey,
});
let zap_state = cur_acc.map_or_else(
|| Ok(AnyZapState::None),
|kp| zaps.any_zap_state_for(kp.pubkey.bytes(), zap_target),
);
let zap_resp = cur_acc
.filter(|k| k.secret_key.is_some())
.map(|_| match &zap_state {
Ok(any_zap_state) => ui.add(zap_button(any_zap_state.clone(), note_id)),
Err(zapping_error) => {
let (rect, _) =
ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
ui.add(x_button(rect))
.on_hover_text(format!("{zapping_error}"))
}
});
let to_noteid = |id: &[u8; 32]| NoteId::new(*id);
if reply_resp.clicked() {
break 's Some(NoteAction::Reply(to_noteid(note_id)));
}
if quote_resp.clicked() {
break 's Some(NoteAction::Quote(to_noteid(note_id)));
}
let Some(zap_resp) = zap_resp else {
break 's None;
};
if !zap_resp.clicked() {
break 's None;
}
let target = NoteZapTargetOwned {
note_id: to_noteid(note_id),
zap_recipient: Pubkey::new(*note_pubkey),
};
if zap_state.is_err() {
break 's Some(NoteAction::Zap(ZapAction::ClearError(target)));
}
Some(NoteAction::Zap(ZapAction::Send(target)))
})
}
fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) {
let color = ui.style().visuals.noninteractive().fg_stroke.color;
ui.add(Label::new(RichText::new(s).size(10.0).color(color)));
}
#[profiling::function]
fn render_reltime(
ui: &mut egui::Ui,
note_cache: &mut CachedNote,
before: bool,
) -> egui::InnerResponse<()> {
ui.horizontal(|ui| {
if before {
secondary_label(ui, "");
}
secondary_label(ui, note_cache.reltime_str_mut());
if !before {
secondary_label(ui, "");
}
})
}
fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
let img_data = if ui.style().visuals.dark_mode {
egui::include_image!("../../../../assets/icons/reply.png")
} else {
egui::include_image!("../../../../assets/icons/reply-dark.png")
};
let (rect, size, resp) =
crate::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key)));
// align rect to note contents
let expand_size = 5.0; // from hover_expand_small
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size));
resp.union(put_resp)
}
fn repost_icon(dark_mode: bool) -> egui::Image<'static> {
let img_data = if dark_mode {
egui::include_image!("../../../../assets/icons/repost_icon_4x.png")
} else {
egui::include_image!("../../../../assets/icons/repost_light_4x.png")
};
egui::Image::new(img_data)
}
fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
let size = 14.0;
let expand_size = 5.0;
let anim_speed = 0.05;
let id = ui.id().with(("repost_anim", note_key));
let (rect, size, resp) = crate::anim::hover_expand(ui, id, size, expand_size, anim_speed);
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0));
let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size));
resp.union(put_resp)
}
fn zap_button(state: AnyZapState, noteid: &[u8; 32]) -> impl egui::Widget + use<'_> {
move |ui: &mut egui::Ui| -> egui::Response {
let img_data = egui::include_image!("../../../../assets/icons/zap_4x.png");
let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap"));
let mut img = egui::Image::new(img_data).max_width(size);
let id = ui.id().with(("pulse", noteid));
let ctx = ui.ctx().clone();
match state {
AnyZapState::None => {
if !ui.visuals().dark_mode {
img = img.tint(egui::Color32::BLACK);
}
}
AnyZapState::Pending => {
let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 };
img = ImagePulseTint::new(&ctx, id, img, &[0xFF, 0xB7, 0x57], alpha_min, 255)
.with_speed(0.35)
.animate();
}
AnyZapState::LocalOnly => {
img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57));
}
AnyZapState::Confirmed => {}
}
// align rect to note contents
let expand_size = 5.0; // from hover_expand_small
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
let put_resp = ui.put(rect, img);
resp.union(put_resp)
}
}

View File

@@ -1,4 +1,4 @@
use crate::ui::ProfilePic;
use crate::ProfilePic;
use bitflags::bitflags;
bitflags! {

View File

@@ -1,12 +1,10 @@
use crate::{
actionbar::NoteAction,
ui::{self},
};
use egui::{Label, RichText, Sense};
use enostr::KeypairUnowned;
use nostrdb::{Note, NoteReply, Transaction};
use super::{contents::NoteContext, NoteOptions};
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]
@@ -41,7 +39,7 @@ pub fn reply_desc(
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)
NoteView::new(note_context, cur_acc, note, note_options)
.actionbar(false)
.wide(true)
.show(ui);
@@ -62,7 +60,7 @@ pub fn reply_desc(
if note_reply.is_reply_to_root() {
// We're replying to the root, let's show this
let action = ui::Mention::new(
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
@@ -86,7 +84,7 @@ pub fn reply_desc(
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(
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
@@ -109,7 +107,7 @@ pub fn reply_desc(
} else {
// replying to bob in alice's thread
let action = ui::Mention::new(
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
@@ -134,7 +132,7 @@ pub fn reply_desc(
Label::new(RichText::new("in").size(size).color(color)).selectable(selectable),
);
let action = ui::Mention::new(
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
@@ -156,7 +154,7 @@ pub fn reply_desc(
note_link(ui, note_context, "thread", &root_note);
}
} else {
let action = ui::Mention::new(
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,

View File

@@ -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 unwrap_profile_url(maybe_url: Option<&str>) -> &str {
if let Some(url) = maybe_url {
url
} else {
ProfilePic::no_pfp_url()
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 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(""))
})
}

View File

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

View File

@@ -58,11 +58,6 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> {
24
}
#[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;

View File

@@ -1,13 +1,11 @@
use crate::ui::ProfilePic;
use crate::NostrName;
use egui::{Frame, Label, RichText, Widget};
use crate::ProfilePic;
use egui::{Frame, Label, RichText};
use egui_extras::Size;
use nostrdb::ProfileRecord;
use notedeck::{Images, NotedeckTextStyle};
use notedeck::{name::get_display_name, profile::get_profile_url, Images, NotedeckTextStyle};
use super::{about_section_widget, banner, display_name_widget, get_display_name};
use notedeck_ui::profile::get_profile_url;
use super::{about_section_widget, banner, display_name_widget};
pub struct ProfilePreview<'a, 'cache> {
profile: &'a ProfileRecord<'a>,
@@ -31,7 +29,7 @@ impl<'a, 'cache> ProfilePreview<'a, 'cache> {
fn body(self, ui: &mut egui::Ui) {
let padding = 12.0;
crate::ui::padding(padding, ui, |ui| {
crate::padding(padding, ui, |ui| {
let mut pfp_rect = ui.available_rect_before_wrap();
let size = 80.0;
pfp_rect.set_width(size);
@@ -113,59 +111,3 @@ impl egui::Widget for SimpleProfilePreview<'_, '_> {
.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),
)
}
}

View File

@@ -76,7 +76,7 @@ fn colored_name(name: &str, color: Option<Color32>) -> RichText {
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);
let closest = notedeck::abbrev::floor_char_boundary(name, len);
&name[..closest]
} else {
name

View File

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