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

View File

@@ -0,0 +1,542 @@
use crate::{
gif::{handle_repaint, retrieve_latest_texture},
images::{render_images, ImageType},
note::{NoteAction, NoteOptions, NoteResponse, NoteView},
};
use egui::{Button, Color32, Hyperlink, Image, Response, RichText, Sense, Window};
use enostr::KeypairUnowned;
use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction};
use tracing::warn;
use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteContext};
pub struct NoteContents<'a, 'd> {
note_context: &'a mut NoteContext<'d>,
cur_acc: &'a Option<KeypairUnowned<'a>>,
txn: &'a Transaction,
note: &'a Note<'a>,
options: NoteOptions,
action: Option<NoteAction>,
}
impl<'a, 'd> NoteContents<'a, 'd> {
#[allow(clippy::too_many_arguments)]
pub fn new(
note_context: &'a mut NoteContext<'d>,
cur_acc: &'a Option<KeypairUnowned<'a>>,
txn: &'a Transaction,
note: &'a Note,
options: NoteOptions,
) -> Self {
NoteContents {
note_context,
cur_acc,
txn,
note,
options,
action: None,
}
}
pub fn action(&self) -> &Option<NoteAction> {
&self.action
}
}
impl egui::Widget for &mut NoteContents<'_, '_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
let result = render_note_contents(
ui,
self.note_context,
self.cur_acc,
self.txn,
self.note,
self.options,
);
self.action = result.action;
result.response
}
}
/// Render an inline note preview with a border. These are used when
/// notes are references within a note
#[allow(clippy::too_many_arguments)]
#[profiling::function]
pub fn render_note_preview(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
cur_acc: &Option<KeypairUnowned>,
txn: &Transaction,
id: &[u8; 32],
parent: NoteKey,
note_options: NoteOptions,
) -> NoteResponse {
let note = if let Ok(note) = note_context.ndb.get_note_by_id(txn, id) {
// TODO: support other preview kinds
if note.kind() == 1 {
note
} else {
return NoteResponse::new(ui.colored_label(
Color32::RED,
format!("TODO: can't preview kind {}", note.kind()),
));
}
} else {
return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD"));
/*
return ui
.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.colored_label(link_color, "@");
ui.colored_label(link_color, &id_str[4..16]);
})
.response;
*/
};
egui::Frame::new()
.fill(ui.visuals().noninteractive().weak_bg_fill)
.inner_margin(egui::Margin::same(8))
.outer_margin(egui::Margin::symmetric(0, 8))
.corner_radius(egui::CornerRadius::same(10))
.stroke(egui::Stroke::new(
1.0,
ui.visuals().noninteractive().bg_stroke.color,
))
.show(ui, |ui| {
NoteView::new(note_context, cur_acc, &note, note_options)
.actionbar(false)
.small_pfp(true)
.wide(true)
.note_previews(false)
.options_button(true)
.parent(parent)
.is_preview(true)
.show(ui)
})
.inner
}
#[allow(clippy::too_many_arguments)]
#[profiling::function]
pub fn render_note_contents(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
cur_acc: &Option<KeypairUnowned>,
txn: &Transaction,
note: &Note,
options: NoteOptions,
) -> NoteResponse {
let note_key = note.key().expect("todo: implement non-db notes");
let selectable = options.has_selectable_text();
let mut images: Vec<(String, MediaCacheType)> = vec![];
let mut note_action: Option<NoteAction> = None;
let mut inline_note: Option<(&[u8; 32], &str)> = None;
let hide_media = options.has_hide_media();
let link_color = ui.visuals().hyperlink_color;
if !options.has_is_preview() {
// need this for the rect to take the full width of the column
let _ = ui.allocate_at_least(egui::vec2(ui.available_width(), 0.0), egui::Sense::click());
}
let response = ui.horizontal_wrapped(|ui| {
let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) {
blocks
} else {
warn!("missing note content blocks? '{}'", note.content());
ui.weak(note.content());
return;
};
ui.spacing_mut().item_spacing.x = 0.0;
for block in blocks.iter(note) {
match block.blocktype() {
BlockType::MentionBech32 => match block.as_mention().unwrap() {
Mention::Profile(profile) => {
let act = crate::Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
profile.pubkey(),
)
.show(ui)
.inner;
if act.is_some() {
note_action = act;
}
}
Mention::Pubkey(npub) => {
let act = crate::Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
npub.pubkey(),
)
.show(ui)
.inner;
if act.is_some() {
note_action = act;
}
}
Mention::Note(note) if options.has_note_previews() => {
inline_note = Some((note.id(), block.as_str()));
}
Mention::Event(note) if options.has_note_previews() => {
inline_note = Some((note.id(), block.as_str()));
}
_ => {
ui.colored_label(link_color, format!("@{}", &block.as_str()[4..16]));
}
},
BlockType::Hashtag => {
let resp = ui.colored_label(link_color, format!("#{}", block.as_str()));
if resp.clicked() {
note_action = Some(NoteAction::Hashtag(block.as_str().to_string()));
} else if resp.hovered() {
crate::show_pointer(ui);
}
}
BlockType::Url => {
let mut found_supported = || -> bool {
let url = block.as_str();
if let Some(cache_type) =
supported_mime_hosted_at_url(&mut note_context.img_cache.urls, url)
{
images.push((url.to_string(), cache_type));
true
} else {
false
}
};
if hide_media || !found_supported() {
ui.add(Hyperlink::from_label_and_url(
RichText::new(block.as_str()).color(link_color),
block.as_str(),
));
}
}
BlockType::Text => {
if options.has_scramble_text() {
ui.add(egui::Label::new(rot13(block.as_str())).selectable(selectable));
} else {
ui.add(egui::Label::new(block.as_str()).selectable(selectable));
}
}
_ => {
ui.colored_label(link_color, block.as_str());
}
}
}
});
let preview_note_action = if let Some((id, _block_str)) = inline_note {
render_note_preview(ui, note_context, cur_acc, txn, id, note_key, options).action
} else {
None
};
if !images.is_empty() && !options.has_textmode() {
ui.add_space(2.0);
let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note")));
image_carousel(ui, note_context.img_cache, images, carousel_id);
ui.add_space(2.0);
}
let note_action = preview_note_action.or(note_action);
NoteResponse::new(response.response).with_action(note_action)
}
fn rot13(input: &str) -> String {
input
.chars()
.map(|c| {
if c.is_ascii_lowercase() {
// Rotate lowercase letters
(((c as u8 - b'a' + 13) % 26) + b'a') as char
} else if c.is_ascii_uppercase() {
// Rotate uppercase letters
(((c as u8 - b'A' + 13) % 26) + b'A') as char
} else {
// Leave other characters unchanged
c
}
})
.collect()
}
fn image_carousel(
ui: &mut egui::Ui,
img_cache: &mut Images,
images: Vec<(String, MediaCacheType)>,
carousel_id: egui::Id,
) {
// let's make sure everything is within our area
let height = 360.0;
let width = ui.available_size().x;
let spinsz = if height > width { width } else { height };
let show_popup = ui.ctx().memory(|mem| {
mem.data
.get_temp(carousel_id.with("show_popup"))
.unwrap_or(false)
});
let current_image = show_popup.then(|| {
ui.ctx().memory(|mem| {
mem.data
.get_temp::<(String, MediaCacheType)>(carousel_id.with("current_image"))
.unwrap_or_else(|| (images[0].0.clone(), images[0].1.clone()))
})
});
ui.add_sized([width, height], |ui: &mut egui::Ui| {
egui::ScrollArea::horizontal()
.id_salt(carousel_id)
.show(ui, |ui| {
ui.horizontal(|ui| {
for (image, cache_type) in images {
render_images(
ui,
img_cache,
&image,
ImageType::Content,
cache_type.clone(),
|ui| {
ui.allocate_space(egui::vec2(spinsz, spinsz));
},
|ui, _| {
ui.allocate_space(egui::vec2(spinsz, spinsz));
},
|ui, url, renderable_media, gifs| {
let texture = handle_repaint(
ui,
retrieve_latest_texture(&image, gifs, renderable_media),
);
let img_resp = ui.add(
Button::image(
Image::new(texture)
.max_height(height)
.corner_radius(5.0)
.fit_to_original_size(1.0),
)
.frame(false),
);
if img_resp.clicked() {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(carousel_id.with("show_popup"), true);
mem.data.insert_temp(
carousel_id.with("current_image"),
(image.clone(), cache_type.clone()),
);
});
}
copy_link(url, img_resp);
},
);
}
})
.response
})
.inner
});
if show_popup {
let current_image = current_image
.as_ref()
.expect("the image was actually clicked");
let image = current_image.clone().0;
let cache_type = current_image.clone().1;
Window::new("image_popup")
.title_bar(false)
.fixed_size(ui.ctx().screen_rect().size())
.fixed_pos(ui.ctx().screen_rect().min)
.frame(egui::Frame::NONE)
.show(ui.ctx(), |ui| {
let screen_rect = ui.ctx().screen_rect();
// escape
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(carousel_id.with("show_popup"), false);
});
}
// background
ui.painter()
.rect_filled(screen_rect, 0.0, Color32::from_black_alpha(230));
// zoom init
let zoom_id = carousel_id.with("zoom_level");
let mut zoom = ui
.ctx()
.memory(|mem| mem.data.get_temp(zoom_id).unwrap_or(1.0_f32));
// pan init
let pan_id = carousel_id.with("pan_offset");
let mut pan_offset = ui
.ctx()
.memory(|mem| mem.data.get_temp(pan_id).unwrap_or(egui::Vec2::ZERO));
// zoom & scroll
if ui.input(|i| i.pointer.hover_pos()).is_some() {
let scroll_delta = ui.input(|i| i.smooth_scroll_delta);
if scroll_delta.y != 0.0 {
let zoom_factor = if scroll_delta.y > 0.0 { 1.05 } else { 0.95 };
zoom *= zoom_factor;
zoom = zoom.clamp(0.1, 5.0);
if zoom <= 1.0 {
pan_offset = egui::Vec2::ZERO;
}
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(zoom_id, zoom);
mem.data.insert_temp(pan_id, pan_offset);
});
}
}
ui.centered_and_justified(|ui| {
render_images(
ui,
img_cache,
&image,
ImageType::Content,
cache_type.clone(),
|ui| {
ui.allocate_space(egui::vec2(spinsz, spinsz));
},
|ui, _| {
ui.allocate_space(egui::vec2(spinsz, spinsz));
},
|ui, url, renderable_media, gifs| {
let texture = handle_repaint(
ui,
retrieve_latest_texture(&image, gifs, renderable_media),
);
let texture_size = texture.size_vec2();
let screen_size = screen_rect.size();
let scale = (screen_size.x / texture_size.x)
.min(screen_size.y / texture_size.y)
.min(1.0);
let scaled_size = texture_size * scale * zoom;
let visible_width = scaled_size.x.min(screen_size.x);
let visible_height = scaled_size.y.min(screen_size.y);
let max_pan_x = ((scaled_size.x - visible_width) / 2.0).max(0.0);
let max_pan_y = ((scaled_size.y - visible_height) / 2.0).max(0.0);
if max_pan_x > 0.0 {
pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x);
} else {
pan_offset.x = 0.0;
}
if max_pan_y > 0.0 {
pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y);
} else {
pan_offset.y = 0.0;
}
let (rect, response) = ui.allocate_exact_size(
egui::vec2(visible_width, visible_height),
egui::Sense::click_and_drag(),
);
let uv_min = egui::pos2(
0.5 - (visible_width / scaled_size.x) / 2.0
+ pan_offset.x / scaled_size.x,
0.5 - (visible_height / scaled_size.y) / 2.0
+ pan_offset.y / scaled_size.y,
);
let uv_max = egui::pos2(
uv_min.x + visible_width / scaled_size.x,
uv_min.y + visible_height / scaled_size.y,
);
let uv = egui::Rect::from_min_max(uv_min, uv_max);
ui.painter()
.image(texture.id(), rect, uv, egui::Color32::WHITE);
let img_rect = ui.allocate_rect(rect, Sense::click());
if img_rect.clicked() {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(carousel_id.with("show_popup"), true);
});
} else if img_rect.clicked_elsewhere() {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(carousel_id.with("show_popup"), false);
});
}
// Handle dragging for pan
if response.dragged() {
let delta = response.drag_delta();
pan_offset.x -= delta.x;
pan_offset.y -= delta.y;
if max_pan_x > 0.0 {
pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x);
} else {
pan_offset.x = 0.0;
}
if max_pan_y > 0.0 {
pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y);
} else {
pan_offset.y = 0.0;
}
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(pan_id, pan_offset);
});
}
// reset zoom on double-click
if response.double_clicked() {
pan_offset = egui::Vec2::ZERO;
zoom = 1.0;
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(pan_id, pan_offset);
mem.data.insert_temp(zoom_id, zoom);
});
}
copy_link(url, response);
},
);
});
});
}
}
fn copy_link(url: &str, img_resp: Response) {
img_resp.context_menu(|ui| {
if ui.button("Copy Link").clicked() {
ui.ctx().copy_text(url.to_owned());
ui.close_menu();
}
});
}

View File

@@ -0,0 +1,160 @@
use egui::{Rect, Vec2};
use nostrdb::NoteKey;
use notedeck::{BroadcastContext, NoteContextSelection};
pub struct NoteContextButton {
put_at: Option<Rect>,
note_key: NoteKey,
}
impl egui::Widget for NoteContextButton {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
let r = if let Some(r) = self.put_at {
r
} else {
let mut place = ui.available_rect_before_wrap();
let size = Self::max_width();
place.set_width(size);
place.set_height(size);
place
};
Self::show(ui, self.note_key, r)
}
}
impl NoteContextButton {
pub fn new(note_key: NoteKey) -> Self {
let put_at: Option<Rect> = None;
NoteContextButton { note_key, put_at }
}
pub fn place_at(mut self, rect: Rect) -> Self {
self.put_at = Some(rect);
self
}
pub fn max_width() -> f32 {
Self::max_radius() * 3.0 + Self::max_distance_between_circles() * 2.0
}
pub fn size() -> Vec2 {
let width = Self::max_width();
egui::vec2(width, width)
}
fn max_radius() -> f32 {
4.0
}
fn min_radius() -> f32 {
2.0
}
fn max_distance_between_circles() -> f32 {
2.0
}
fn expansion_multiple() -> f32 {
2.0
}
fn min_distance_between_circles() -> f32 {
Self::max_distance_between_circles() / Self::expansion_multiple()
}
#[profiling::function]
pub fn show(ui: &mut egui::Ui, note_key: NoteKey, put_at: Rect) -> egui::Response {
let id = ui.id().with(("more_options_anim", note_key));
let min_radius = Self::min_radius();
let anim_speed = 0.05;
let response = ui.interact(put_at, id, egui::Sense::click());
let hovered = response.hovered();
let animation_progress = ui.ctx().animate_bool_with_time(id, hovered, anim_speed);
if hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let min_distance = Self::min_distance_between_circles();
let cur_distance = min_distance
+ (Self::max_distance_between_circles() - min_distance) * animation_progress;
let cur_radius = min_radius + (Self::max_radius() - min_radius) * animation_progress;
let center = put_at.center();
let left_circle_center = center - egui::vec2(cur_distance + cur_radius, 0.0);
let right_circle_center = center + egui::vec2(cur_distance + cur_radius, 0.0);
let translated_radius = (cur_radius - 1.0) / 2.0;
// This works in both themes
let color = ui.style().visuals.noninteractive().fg_stroke.color;
// Draw circles
let painter = ui.painter_at(put_at);
painter.circle_filled(left_circle_center, translated_radius, color);
painter.circle_filled(center, translated_radius, color);
painter.circle_filled(right_circle_center, translated_radius, color);
response
}
#[profiling::function]
pub fn menu(
ui: &mut egui::Ui,
button_response: egui::Response,
) -> Option<NoteContextSelection> {
let mut context_selection: Option<NoteContextSelection> = None;
stationary_arbitrary_menu_button(ui, button_response, |ui| {
ui.set_max_width(200.0);
if ui.button("Copy text").clicked() {
context_selection = Some(NoteContextSelection::CopyText);
ui.close_menu();
}
if ui.button("Copy user public key").clicked() {
context_selection = Some(NoteContextSelection::CopyPubkey);
ui.close_menu();
}
if ui.button("Copy note id").clicked() {
context_selection = Some(NoteContextSelection::CopyNoteId);
ui.close_menu();
}
if ui.button("Copy note json").clicked() {
context_selection = Some(NoteContextSelection::CopyNoteJSON);
ui.close_menu();
}
if ui.button("Broadcast").clicked() {
context_selection = Some(NoteContextSelection::Broadcast(
BroadcastContext::Everywhere,
));
ui.close_menu();
}
if ui.button("Broadcast to local network").clicked() {
context_selection = Some(NoteContextSelection::Broadcast(
BroadcastContext::LocalNetwork,
));
ui.close_menu();
}
});
context_selection
}
}
fn stationary_arbitrary_menu_button<R>(
ui: &mut egui::Ui,
button_response: egui::Response,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<Option<R>> {
let bar_id = ui.id();
let mut bar_state = egui::menu::BarState::load(ui.ctx(), bar_id);
let inner = bar_state.bar_menu(&button_response, add_contents);
bar_state.store(ui.ctx(), bar_id);
egui::InnerResponse::new(inner.map(|r| r.inner), button_response)
}

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

@@ -0,0 +1,79 @@
use crate::ProfilePic;
use bitflags::bitflags;
bitflags! {
// Attributes can be applied to flags types
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NoteOptions: u64 {
const actionbar = 0b0000000000000001;
const note_previews = 0b0000000000000010;
const small_pfp = 0b0000000000000100;
const medium_pfp = 0b0000000000001000;
const wide = 0b0000000000010000;
const selectable_text = 0b0000000000100000;
const textmode = 0b0000000001000000;
const options_button = 0b0000000010000000;
const hide_media = 0b0000000100000000;
/// Scramble text so that its not distracting during development
const scramble_text = 0b0000001000000000;
/// Whether the current note is a preview
const is_preview = 0b0000010000000000;
}
}
impl Default for NoteOptions {
fn default() -> NoteOptions {
NoteOptions::options_button | NoteOptions::note_previews | NoteOptions::actionbar
}
}
macro_rules! create_bit_methods {
($fn_name:ident, $has_name:ident, $option:ident) => {
#[inline]
pub fn $fn_name(&mut self, enable: bool) {
if enable {
*self |= NoteOptions::$option;
} else {
*self &= !NoteOptions::$option;
}
}
#[inline]
pub fn $has_name(self) -> bool {
(self & NoteOptions::$option) == NoteOptions::$option
}
};
}
impl NoteOptions {
create_bit_methods!(set_small_pfp, has_small_pfp, small_pfp);
create_bit_methods!(set_medium_pfp, has_medium_pfp, medium_pfp);
create_bit_methods!(set_note_previews, has_note_previews, note_previews);
create_bit_methods!(set_selectable_text, has_selectable_text, selectable_text);
create_bit_methods!(set_textmode, has_textmode, textmode);
create_bit_methods!(set_actionbar, has_actionbar, actionbar);
create_bit_methods!(set_wide, has_wide, wide);
create_bit_methods!(set_options_button, has_options_button, options_button);
create_bit_methods!(set_hide_media, has_hide_media, hide_media);
create_bit_methods!(set_scramble_text, has_scramble_text, scramble_text);
create_bit_methods!(set_is_preview, has_is_preview, is_preview);
pub fn new(is_universe_timeline: bool) -> Self {
let mut options = NoteOptions::default();
options.set_hide_media(is_universe_timeline);
options
}
pub fn pfp_size(&self) -> i8 {
if self.has_small_pfp() {
ProfilePic::small_size()
} else if self.has_medium_pfp() {
ProfilePic::medium_size()
} else {
ProfilePic::default_size()
}
}
}

View File

@@ -0,0 +1,180 @@
use egui::{Label, RichText, Sense};
use nostrdb::{Note, NoteReply, Transaction};
use super::NoteOptions;
use crate::{note::NoteView, Mention};
use enostr::KeypairUnowned;
use notedeck::{NoteAction, NoteContext};
#[must_use = "Please handle the resulting note action"]
#[profiling::function]
pub fn reply_desc(
ui: &mut egui::Ui,
cur_acc: &Option<KeypairUnowned>,
txn: &Transaction,
note_reply: &NoteReply,
note_context: &mut NoteContext,
note_options: NoteOptions,
) -> Option<NoteAction> {
let mut note_action: Option<NoteAction> = None;
let size = 10.0;
let selectable = false;
let visuals = ui.visuals();
let color = visuals.noninteractive().fg_stroke.color;
let link_color = visuals.hyperlink_color;
// note link renderer helper
let note_link =
|ui: &mut egui::Ui, note_context: &mut NoteContext, text: &str, note: &Note<'_>| {
let r = ui.add(
Label::new(RichText::new(text).size(size).color(link_color))
.sense(Sense::click())
.selectable(selectable),
);
if r.clicked() {
// TODO: jump to note
}
if r.hovered() {
r.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(400.0);
NoteView::new(note_context, cur_acc, note, note_options)
.actionbar(false)
.wide(true)
.show(ui);
});
}
};
ui.add(Label::new(RichText::new("replying to").size(size).color(color)).selectable(selectable));
let reply = note_reply.reply()?;
let reply_note = if let Ok(reply_note) = note_context.ndb.get_note_by_id(txn, reply.id) {
reply_note
} else {
ui.add(Label::new(RichText::new("a note").size(size).color(color)).selectable(selectable));
return None;
};
if note_reply.is_reply_to_root() {
// We're replying to the root, let's show this
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
reply_note.pubkey(),
)
.size(size)
.selectable(selectable)
.show(ui)
.inner;
if action.is_some() {
note_action = action;
}
ui.add(Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable));
note_link(ui, note_context, "thread", &reply_note);
} else if let Some(root) = note_reply.root() {
// replying to another post in a thread, not the root
if let Ok(root_note) = note_context.ndb.get_note_by_id(txn, root.id) {
if root_note.pubkey() == reply_note.pubkey() {
// simply "replying to bob's note" when replying to bob in his thread
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
reply_note.pubkey(),
)
.size(size)
.selectable(selectable)
.show(ui)
.inner;
if action.is_some() {
note_action = action;
}
ui.add(
Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable),
);
note_link(ui, note_context, "note", &reply_note);
} else {
// replying to bob in alice's thread
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
reply_note.pubkey(),
)
.size(size)
.selectable(selectable)
.show(ui)
.inner;
if action.is_some() {
note_action = action;
}
ui.add(
Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable),
);
note_link(ui, note_context, "note", &reply_note);
ui.add(
Label::new(RichText::new("in").size(size).color(color)).selectable(selectable),
);
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
root_note.pubkey(),
)
.size(size)
.selectable(selectable)
.show(ui)
.inner;
if action.is_some() {
note_action = action;
}
ui.add(
Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable),
);
note_link(ui, note_context, "thread", &root_note);
}
} else {
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
reply_note.pubkey(),
)
.size(size)
.selectable(selectable)
.show(ui)
.inner;
if action.is_some() {
note_action = action;
}
ui.add(
Label::new(RichText::new("in someone's thread").size(size).color(color))
.selectable(selectable),
);
}
}
note_action
}