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:
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."));