notedeck

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

commit e20861f8d677f48eac16afeb725f15521f6be66f
parent af53dd48522eb558a54adb1c5d29e83daa90a5fa
Author: William Casarin <jb55@jb55.com>
Date:   Fri,  3 Oct 2025 13:30:41 -0700

Merges notification filters, perf & crash fixes by kernel

kernelkind (6):
      feat: "All" & "Mentions" notifications tabs like Damus iOS
      fix: WalletView don't request keyboard focus if narrow
      fix: crash on startup
      add `load_texture_checked`
      use `load_texture_checked` over `load_texture`
      add clippy rule to disallow the usage of `load_texture`

Diffstat:
Acrates/notedeck/Clippy.toml | 4++++
Mcrates/notedeck/src/lib.rs | 2++
Mcrates/notedeck/src/media/blur.rs | 22+++++++++++++++++++---
Mcrates/notedeck/src/media/images.rs | 14++++++++++----
Mcrates/notedeck/src/media/mod.rs | 20++++++++++++++++++++
Mcrates/notedeck_columns/src/timeline/kind.rs | 2+-
Mcrates/notedeck_columns/src/timeline/mod.rs | 96++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/notedeck_columns/src/ui/timeline.rs | 13++-----------
Mcrates/notedeck_columns/src/ui/wallet.rs | 4+++-
9 files changed, 119 insertions(+), 58 deletions(-)

diff --git a/crates/notedeck/Clippy.toml b/crates/notedeck/Clippy.toml @@ -0,0 +1,3 @@ +disallowed-methods = [ + { path = "egui::Context::load_texture", reason = "Use load_texture_checked" } +] +\ No newline at end of file diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -1,3 +1,5 @@ +#![deny(clippy::disallowed_methods)] + pub mod abbrev; mod account; mod app; diff --git a/crates/notedeck/src/media/blur.rs b/crates/notedeck/src/media/blur.rs @@ -2,7 +2,10 @@ use std::collections::HashMap; use nostrdb::Note; -use crate::jobs::{Job, JobError, JobParamsOwned}; +use crate::{ + jobs::{Job, JobError, JobParamsOwned}, + media::load_texture_checked, +}; #[derive(Clone)] pub struct ImageMetadata { @@ -23,6 +26,19 @@ impl PixelDimensions { y: (self.y as f32) / ppp, } } + + pub fn clamp_wgpu(mut self) -> PixelDimensions { + let val = super::MAX_SIZE_WGPU as u32; + if self.x > val { + self.x = val; + } + + if self.y > val { + self.y = val + } + + self + } } #[derive(Clone, Debug)] @@ -50,7 +66,7 @@ impl ImageMetadata { ui: &egui::Ui, available_points: PointDimensions, ) -> PixelDimensions { - let max_pixels = available_points.to_pixels(ui); + let max_pixels = available_points.to_pixels(ui).clamp_wgpu(); let Some(defined_dimensions) = &self.dimensions else { return max_pixels; @@ -187,5 +203,5 @@ fn generate_blurhash_texturehandle( .map_err(|e| crate::Error::Generic(e.to_string()))?; let img = egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &bytes); - Ok(ctx.load_texture(url, img, Default::default())) + Ok(load_texture_checked(ctx, url, img, Default::default())) } diff --git a/crates/notedeck/src/media/images.rs b/crates/notedeck/src/media/images.rs @@ -1,3 +1,4 @@ +use crate::media::load_texture_checked; use crate::{Animation, ImageFrame, MediaCache, MediaCacheType, TextureFrame, TexturedImage}; use egui::{pos2, Color32, ColorImage, Context, Rect, Sense, SizeHint}; use image::codecs::gif::GifDecoder; @@ -241,7 +242,8 @@ async fn async_fetch_img_from_disk( image_buffer.width(), image_buffer.height(), ); - Ok(TexturedImage::Static(ctx.load_texture( + Ok(TexturedImage::Static(load_texture_checked( + &ctx, &url, img, Default::default(), @@ -365,7 +367,7 @@ fn generate_animation_frame( TextureFrame { delay, - texture: ctx.load_texture(format!("{url}{index}"), color_img, Default::default()), + texture: load_texture_checked(ctx, format!("{url}{index}"), color_img, Default::default()), } } @@ -429,8 +431,12 @@ fn fetch_img_from_net( MediaCacheType::Image => { let img = parse_img_response(resp, imgtyp); img.map(|img| { - let texture_handle = - ctx.load_texture(&cloned_url, img.clone(), Default::default()); + let texture_handle = load_texture_checked( + &ctx, + &cloned_url, + img.clone(), + Default::default(), + ); // write to disk std::thread::spawn(move || { diff --git a/crates/notedeck/src/media/mod.rs b/crates/notedeck/src/media/mod.rs @@ -10,6 +10,7 @@ pub use blur::{ compute_blurhash, update_imeta_blurhashes, ImageMetadata, ObfuscationType, PixelDimensions, PointDimensions, }; +use egui::{ColorImage, TextureHandle}; pub use images::ImageType; pub use renderable::RenderableMedia; @@ -30,3 +31,22 @@ impl AnimationMode { !matches!(self, Self::NoAnimation) } } + +// max size wgpu can handle without panicing +pub const MAX_SIZE_WGPU: usize = 8192; + +pub fn load_texture_checked( + ctx: &egui::Context, + name: impl Into<String>, + image: ColorImage, + options: egui::TextureOptions, +) -> TextureHandle { + let size = image.size; + + if size[0] > MAX_SIZE_WGPU || size[1] > MAX_SIZE_WGPU { + panic!("The image MUST be less than or equal to {MAX_SIZE_WGPU} pixels in each direction"); + } + + #[allow(clippy::disallowed_methods, reason = "centralized safe wrapper")] + ctx.load_texture(name, image, options) +} diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs @@ -577,7 +577,7 @@ impl TimelineKind { Some(Timeline::new( TimelineKind::notifications(pk), FilterState::ready(vec![notifications_filter]), - TimelineTab::only_notes_and_replies(), + TimelineTab::notifications(), )) } diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs @@ -40,12 +40,15 @@ pub use note_units::{CompositeType, InsertionResponse, NoteUnits}; pub use timeline_units::{TimelineUnits, UnknownPks}; pub use unit::{CompositeUnit, NoteUnit, ReactionUnit, RepostUnit}; -#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] +#[derive(Copy, Clone, Eq, PartialEq, Debug, Default, PartialOrd, Ord)] pub enum ViewFilter { + MentionsOnly, Notes, #[default] NotesAndReplies, + + All, } impl ViewFilter { @@ -59,6 +62,10 @@ impl ViewFilter { "Filter label for notes and replies view" ) } + ViewFilter::All => tr!(i18n, "All", "Filter label for all notes view"), + ViewFilter::MentionsOnly => { + tr!(i18n, "Mentions", "Filter label for mentions only view") + } } } @@ -70,10 +77,26 @@ impl ViewFilter { true } + fn notes_and_replies(_cache: &CachedNote, note: &Note) -> bool { + note.kind() == 1 || note.kind() == 6 + } + + fn mentions_only(cache: &CachedNote, note: &Note) -> bool { + if note.kind() != 1 { + return false; + } + + let note_reply = cache.reply.borrow(note.tags()); + + note_reply.is_reply() || note_reply.mention().is_some() + } + pub fn filter(&self) -> fn(&CachedNote, &Note) -> bool { match self { ViewFilter::Notes => ViewFilter::filter_notes, - ViewFilter::NotesAndReplies => ViewFilter::identity, + ViewFilter::NotesAndReplies => ViewFilter::notes_and_replies, + ViewFilter::All => ViewFilter::identity, + ViewFilter::MentionsOnly => ViewFilter::mentions_only, } } } @@ -111,6 +134,13 @@ impl TimelineTab { ] } + pub fn notifications() -> Vec<Self> { + vec![ + TimelineTab::new(ViewFilter::All), + TimelineTab::new(ViewFilter::MentionsOnly), + ] + } + pub fn new_with_capacity(filter: ViewFilter, cap: usize) -> Self { let selection = 0i32; let mut list = VirtualList::new(); @@ -298,14 +328,17 @@ impl Timeline { &mut self.views[self.selected_view] } - /// Get the note refs for NotesAndReplies. If we only have Notes, then - /// just return that instead + /// Get the note refs for the filter with the widest scope pub fn all_or_any_entries(&self) -> &TimelineUnits { - self.entries(ViewFilter::NotesAndReplies) - .unwrap_or_else(|| { - self.entries(ViewFilter::Notes) - .expect("should have at least notes") - }) + let widest_filter = self + .views + .iter() + .map(|view| view.filter) + .max() + .expect("at least one filter exists"); + + self.entries(widest_filter) + .expect("should have at least notes") } pub fn entries(&self, view: ViewFilter) -> Option<&TimelineUnits> { @@ -409,38 +442,25 @@ impl Timeline { } for view in &mut self.views { - match view.filter { - ViewFilter::NotesAndReplies => { - let res: Vec<&NotePayload<'_>> = payloads.iter().collect(); - if let Some(res) = - view.insert(res, ndb, txn, reversed, self.enable_front_insert) - { - res.process(unknown_ids, ndb, txn); - } - } - - ViewFilter::Notes => { - let mut filtered_payloads = Vec::with_capacity(payloads.len()); - for payload in &payloads { - let cached_note = - note_cache.cached_note_or_insert(payload.key, &payload.note); + let should_include = view.filter.filter(); + let mut filtered_payloads = Vec::with_capacity(payloads.len()); + for payload in &payloads { + let cached_note = note_cache.cached_note_or_insert(payload.key, &payload.note); - if ViewFilter::filter_notes(cached_note, &payload.note) { - filtered_payloads.push(payload); - } - } - - if let Some(res) = view.insert( - filtered_payloads, - ndb, - txn, - reversed, - self.enable_front_insert, - ) { - res.process(unknown_ids, ndb, txn); - } + if should_include(cached_note, &payload.note) { + filtered_payloads.push(payload); } } + + if let Some(res) = view.insert( + filtered_payloads, + ndb, + txn, + reversed, + self.enable_front_insert, + ) { + res.process(unknown_ids, ndb, txn); + } } Ok(()) diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -15,7 +15,7 @@ use tracing::{error, warn}; use crate::nav::BodyResponse; use crate::timeline::{ CompositeType, CompositeUnit, NoteUnit, ReactionUnit, RepostUnit, TimelineCache, TimelineKind, - TimelineTab, ViewFilter, + TimelineTab, }; use notedeck::{ note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo, @@ -303,16 +303,7 @@ pub fn tabs_ui( let ind = state.index(); - let txt = match views[ind as usize].filter { - ViewFilter::Notes => tr!(i18n, "Notes", "Label for notes-only filter"), - ViewFilter::NotesAndReplies => { - tr!( - i18n, - "Notes & Replies", - "Label for notes and replies filter" - ) - } - }; + let txt = views[ind as usize].filter.name(i18n); let res = ui.add(egui::Label::new(txt.clone()).selectable(false)); diff --git a/crates/notedeck_columns/src/ui/wallet.rs b/crates/notedeck_columns/src/ui/wallet.rs @@ -402,7 +402,9 @@ fn show_default_zap( notedeck_ui::include_input(ui, &r); } - ui.memory_mut(|m| m.request_focus(id)); + if !notedeck::ui::is_narrow(ui.ctx()) { // TODO: this should really be checking if we are using a virtual keyboard instead of narrow + ui.memory_mut(|m| m.request_focus(id)); + } ui.label(tr!(i18n, "sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings."));