notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 3f9d0300468d72831a73ccca753e44367e7128bc
parent 6a08d4b1b2ba8af6031fbd2d72628f50c9df6bfa
Author: William Casarin <jb55@jb55.com>
Date:   Sun,  3 Aug 2025 10:38:38 -0700

Merge remote-tracking branch 'github/pr/1025'

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mcrates/notedeck/Cargo.toml | 1+
Mcrates/notedeck/src/lib.rs | 1+
Mcrates/notedeck/src/time.rs | 9+++++++++
Mcrates/notedeck_columns/src/nav.rs | 1+
Mcrates/notedeck_columns/src/ui/settings.rs | 21+++++++++++----------
Mcrates/notedeck_columns/src/ui/thread.rs | 1+
Mcrates/notedeck_dave/Cargo.toml | 2+-
Mcrates/notedeck_ui/src/note/contents.rs | 43+++++++++++++++++++++++++++++++++----------
Mcrates/notedeck_ui/src/note/mod.rs | 18+++++++++++++-----
Mcrates/notedeck_ui/src/note/options.rs | 5++++-
12 files changed, 77 insertions(+), 27 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3485,6 +3485,7 @@ dependencies = [ "bincode", "bitflags 2.9.1", "blurhash", + "chrono", "dirs", "eframe", "egui", diff --git a/Cargo.toml b/Cargo.toml @@ -14,6 +14,7 @@ members = [ [workspace.dependencies] opener = "0.8.2" +chrono = "0.4.40" base32 = "0.4.0" base64 = "0.22.1" rmpv = "1.3.0" diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml @@ -49,6 +49,7 @@ once_cell = { workspace = true } md5 = { workspace = true } bitflags = { workspace = true } regex = "1" +chrono = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -80,6 +80,7 @@ pub use storage::{AccountStorage, DataPath, DataPathType, Directory}; pub use style::NotedeckTextStyle; pub use theme::ColorTheme; pub use time::time_ago_since; +pub use time::time_format; pub use timecache::TimeCached; pub use unknowns::{get_unknown_note_ids, NoteRefsUnkIdAction, SingleUnkIdAction, UnknownIds}; pub use urls::{supported_mime_hosted_at_url, SupportedMimeType, UrlMimes}; diff --git a/crates/notedeck/src/time.rs b/crates/notedeck/src/time.rs @@ -1,4 +1,5 @@ use crate::{tr, Localization}; +use chrono::DateTime; use std::time::{SystemTime, UNIX_EPOCH}; // Time duration constants in seconds @@ -83,6 +84,14 @@ fn time_ago_between(i18n: &mut Localization, timestamp: u64, now: u64) -> String } } +pub fn time_format(_i18n: &mut Localization, timestamp: u64) -> String { + // TODO: format this using the selected locale + DateTime::from_timestamp(timestamp as i64, 0) + .unwrap() + .format("%l:%M %p %b %d, %Y") + .to_string() +} + pub fn time_ago_since(i18n: &mut Localization, timestamp: u64) -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -591,6 +591,7 @@ fn render_nav_body( ) .ui(ui) .map(RenderNavAction::SettingsAction), + Route::Reply(id) => { let txn = if let Ok(txn) = Transaction::new(ctx.ndb) { txn diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs @@ -270,6 +270,7 @@ impl<'a> SettingsView<'a> { }); let txn = Transaction::new(self.note_context.ndb).unwrap(); + if let Some(note_id) = NoteId::from_bech(PREVIEW_NOTE_ID) { if let Ok(preview_note) = self.note_context.ndb.get_note_by_id(&txn, note_id.bytes()) @@ -277,17 +278,17 @@ impl<'a> SettingsView<'a> { notedeck_ui::padding(8.0, ui, |ui| { if is_narrow(ui.ctx()) { ui.set_max_width(ui.available_width()); - } - NoteView::new( - self.note_context, - &preview_note, - *self.note_options, - self.jobs, - ) - .actionbar(false) - .options_button(false) - .show(ui); + NoteView::new( + self.note_context, + &preview_note, + *self.note_options, + self.jobs, + ) + .actionbar(false) + .options_button(false) + .show(ui); + } }); ui.separator(); } diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -292,6 +292,7 @@ struct ThreadNote<'a> { impl<'a> ThreadNote<'a> { fn options(&self, mut cur_options: NoteOptions) -> NoteOptions { + cur_options.set(NoteOptions::ShowCreatedAtBottom, true); match self.note_type { ThreadNoteType::Chain { root: _ } => cur_options, ThreadNoteType::Selected { root: _ } => { diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml @@ -18,7 +18,7 @@ serde_json = { workspace = true } serde = { workspace = true } nostrdb = { workspace = true } hex = { workspace = true } -chrono = "0.4.40" +chrono = { workspace = true } rand = "0.9.0" bytemuck = "1.22.0" futures = "0.3.31" diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -1,16 +1,16 @@ +use super::media::image_carousel; use crate::{ note::{NoteAction, NoteOptions, NoteResponse, NoteView}, secondary_label, }; -use notedeck::{JobsCache, RenderableMedia}; - use egui::{Color32, Hyperlink, Label, RichText}; use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction}; +use notedeck::{ + time_format, update_imeta_blurhashes, IsFollowing, NoteCache, NoteContext, NotedeckTextStyle, +}; +use notedeck::{JobsCache, RenderableMedia}; use tracing::warn; -use super::media::image_carousel; -use notedeck::{update_imeta_blurhashes, IsFollowing, NoteCache, NoteContext, NotedeckTextStyle}; - pub struct NoteContents<'a, 'd> { note_context: &'a mut NoteContext<'d>, txn: &'a Transaction, @@ -42,8 +42,13 @@ impl<'a, 'd> NoteContents<'a, 'd> { impl egui::Widget for &mut NoteContents<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let create_at_bottom = self.options.contains(NoteOptions::ShowCreatedAtBottom); if self.options.contains(NoteOptions::ShowNoteClientTop) { - render_client(ui, self.note_context.note_cache, self.note); + render_client(ui, self.note_context.note_cache, self.note, false); + } + // bottom created at only on selected note + if create_at_bottom { + self.options.set(NoteOptions::ShowCreatedAtBottom, false); } let result = render_note_contents( ui, @@ -53,21 +58,39 @@ impl egui::Widget for &mut NoteContents<'_, '_> { self.options, self.jobs, ); - if self.options.contains(NoteOptions::ShowNoteClientBottom) { - render_client(ui, self.note_context.note_cache, self.note); - } + ui.horizontal(|ui| { + if create_at_bottom { + secondary_label( + ui, + time_format(self.note_context.i18n, self.note.created_at()), + ); + } + + if self.options.contains(NoteOptions::ShowNoteClientBottom) { + render_client( + ui, + self.note_context.note_cache, + self.note, + create_at_bottom, + ); + } + }); + self.action = result.action; result.response } } #[profiling::function] -fn render_client(ui: &mut egui::Ui, note_cache: &mut NoteCache, note: &Note) { +fn render_client(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); match cached_note.client.as_deref() { Some(client) if !client.is_empty() => { ui.horizontal(|ui| { + if before { + secondary_label(ui, "⋅"); + } secondary_label(ui, format!("via {client}")); }); } diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -212,7 +212,7 @@ impl<'a, 'd> NoteView<'a, 'd> { 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, self.note_context.i18n, self.note.created_at(), false).response + render_notetime(ui, self.note_context.i18n, self.note.created_at(), false).response }); let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); ui.allocate_rect(rect, Sense::hover()); @@ -363,13 +363,17 @@ impl<'a, 'd> NoteView<'a, 'd> { note: &Note, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, show_unread_indicator: bool, + flags: NoteOptions, ) { let horiz_resp = ui .horizontal(|ui| { ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 }; - ui.add(Username::new(i18n, profile.as_ref().ok(), note.pubkey()).abbreviated(20)); - - render_reltime(ui, i18n, note.created_at(), true); + let response = ui + .add(Username::new(i18n, profile.as_ref().ok(), note.pubkey()).abbreviated(20)); + if !flags.contains(NoteOptions::ShowCreatedAtBottom) { + return render_notetime(ui, i18n, note.created_at(), true).response; + } + response }) .response; @@ -417,6 +421,7 @@ impl<'a, 'd> NoteView<'a, 'd> { self.note, profile, self.show_unread_indicator, + self.flags, ); }) .response @@ -503,6 +508,8 @@ impl<'a, 'd> NoteView<'a, 'd> { let pfp_rect = pfp_resp.bounding_rect; let mut note_action: Option<NoteAction> = pfp_resp.into_action(self.note.pubkey()); + self.flags.set(NoteOptions::ShowCreatedAtBottom, false); + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { NoteView::note_header( ui, @@ -510,6 +517,7 @@ impl<'a, 'd> NoteView<'a, 'd> { self.note, profile, self.show_unread_indicator, + self.flags, ); ui.horizontal_wrapped(|ui| 's: { @@ -862,7 +870,7 @@ fn render_note_actionbar( } #[profiling::function] -fn render_reltime( +fn render_notetime( ui: &mut egui::Ui, i18n: &mut Localization, created_at: u64, diff --git a/crates/notedeck_ui/src/note/options.rs b/crates/notedeck_ui/src/note/options.rs @@ -22,11 +22,14 @@ bitflags! { /// Is the content truncated? If the length is over a certain size it /// will end with a ... and a "Show more" button. const Truncate = 1 << 11; - /// Show note's client in the note header + /// Show note's client in the note content const ShowNoteClientTop = 1 << 12; const ShowNoteClientBottom = 1 << 13; const RepliesNewestFirst = 1 << 14; + + // Show note's created at note bottom + const ShowCreatedAtBottom = 1 << 15; } }