Internationalize user-facing strings and export them for translations

Changelog-Added: Internationalized user-facing strings and exported them for translations
Signed-off-by: Terry Yiu <git@tyiu.xyz>
This commit is contained in:
2025-06-26 23:13:31 -04:00
committed by William Casarin
parent d07c3e9135
commit 3f5036bd32
37 changed files with 2198 additions and 437 deletions

View File

@@ -1,6 +1,6 @@
use egui::{Rect, Vec2};
use nostrdb::NoteKey;
use notedeck::{BroadcastContext, NoteContextSelection};
use notedeck::{tr, BroadcastContext, NoteContextSelection};
pub struct NoteContextButton {
put_at: Option<Rect>,
@@ -109,31 +109,78 @@ impl NoteContextButton {
) -> Option<NoteContextSelection> {
let mut context_selection: Option<NoteContextSelection> = None;
// Debug: Check if global i18n is available
if let Some(i18n) = notedeck::i18n::get_global_i18n() {
if let Ok(locale) = i18n.get_current_locale() {
tracing::debug!("Current locale in context menu: {}", locale);
}
} else {
tracing::warn!("Global i18n context not available in context menu");
}
stationary_arbitrary_menu_button(ui, button_response, |ui| {
ui.set_max_width(200.0);
if ui.button("Copy text").clicked() {
// Debug: Check what the tr! macro returns
let copy_text = tr!(
"Copy Text",
"Copy the text content of the note to clipboard"
);
tracing::debug!("Copy Text translation: '{}'", copy_text);
if ui.button(copy_text).clicked() {
context_selection = Some(NoteContextSelection::CopyText);
ui.close_menu();
}
if ui.button("Copy user public key").clicked() {
if ui
.button(tr!(
"Copy Pubkey",
"Copy the author's public key to clipboard"
))
.clicked()
{
context_selection = Some(NoteContextSelection::CopyPubkey);
ui.close_menu();
}
if ui.button("Copy note id").clicked() {
if ui
.button(tr!(
"Copy Note ID",
"Copy the unique note identifier to clipboard"
))
.clicked()
{
context_selection = Some(NoteContextSelection::CopyNoteId);
ui.close_menu();
}
if ui.button("Copy note json").clicked() {
if ui
.button(tr!(
"Copy Note JSON",
"Copy the raw note data in JSON format to clipboard"
))
.clicked()
{
context_selection = Some(NoteContextSelection::CopyNoteJSON);
ui.close_menu();
}
if ui.button("Broadcast").clicked() {
if ui
.button(tr!(
"Broadcast",
"Broadcast the note to all connected relays"
))
.clicked()
{
context_selection = Some(NoteContextSelection::Broadcast(
BroadcastContext::Everywhere,
));
ui.close_menu();
}
if ui.button("Broadcast to local network").clicked() {
if ui
.button(tr!(
"Broadcast Local",
"Broadcast the note only to local network relays"
))
.clicked()
{
context_selection = Some(NoteContextSelection::Broadcast(
BroadcastContext::LocalNetwork,
));

View File

@@ -6,7 +6,7 @@ use egui::{
};
use notedeck::{
fonts::get_font_size, note::MediaAction, show_one_error_message, supported_mime_hosted_at_url,
GifState, GifStateMap, Images, JobPool, MediaCache, MediaCacheType, NotedeckTextStyle,
tr, GifState, GifStateMap, Images, JobPool, MediaCache, MediaCacheType, NotedeckTextStyle,
TexturedImage, TexturesCache, UrlMimes,
};
@@ -636,7 +636,10 @@ fn render_full_screen_media(
fn copy_link(url: &str, img_resp: &Response) {
img_resp.context_menu(|ui| {
if ui.button("Copy Link").clicked() {
if ui
.button(tr!("Copy Link", "Button to copy media link to clipboard"))
.clicked()
{
ui.ctx().copy_text(url.to_owned());
ui.close_menu();
}
@@ -722,14 +725,18 @@ fn render_blur_text(ui: &mut egui::Ui, url: &str, render_rect: egui::Rect) -> eg
text_style.font_family(),
);
let info_galley = painter.layout(
"Media from someone you don't follow".to_owned(),
tr!(
"Media from someone you don't follow",
"Text shown on blurred media from unfollowed users"
)
.to_owned(),
animation_fontid.clone(),
ui.visuals().text_color(),
render_rect.width() / 2.0,
);
let load_galley = painter.layout_no_wrap(
"Tap to Load".to_owned(),
tr!("Tap to Load", "Button text to load blurred media").to_owned(),
animation_fontid,
egui::Color32::BLACK,
// ui.visuals().widgets.inactive.bg_fill,

View File

@@ -27,7 +27,7 @@ use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction};
use notedeck::{
name::get_display_name,
note::{NoteAction, NoteContext, ZapAction},
AnyZapState, CachedNote, ContextSelection, NoteCache, NoteZapTarget, NoteZapTargetOwned,
tr, AnyZapState, CachedNote, ContextSelection, NoteCache, NoteZapTarget, NoteZapTargetOwned,
NotedeckTextStyle, ZapTarget, Zaps,
};
@@ -308,7 +308,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
let color = ui.style().visuals.noninteractive().fg_stroke.color;
ui.add_space(4.0);
ui.label(
RichText::new("Reposted")
RichText::new(tr!("Reposted", "Label for reposted notes"))
.color(color)
.text_style(style.text_style()),
);
@@ -864,7 +864,7 @@ fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
let put_resp = ui
.put(rect, img.max_width(size))
.on_hover_text("Reply to this note");
.on_hover_text(tr!("Reply to this note", "Hover text for reply button"));
resp.union(put_resp)
}
@@ -889,7 +889,7 @@ fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
let put_resp = ui
.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size))
.on_hover_text("Repost this note");
.on_hover_text(tr!("Repost this note", "Hover text for repost button"));
resp.union(put_resp)
}
@@ -927,7 +927,9 @@ fn zap_button(state: AnyZapState, noteid: &[u8; 32]) -> impl egui::Widget + use<
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).on_hover_text("Zap this note");
let put_resp = ui
.put(rect, img)
.on_hover_text(tr!("Zap this note", "Hover text for zap button"));
resp.union(put_resp)
}

View File

@@ -1,9 +1,190 @@
use egui::{Label, RichText, Sense};
use nostrdb::{Note, NoteReply, Transaction};
use nostrdb::{NoteReply, Transaction};
use super::NoteOptions;
use crate::{jobs::JobsCache, note::NoteView, Mention};
use notedeck::{NoteAction, NoteContext};
use notedeck::{tr, NoteAction, NoteContext};
// Rich text segment types for internationalized rendering
#[derive(Debug, Clone)]
pub enum TextSegment {
Plain(String),
UserMention([u8; 32]), // pubkey
ThreadUserMention([u8; 32]), // pubkey
NoteLink([u8; 32]),
ThreadLink([u8; 32]),
}
// Helper function to parse i18n template strings with placeholders
fn parse_i18n_template(template: &str) -> Vec<TextSegment> {
let mut segments = Vec::new();
let mut current_text = String::new();
let mut chars = template.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '{' {
// Save any accumulated plain text
if !current_text.is_empty() {
segments.push(TextSegment::Plain(current_text.clone()));
current_text.clear();
}
// Parse placeholder
let mut placeholder = String::new();
for ch in chars.by_ref() {
if ch == '}' {
break;
}
placeholder.push(ch);
}
// Handle different placeholder types
match placeholder.as_str() {
// Placeholder values will be filled later.
"user" => segments.push(TextSegment::UserMention([0; 32])),
"thread_user" => segments.push(TextSegment::ThreadUserMention([0; 32])),
"note" => segments.push(TextSegment::NoteLink([0; 32])),
"thread" => segments.push(TextSegment::ThreadLink([0; 32])),
_ => {
// Unknown placeholder, treat as plain text
current_text.push_str(&format!("{{{placeholder}}}"));
}
}
} else {
current_text.push(ch);
}
}
// Add any remaining plain text
if !current_text.is_empty() {
segments.push(TextSegment::Plain(current_text));
}
segments
}
// Helper function to fill in the actual data for placeholders
fn fill_template_data(
mut segments: Vec<TextSegment>,
reply_pubkey: &[u8; 32],
reply_note_id: &[u8; 32],
root_pubkey: Option<&[u8; 32]>,
root_note_id: Option<&[u8; 32]>,
) -> Vec<TextSegment> {
for segment in &mut segments {
match segment {
TextSegment::UserMention(pubkey) if *pubkey == [0; 32] => {
*pubkey = *reply_pubkey;
}
TextSegment::ThreadUserMention(pubkey) if *pubkey == [0; 32] => {
*pubkey = *root_pubkey.unwrap_or(reply_pubkey);
}
TextSegment::NoteLink(note_id) if *note_id == [0; 32] => {
*note_id = *reply_note_id;
}
TextSegment::ThreadLink(note_id) if *note_id == [0; 32] => {
*note_id = *root_note_id.unwrap_or(reply_note_id);
}
_ => {}
}
}
segments
}
// Main rendering function for text segments
#[allow(clippy::too_many_arguments)]
fn render_text_segments(
ui: &mut egui::Ui,
segments: &[TextSegment],
txn: &Transaction,
note_context: &mut NoteContext,
note_options: NoteOptions,
jobs: &mut JobsCache,
size: f32,
selectable: bool,
) -> Option<NoteAction> {
let mut note_action: Option<NoteAction> = None;
let visuals = ui.visuals();
let color = visuals.noninteractive().fg_stroke.color;
let link_color = visuals.hyperlink_color;
for segment in segments {
match segment {
TextSegment::Plain(text) => {
ui.add(
Label::new(RichText::new(text).size(size).color(color)).selectable(selectable),
);
}
TextSegment::UserMention(pubkey) | TextSegment::ThreadUserMention(pubkey) => {
let action = Mention::new(note_context.ndb, note_context.img_cache, txn, pubkey)
.size(size)
.selectable(selectable)
.show(ui);
if action.is_some() {
note_action = action;
}
}
TextSegment::NoteLink(note_id) => {
if let Ok(note) = note_context.ndb.get_note_by_id(txn, note_id) {
let r = ui.add(
Label::new(
RichText::new(tr!("note", "Link text for note references"))
.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, &note, note_options, jobs)
.actionbar(false)
.wide(true)
.show(ui);
});
}
}
}
TextSegment::ThreadLink(note_id) => {
if let Ok(note) = note_context.ndb.get_note_by_id(txn, note_id) {
let r = ui.add(
Label::new(
RichText::new(tr!("thread", "Link text for thread references"))
.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, &note, note_options, jobs)
.actionbar(false)
.wide(true)
.show(ui);
});
}
}
}
}
}
note_action
}
#[must_use = "Please handle the resulting note action"]
#[profiling::function]
@@ -15,163 +196,109 @@ pub fn reply_desc(
note_options: NoteOptions,
jobs: &mut JobsCache,
) -> 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<'_>,
jobs: &mut JobsCache| {
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, note, note_options, jobs)
.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;
// Handle case where reply note is not found
let template = tr!(
"replying to a note",
"Fallback text when reply note is not found"
);
let segments = parse_i18n_template(&template);
return render_text_segments(
ui,
&segments,
txn,
note_context,
note_options,
jobs,
size,
selectable,
);
};
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,
let segments = if note_reply.is_reply_to_root() {
// Template: "replying to {user}'s {thread}"
let template = tr!(
"replying to {user}'s {thread}",
"Template for replying to root thread",
user = "{user}",
thread = "{thread}"
);
let segments = parse_i18n_template(&template);
fill_template_data(
segments,
reply_note.pubkey(),
reply.id,
None,
Some(reply.id),
)
.size(size)
.selectable(selectable)
.show(ui);
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, jobs);
} 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);
if action.is_some() {
note_action = action;
}
ui.add(
Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable),
// Template: "replying to {user}'s {note}"
let template = tr!(
"replying to {user}'s {note}",
"Template for replying to user's note",
user = "{user}",
note = "{note}"
);
note_link(ui, note_context, "note", &reply_note, jobs);
let segments = parse_i18n_template(&template);
fill_template_data(segments, reply_note.pubkey(), reply.id, None, None)
} else {
// replying to bob in alice's thread
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
// Template: "replying to {reply_user}'s {note} in {thread_user}'s {thread}"
// This would need more sophisticated placeholder handling
let template = tr!(
"replying to {user}'s {note} in {thread_user}'s {thread}",
"Template for replying to note in different user's thread",
user = "{user}",
note = "{note}",
thread_user = "{thread_user}",
thread = "{thread}"
);
let segments = parse_i18n_template(&template);
fill_template_data(
segments,
reply_note.pubkey(),
reply.id,
Some(root_note.pubkey()),
Some(root.id),
)
.size(size)
.selectable(selectable)
.show(ui);
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, jobs);
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);
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, jobs);
}
} else {
let action = Mention::new(
note_context.ndb,
note_context.img_cache,
txn,
reply_note.pubkey(),
)
.size(size)
.selectable(selectable)
.show(ui);
if action.is_some() {
note_action = action;
}
ui.add(
Label::new(RichText::new("in someone's thread").size(size).color(color))
.selectable(selectable),
// Template: "replying to {user} in someone's thread"
let template = tr!(
"replying to {user} in someone's thread",
"Template for replying to user in unknown thread",
user = "{user}"
);
let segments = parse_i18n_template(&template);
fill_template_data(segments, reply_note.pubkey(), reply.id, None, None)
}
}
} else {
// Fallback
let template = tr!(
"replying to {user}",
"Fallback template for replying to user",
user = "{user}"
);
let segments = parse_i18n_template(&template);
fill_template_data(segments, reply_note.pubkey(), reply.id, None, None)
};
note_action
render_text_segments(
ui,
&segments,
txn,
note_context,
note_options,
jobs,
size,
selectable,
)
}