Files
notedeck/crates/notedeck_ui/src/note/contents.rs
William Casarin 2edc19fbcc ui/note: fix extra padding in block renderer
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-03 16:26:26 -07:00

424 lines
14 KiB
Rust

use super::media::image_carousel;
use crate::{
note::{NoteAction, NoteOptions, NoteResponse, NoteView},
secondary_label,
};
use egui::{Color32, Hyperlink, Label, RichText};
use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction};
use notedeck::Localization;
use notedeck::{
time_format, update_imeta_blurhashes, IsFollowing, NoteCache, NoteContext, NotedeckTextStyle,
};
use notedeck::{JobsCache, RenderableMedia};
use tracing::warn;
pub struct NoteContents<'a, 'd> {
note_context: &'a mut NoteContext<'d>,
txn: &'a Transaction,
note: &'a Note<'a>,
options: NoteOptions,
pub action: Option<NoteAction>,
jobs: &'a mut JobsCache,
}
impl<'a, 'd> NoteContents<'a, 'd> {
#[allow(clippy::too_many_arguments)]
pub fn new(
note_context: &'a mut NoteContext<'d>,
txn: &'a Transaction,
note: &'a Note,
options: NoteOptions,
jobs: &'a mut JobsCache,
) -> Self {
NoteContents {
note_context,
txn,
note,
options,
action: None,
jobs,
}
}
}
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.txn,
self.note,
self.options,
self.jobs,
);
self.action = result.action;
result.response
}
}
fn render_client_name(ui: &mut egui::Ui, note_cache: &mut NoteCache, note: &Note, before: bool) {
let cached_note = note_cache.cached_note_or_insert_mut(note.key().unwrap(), note);
let Some(client) = cached_note.client.as_ref() else {
return;
};
if client.is_empty() {
return;
}
if before {
secondary_label(ui, "");
}
secondary_label(ui, format!("via {client}"));
}
/// 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,
txn: &Transaction,
id: &[u8; 32],
parent: NoteKey,
note_options: NoteOptions,
jobs: &mut JobsCache,
) -> 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 {
note_context
.unknown_ids
.add_note_id_if_missing(note_context.ndb, txn, id);
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;
*/
};
NoteView::new(note_context, &note, note_options, jobs)
.preview_style()
.parent(parent)
.show(ui)
}
/// Render note contents and surrounding info (client name, full date timestamp)
fn render_note_contents(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
txn: &Transaction,
note: &Note,
options: NoteOptions,
jobs: &mut JobsCache,
) -> NoteResponse {
let response = render_undecorated_note_contents(ui, note_context, txn, note, options, jobs);
ui.horizontal_wrapped(|ui| {
note_bottom_metadata_ui(
ui,
note_context.i18n,
note_context.note_cache,
note,
options,
);
});
response
}
/// Client name, full timestamp, etc
fn note_bottom_metadata_ui(
ui: &mut egui::Ui,
i18n: &mut Localization,
note_cache: &mut NoteCache,
note: &Note,
options: NoteOptions,
) {
let show_full_date = options.contains(NoteOptions::FullCreatedDate);
if show_full_date {
secondary_label(ui, time_format(i18n, note.created_at()));
}
if options.contains(NoteOptions::ClientName) {
render_client_name(ui, note_cache, note, show_full_date);
}
}
#[allow(clippy::too_many_arguments)]
#[profiling::function]
fn render_undecorated_note_contents<'a>(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
txn: &Transaction,
note: &'a Note,
options: NoteOptions,
jobs: &mut JobsCache,
) -> NoteResponse {
let note_key = note.key().expect("todo: implement non-db notes");
let selectable = options.contains(NoteOptions::SelectableText);
let mut note_action: Option<NoteAction> = None;
let mut inline_note: Option<(&[u8; 32], &str)> = None;
let hide_media = options.contains(NoteOptions::HideMedia);
let link_color = ui.visuals().hyperlink_color;
// The current length of the rendered blocks. Used in trucation logic
let mut current_len: usize = 0;
let truncate_len = 280;
if !options.contains(NoteOptions::IsPreview) {
// 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 mut supported_medias: Vec<RenderableMedia> = vec![];
let response = ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
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;
};
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);
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);
if act.is_some() {
note_action = act;
}
}
Mention::Note(note) if options.contains(NoteOptions::HasNotePreviews) => {
inline_note = Some((note.id(), block.as_str()));
}
Mention::Event(note) if options.contains(NoteOptions::HasNotePreviews) => {
inline_note = Some((note.id(), block.as_str()));
}
_ => {
ui.colored_label(
link_color,
RichText::new(format!("@{}", &block.as_str()[..16]))
.text_style(NotedeckTextStyle::NoteBody.text_style()),
);
}
},
BlockType::Hashtag => {
if block.as_str().trim().is_empty() {
continue;
}
let resp = ui
.colored_label(
link_color,
RichText::new(format!("#{}", block.as_str()))
.text_style(NotedeckTextStyle::NoteBody.text_style()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
note_action = Some(NoteAction::Hashtag(block.as_str().to_string()));
}
}
BlockType::Url => {
let mut found_supported = || -> bool {
let url = block.as_str();
if !note_context.img_cache.metadata.contains_key(url) {
update_imeta_blurhashes(note, &mut note_context.img_cache.metadata);
}
let Some(media) = note_context.img_cache.get_renderable_media(url) else {
return false;
};
supported_medias.push(media);
true
};
if hide_media || !found_supported() {
if block.as_str().trim().is_empty() {
continue;
}
ui.add(Hyperlink::from_label_and_url(
RichText::new(block.as_str())
.color(link_color)
.text_style(NotedeckTextStyle::NoteBody.text_style()),
block.as_str(),
));
}
}
BlockType::Text => {
// truncate logic
let mut truncate = false;
let block_str = if options.contains(NoteOptions::Truncate)
&& (current_len + block.as_str().len() > truncate_len)
{
truncate = true;
// The current block goes over the truncate length,
// we'll need to truncate this block
let block_str = block.as_str();
let closest = notedeck::abbrev::floor_char_boundary(
block_str,
truncate_len - current_len,
);
&(block_str[..closest].to_string() + "")
} else {
let block_str = block.as_str();
current_len += block_str.len();
block_str
};
if block_str.trim().is_empty() {
continue;
}
if options.contains(NoteOptions::ScrambleText) {
ui.add(
Label::new(
RichText::new(rot13(block_str))
.text_style(NotedeckTextStyle::NoteBody.text_style()),
)
.wrap()
.selectable(selectable),
);
} else {
ui.add(
Label::new(
RichText::new(block_str)
.text_style(NotedeckTextStyle::NoteBody.text_style()),
)
.wrap()
.selectable(selectable),
);
}
// don't render any more blocks
if truncate {
break;
}
}
_ => {
ui.colored_label(link_color, block.as_str());
}
}
}
});
let preview_note_action = inline_note.and_then(|(id, _)| {
render_note_preview(ui, note_context, txn, id, note_key, options, jobs)
.action
.map(|a| match a {
NoteAction::Note { note_id, .. } => NoteAction::Note {
note_id,
preview: true,
},
other => other,
})
});
let mut media_action = None;
if !supported_medias.is_empty() && !options.contains(NoteOptions::Textmode) {
ui.add_space(2.0);
let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note")));
let is_self = note.pubkey()
== note_context
.accounts
.get_selected_account()
.key
.pubkey
.bytes();
let trusted_media = is_self
|| note_context
.accounts
.get_selected_account()
.is_following(note.pubkey())
== IsFollowing::Yes;
media_action = image_carousel(
ui,
note_context.img_cache,
note_context.job_pool,
jobs,
&supported_medias,
carousel_id,
trusted_media,
note_context.i18n,
options,
);
ui.add_space(2.0);
}
let note_action = preview_note_action
.or(note_action)
.or(media_action.map(NoteAction::Media));
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()
}