Files
notedeck/crates/notedeck_columns/src/ui/note/contents.rs
William Casarin bd352f76d4 feat: add scramble flag for development text scrambling
This commit introduces a new scramble option to help reduce distractions
during development by scrambling text using rot13. When enabled via the
new `--scramble` flag, text displayed in various views is transformed,
making it easier to focus on layout and behavior without reading the
actual content.

App & Args Updates

  - Added a `scramble: bool` field to the main application state (in `app.rs`).

  - Extended argument parsing (in `args.rs`) to recognize the `--scramble` flag.

NoteOptions Enhancement

  - Introduced a new bit flag `scramble_text` in `NoteOptions` with
    corresponding setter/getter methods.

UI Adjustments

  - Propagated the scramble flag through note rendering functions across
    navigation, timeline, and note view modules.

  - Updated several UI components (e.g., in `nav.rs`, `route.rs`, and
    `contents.rs`) to accept and apply the new note options.

Rot13 Implementation

  - Implemented a helper function (`rot13`) to scramble text
    conditionally when the scramble option is enabled.

This feature is intended for development builds only, offering a way to
obscure text content during UI tweaks and testing.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-02-22 14:30:38 -08:00

354 lines
12 KiB
Rust

use crate::ui::{
self,
note::{NoteOptions, NoteResponse},
ProfilePic,
};
use crate::{actionbar::NoteAction, images::ImageType, timeline::TimelineKind};
use egui::{Color32, Hyperlink, Image, RichText};
use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction};
use tracing::warn;
use notedeck::{ImageCache, NoteCache};
pub struct NoteContents<'a> {
ndb: &'a Ndb,
img_cache: &'a mut ImageCache,
note_cache: &'a mut NoteCache,
txn: &'a Transaction,
note: &'a Note<'a>,
note_key: NoteKey,
options: NoteOptions,
action: Option<NoteAction>,
}
impl<'a> NoteContents<'a> {
pub fn new(
ndb: &'a Ndb,
img_cache: &'a mut ImageCache,
note_cache: &'a mut NoteCache,
txn: &'a Transaction,
note: &'a Note,
note_key: NoteKey,
options: ui::note::NoteOptions,
) -> Self {
NoteContents {
ndb,
img_cache,
note_cache,
txn,
note,
note_key,
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.ndb,
self.img_cache,
self.note_cache,
self.txn,
self.note,
self.note_key,
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)]
pub fn render_note_preview(
ui: &mut egui::Ui,
ndb: &Ndb,
note_cache: &mut NoteCache,
img_cache: &mut ImageCache,
txn: &Transaction,
id: &[u8; 32],
parent: NoteKey,
note_options: NoteOptions,
) -> NoteResponse {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let note = if let Ok(note) = 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::none()
.fill(ui.visuals().noninteractive().weak_bg_fill)
.inner_margin(egui::Margin::same(8.0))
.outer_margin(egui::Margin::symmetric(0.0, 8.0))
.rounding(egui::Rounding::same(10.0))
.stroke(egui::Stroke::new(
1.0,
ui.visuals().noninteractive().bg_stroke.color,
))
.show(ui, |ui| {
ui::NoteView::new(ndb, note_cache, img_cache, &note, note_options)
.actionbar(false)
.small_pfp(true)
.wide(true)
.note_previews(false)
.options_button(true)
.parent(parent)
.show(ui)
})
.inner
}
fn is_image_link(url: &str) -> bool {
url.ends_with("png") || url.ends_with("jpg") || url.ends_with("jpeg")
}
#[allow(clippy::too_many_arguments)]
fn render_note_contents(
ui: &mut egui::Ui,
ndb: &Ndb,
img_cache: &mut ImageCache,
note_cache: &mut NoteCache,
txn: &Transaction,
note: &Note,
note_key: NoteKey,
options: NoteOptions,
) -> NoteResponse {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let selectable = options.has_selectable_text();
let mut images: Vec<String> = 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;
let response = ui.horizontal_wrapped(|ui| {
let blocks = if let Ok(blocks) = ndb.get_blocks_by_key(txn, note_key) {
blocks
} else {
warn!("missing note content blocks? '{}'", note.content());
ui.weak(note.content());
return;
};
ui.spacing_mut().item_spacing.x = 0.0;
for block in blocks.iter(note) {
match block.blocktype() {
BlockType::MentionBech32 => match block.as_mention().unwrap() {
Mention::Profile(profile) => {
let act = ui::Mention::new(ndb, img_cache, txn, profile.pubkey())
.show(ui)
.inner;
if act.is_some() {
note_action = act;
}
}
Mention::Pubkey(npub) => {
let act = ui::Mention::new(ndb, 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 => {
#[cfg(feature = "profiling")]
puffin::profile_scope!("hashtag 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(),
)));
} else if resp.hovered() {
ui::show_pointer(ui);
}
}
BlockType::Url => {
let lower_url = block.as_str().to_lowercase();
if !hide_media && is_image_link(&lower_url) {
images.push(block.as_str().to_string());
} else {
#[cfg(feature = "profiling")]
puffin::profile_scope!("url contents");
ui.add(Hyperlink::from_label_and_url(
RichText::new(block.as_str()).color(link_color),
block.as_str(),
));
}
}
BlockType::Text => {
#[cfg(feature = "profiling")]
puffin::profile_scope!("text contents");
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, ndb, note_cache, img_cache, 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, 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 ImageCache,
images: Vec<String>,
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 };
ui.add_sized([width, height], |ui: &mut egui::Ui| {
egui::ScrollArea::horizontal()
.id_salt(carousel_id)
.show(ui, |ui| {
ui.horizontal(|ui| {
for image in images {
// If the cache is empty, initiate the fetch
let m_cached_promise = img_cache.map().get(&image);
if m_cached_promise.is_none() {
let res = crate::images::fetch_img(
img_cache,
ui.ctx(),
&image,
ImageType::Content(width.round() as u32, height.round() as u32),
);
img_cache.map_mut().insert(image.to_owned(), res);
}
// What is the state of the fetch?
match img_cache.map()[&image].ready() {
// Still waiting
None => {
ui.allocate_space(egui::vec2(spinsz, spinsz));
//ui.add(egui::Spinner::new().size(spinsz));
}
// Failed to fetch image!
Some(Err(_err)) => {
// FIXME - use content-specific error instead
let no_pfp = crate::images::fetch_img(
img_cache,
ui.ctx(),
ProfilePic::no_pfp_url(),
ImageType::Profile(128),
);
img_cache.map_mut().insert(image.to_owned(), no_pfp);
// spin until next pass
ui.allocate_space(egui::vec2(spinsz, spinsz));
//ui.add(egui::Spinner::new().size(spinsz));
}
// Use the previously resolved image
Some(Ok(img)) => {
let img_resp = ui.add(
Image::new(img)
.max_height(height)
.rounding(5.0)
.fit_to_original_size(1.0),
);
img_resp.context_menu(|ui| {
if ui.button("Copy Link").clicked() {
ui.ctx().copy_text(image);
ui.close_menu();
}
});
}
}
}
})
.response
})
.inner
});
}