Compare commits

...

6 commits

Author SHA1 Message Date
2c5217e744 api: fix unread detection broken by substring match
The check `.contains("user/-/state/com.google/read")` was a substring
match, which also matched "user/-/state/com.google/reading-list" — a
category present on every article fetched from the reading list. This
caused all articles to be treated as read, so nothing ever appeared
bold in the sidebar.

Fix by using == for exact string comparison.
2026-03-22 01:58:51 +00:00
9a4bf4b9f8 article-row: fix bold on initial load, add right-click menu
Set unread bold state directly in bind() instead of relying on
obj.notify("unread"), which was unreliable during list factory binding
(GLib may defer or drop notifications during initial bind).

Also add a right-click context menu on each article row with a single
"Mark as Unread" item. The menu is a GtkPopover positioned at the
cursor. Clicking it activates the new win.mark-article-unread action,
which takes the article ID as a string parameter and reuses the
existing mark-unread logic.

Refactor do_mark_unread() to delegate to the new do_mark_article_unread()
so the behaviour is consistent whether triggered from the toolbar button,
keyboard shortcut, or right-click menu.
2026-03-22 01:51:12 +00:00
571d80fa6b api: decode HTML entities in article excerpts 2026-03-22 01:17:49 +00:00
81439edf87 sidebar: use Pango markup for bold, fix zoom CSS specificity 2026-03-22 00:40:20 +00:00
e5f2d5c941 filters: reload current article immediately when preferences closes 2026-03-22 00:37:10 +00:00
b63549ae0a sidebar: fix unread bold via notify, larger title font, sidebar zoom 2026-03-22 00:34:51 +00:00
6 changed files with 135 additions and 28 deletions

View file

@ -32,6 +32,7 @@ template $ArticleRow : Gtk.Box {
wrap: true;
lines: 2;
ellipsize: end;
styles ["article-title"]
}
Label excerpt_label {

View file

@ -46,6 +46,9 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
<property name="wrap">true</property>
<property name="lines">2</property>
<property name="ellipsize">3</property>
<style>
<class name="article-title"/>
</style>
</object>
</child>
<child>

View file

@ -148,7 +148,7 @@ impl Api {
Ok(stream.items.into_iter().map(|item| {
let unread = !item.categories.as_deref().unwrap_or_default()
.iter()
.any(|c| c.contains("user/-/state/com.google/read"));
.any(|c| c == "user/-/state/com.google/read");
let content = item.summary
.as_ref()
.and_then(|s| s.content.clone())
@ -249,9 +249,20 @@ fn plain_text_excerpt(html: &str, max_chars: usize) -> String {
}
}
let collapsed: String = out.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= max_chars {
collapsed
let decoded = decode_html_entities(&collapsed);
if decoded.chars().count() <= max_chars {
decoded
} else {
collapsed.chars().take(max_chars).collect::<String>() + ""
decoded.chars().take(max_chars).collect::<String>() + ""
}
}
fn decode_html_entities(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
}

View file

@ -71,8 +71,8 @@ mod imp {
.sidebar-content row:selected {
background-color: alpha(@window_fg_color, 0.22);
}
.unread-title {
font-weight: bold;
.article-title {
font-size: 1.05em;
}"
);
gtk4::style_context_add_provider_for_display(

View file

@ -44,6 +44,7 @@ mod imp {
pub bindings: RefCell<Vec<glib::Binding>>,
pub unread_handler: RefCell<Option<(ArticleObject, glib::SignalHandlerId)>>,
pub context_menu: std::cell::OnceCell<gtk4::Popover>,
}
#[glib::object_subclass]
@ -61,32 +62,98 @@ mod imp {
}
}
impl ObjectImpl for ArticleRow {}
impl ObjectImpl for ArticleRow {
fn constructed(&self) {
self.parent_constructed();
self.setup_context_menu();
}
fn dispose(&self) {
if let Some(popover) = self.context_menu.get() {
popover.unparent();
}
}
}
impl WidgetImpl for ArticleRow {}
impl BoxImpl for ArticleRow {}
impl ArticleRow {
fn setup_context_menu(&self) {
let button = gtk4::Button::with_label("Mark as Unread");
button.set_has_frame(false);
let popover = gtk4::Popover::new();
popover.set_child(Some(&button));
popover.set_parent(&*self.obj());
self.context_menu.set(popover.clone()).ok();
// Close popover and activate action when button is clicked.
let imp_weak = self.downgrade();
let popover_weak = popover.downgrade();
button.connect_clicked(move |_| {
if let Some(popover) = popover_weak.upgrade() {
popover.popdown();
}
let Some(imp) = imp_weak.upgrade() else { return };
let handler = imp.unread_handler.borrow();
let Some((obj, _)) = handler.as_ref() else { return };
let article_id = obj.article().id.clone();
drop(handler);
imp.obj()
.activate_action(
"win.mark-article-unread",
Some(&article_id.to_variant()),
)
.ok();
});
// Right-click gesture to show the popover at cursor position.
let gesture = gtk4::GestureClick::new();
gesture.set_button(3);
let popover_weak2 = popover.downgrade();
gesture.connect_pressed(move |gesture, _, x, y| {
gesture.set_state(gtk4::EventSequenceState::Claimed);
let Some(popover) = popover_weak2.upgrade() else { return };
popover.set_pointing_to(Some(&gtk4::gdk::Rectangle::new(
x as i32,
y as i32,
1,
1,
)));
popover.popup();
});
self.obj().add_controller(gesture);
}
pub fn bind(&self, obj: &ArticleObject) {
let article = obj.article();
self.feed_title_label.set_text(&article.feed_title);
self.title_label.set_text(&article.title);
self.excerpt_label.set_text(&article.excerpt);
self.date_label.set_text(&relative_time(article.published));
self.update_read_style(article.unread);
// Set initial bold state directly (using Pango markup to avoid
// CSS specificity issues with the zoom font-size provider).
let escaped = glib::markup_escape_text(&article.title);
if article.unread {
self.title_label.remove_css_class("dim-label");
self.title_label.set_markup(&format!("<b>{escaped}</b>"));
} else {
self.title_label.add_css_class("dim-label");
self.title_label.set_markup(&escaped);
}
drop(article);
// Watch for unread state changes
// Connect handler for future unread state changes.
let title_label = self.title_label.clone();
let id = obj.connect_notify_local(Some("unread"), move |obj, _| {
let unread = obj.article().unread;
if unread {
let article = obj.article();
let escaped = glib::markup_escape_text(&article.title);
if article.unread {
title_label.remove_css_class("dim-label");
title_label.add_css_class("unread-title");
title_label.set_markup(&format!("<b>{escaped}</b>"));
} else {
title_label.add_css_class("dim-label");
title_label.remove_css_class("unread-title");
title_label.set_markup(&escaped);
}
});
*self.unread_handler.borrow_mut() = Some((obj.clone(), id));
@ -100,16 +167,6 @@ mod imp {
b.unbind();
}
}
fn update_read_style(&self, unread: bool) {
if unread {
self.title_label.remove_css_class("dim-label");
self.title_label.add_css_class("unread-title");
} else {
self.title_label.add_css_class("dim-label");
self.title_label.remove_css_class("unread-title");
}
}
}
}

View file

@ -77,6 +77,11 @@ pub mod imp {
klass.install_action("win.reload", None, |win, _, _| win.imp().do_reload());
klass.install_action("win.logout", None, |win, _, _| win.imp().do_logout());
klass.install_action("win.mark-unread", None, |win, _, _| win.imp().do_mark_unread());
klass.install_action("win.mark-article-unread", Some(glib::VariantTy::STRING), |win, _, param| {
if let Some(id) = param.and_then(|p| p.get::<String>()) {
win.imp().do_mark_article_unread(id);
}
});
klass.install_action("win.open-in-browser", None, |win, _, _| {
win.imp().do_open_in_browser()
});
@ -101,6 +106,14 @@ pub mod imp {
});
klass.install_action("win.preferences", None, |win, _, _| {
let dialog = crate::preferences_dialog::PreferencesDialog::new();
let win_weak = win.downgrade();
dialog.connect_closed(move |_| {
if let Some(win) = win_weak.upgrade() {
let imp = win.imp();
*imp.filter_rules.borrow_mut() = crate::filters::load_rules();
imp.reload_current_article();
}
});
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
});
}
@ -234,6 +247,21 @@ pub mod imp {
obj.set_unread(false);
}
fn reload_current_article(&self) {
let id = self.current_article_id.borrow().clone();
let Some(id) = id else { return };
if let Some(store) = self.article_store.borrow().as_ref() {
for i in 0..store.n_items() {
if let Some(obj) = store.item(i).and_downcast::<ArticleObject>() {
if obj.article().id == id {
self.load_article_in_webview(&obj.article().clone());
break;
}
}
}
}
}
fn load_article_in_webview(&self, article: &crate::model::Article) {
let rules = self.filter_rules.borrow();
let content = crate::filters::apply(&rules, &article.id, &article.link, &article.content);
@ -727,8 +755,11 @@ pub mod imp {
fn do_mark_unread(&self) {
let id = self.current_article_id.borrow().clone();
let Some(id) = id else { return };
self.do_mark_article_unread(id);
}
// Find the ArticleObject in the store and set unread=true
fn do_mark_article_unread(&self, id: String) {
// Find the ArticleObject in the store and set unread=true.
if let Some(store) = self.article_store.borrow().as_ref() {
for i in 0..store.n_items() {
if let Some(obj) = store.item(i).and_downcast::<ArticleObject>() {
@ -739,7 +770,11 @@ pub mod imp {
}
}
// If this is the currently displayed article, guard against it
// being immediately re-marked read when the selection fires.
if self.current_article_id.borrow().as_deref() == Some(&*id) {
*self.mark_unread_guard.borrow_mut() = true;
}
let api = self.api.borrow().clone();
let wt = self.write_token.borrow().clone();
@ -808,7 +843,7 @@ pub mod imp {
fn update_sidebar_zoom(&self, level: f64) {
if let Some(css) = self.sidebar_zoom_css.get() {
css.load_from_string(&format!(
".sidebar-content label {{ font-size: {level}em; }}"
".sidebar-content {{ font-size: {level}em; }}"
));
}
}