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.
886 lines
40 KiB
Rust
886 lines
40 KiB
Rust
use gtk4::prelude::*;
|
|
use gtk4::subclass::prelude::*;
|
|
use gtk4::{gio, glib};
|
|
use webkit6::prelude::{PolicyDecisionExt, WebViewExt};
|
|
|
|
glib::wrapper! {
|
|
pub struct FeedTheMonkeyWindow(ObjectSubclass<imp::FeedTheMonkeyWindow>)
|
|
@extends libadwaita::ApplicationWindow, gtk4::ApplicationWindow, gtk4::Window, gtk4::Widget,
|
|
@implements gio::ActionGroup, gio::ActionMap, gtk4::Accessible, gtk4::Buildable,
|
|
gtk4::ConstraintTarget, gtk4::Native, gtk4::Root, gtk4::ShortcutManager;
|
|
}
|
|
|
|
impl FeedTheMonkeyWindow {
|
|
pub fn new(app: &libadwaita::Application) -> Self {
|
|
glib::Object::builder()
|
|
.property("application", app)
|
|
.build()
|
|
}
|
|
}
|
|
|
|
pub mod imp {
|
|
use super::*;
|
|
use crate::api::Api;
|
|
use crate::credentials;
|
|
use crate::login_dialog::LoginDialog;
|
|
use crate::model::ArticleObject;
|
|
use gtk4::CompositeTemplate;
|
|
use libadwaita::prelude::*;
|
|
use libadwaita::subclass::prelude::*;
|
|
use std::cell::RefCell;
|
|
|
|
#[derive(CompositeTemplate, Default)]
|
|
#[template(resource = "/net/jeena/FeedTheMonkey/ui/window.ui")]
|
|
pub struct FeedTheMonkeyWindow {
|
|
#[template_child]
|
|
pub toast_overlay: TemplateChild<libadwaita::ToastOverlay>,
|
|
#[template_child]
|
|
pub paned: TemplateChild<gtk4::Paned>,
|
|
#[template_child]
|
|
pub sidebar_toolbar: TemplateChild<libadwaita::ToolbarView>,
|
|
#[template_child]
|
|
pub refresh_stack: TemplateChild<gtk4::Stack>,
|
|
#[template_child]
|
|
pub refresh_button: TemplateChild<gtk4::Button>,
|
|
#[template_child]
|
|
pub article_menu_button: TemplateChild<gtk4::MenuButton>,
|
|
#[template_child]
|
|
pub sidebar_content: TemplateChild<gtk4::Stack>,
|
|
#[template_child]
|
|
pub article_list_view: TemplateChild<gtk4::ListView>,
|
|
#[template_child]
|
|
pub content_stack: TemplateChild<gtk4::Stack>,
|
|
#[template_child]
|
|
pub web_view: TemplateChild<webkit6::WebView>,
|
|
#[template_child]
|
|
pub error_status: TemplateChild<libadwaita::StatusPage>,
|
|
|
|
pub filter_rules: RefCell<Vec<crate::filters::Rule>>,
|
|
pub api: RefCell<Option<Api>>,
|
|
pub write_token: RefCell<Option<String>>,
|
|
pub article_store: RefCell<Option<gio::ListStore>>,
|
|
pub selection: RefCell<Option<gtk4::SingleSelection>>,
|
|
pub current_article_id: RefCell<Option<String>>,
|
|
pub mark_unread_guard: RefCell<bool>,
|
|
pub pending_restore_id: RefCell<Option<String>>,
|
|
pub sidebar_zoom_css: std::cell::OnceCell<gtk4::CssProvider>,
|
|
}
|
|
|
|
#[glib::object_subclass]
|
|
impl ObjectSubclass for FeedTheMonkeyWindow {
|
|
const NAME: &'static str = "FeedTheMonkeyWindow";
|
|
type Type = super::FeedTheMonkeyWindow;
|
|
type ParentType = libadwaita::ApplicationWindow;
|
|
|
|
fn class_init(klass: &mut Self::Class) {
|
|
klass.bind_template();
|
|
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()
|
|
});
|
|
klass.install_action("win.next-article", None, |win, _, _| {
|
|
win.imp().navigate_by(1)
|
|
});
|
|
klass.install_action("win.prev-article", None, |win, _, _| {
|
|
win.imp().navigate_by(-1)
|
|
});
|
|
klass.install_action("win.zoom-in", None, |win, _, _| win.imp().zoom(1.1));
|
|
klass.install_action("win.zoom-out", None, |win, _, _| win.imp().zoom(1.0 / 1.1));
|
|
klass.install_action("win.zoom-reset", None, |win, _, _| win.imp().zoom_reset());
|
|
klass.install_action("win.toggle-fullscreen", None, |win, _, _| {
|
|
if win.is_fullscreen() {
|
|
win.unfullscreen();
|
|
} else {
|
|
win.fullscreen();
|
|
}
|
|
});
|
|
klass.install_action("win.toggle-sidebar", None, |win, _, _| {
|
|
win.imp().do_toggle_sidebar();
|
|
});
|
|
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>()));
|
|
});
|
|
}
|
|
|
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
|
obj.init_template();
|
|
}
|
|
}
|
|
|
|
impl ObjectImpl for FeedTheMonkeyWindow {
|
|
fn constructed(&self) {
|
|
self.parent_constructed();
|
|
self.setup_window_state();
|
|
self.setup_list();
|
|
self.setup_webview();
|
|
self.setup_sidebar_toggle();
|
|
self.setup_capture_keys();
|
|
self.restore_from_cache();
|
|
self.auto_login();
|
|
}
|
|
}
|
|
|
|
impl FeedTheMonkeyWindow {
|
|
// ── Window state ─────────────────────────────────────────────────────
|
|
|
|
fn setup_window_state(&self) {
|
|
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
|
let window = self.obj();
|
|
window.set_title(Some("FeedTheMonkey"));
|
|
let w = settings.int("window-width");
|
|
let h = settings.int("window-height");
|
|
window.set_default_size(w, h);
|
|
if settings.boolean("window-maximized") {
|
|
window.maximize();
|
|
}
|
|
|
|
self.paned.set_position(settings.int("sidebar-width"));
|
|
let zoom = settings.double("zoom-level");
|
|
self.web_view.set_zoom_level(zoom);
|
|
|
|
// Set up sidebar font zoom CSS provider
|
|
let zoom_css = gtk4::CssProvider::new();
|
|
gtk4::style_context_add_provider_for_display(
|
|
>k4::gdk::Display::default().unwrap(),
|
|
&zoom_css,
|
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
);
|
|
self.sidebar_zoom_css.set(zoom_css).ok();
|
|
self.update_sidebar_zoom(zoom);
|
|
|
|
// Persist sidebar width while dragging (only when visible)
|
|
let s2 = settings.clone();
|
|
let sidebar_weak = self.sidebar_toolbar.downgrade();
|
|
self.paned.connect_notify_local(Some("position"), move |paned, _| {
|
|
if sidebar_weak.upgrade().map(|s| s.is_visible()).unwrap_or(false) {
|
|
s2.set_int("sidebar-width", paned.position()).ok();
|
|
}
|
|
});
|
|
|
|
let s = settings.clone();
|
|
window.connect_close_request(move |win| {
|
|
if !win.is_maximized() {
|
|
s.set_int("window-width", win.width()).ok();
|
|
s.set_int("window-height", win.height()).ok();
|
|
}
|
|
s.set_boolean("window-maximized", win.is_maximized()).ok();
|
|
glib::Propagation::Proceed
|
|
});
|
|
}
|
|
|
|
// ── List view ─────────────────────────────────────────────────────────
|
|
|
|
fn setup_list(&self) {
|
|
let store = gio::ListStore::new::<ArticleObject>();
|
|
let selection = gtk4::SingleSelection::new(Some(store.clone()));
|
|
*self.article_store.borrow_mut() = Some(store);
|
|
|
|
let factory = gtk4::SignalListItemFactory::new();
|
|
factory.connect_setup(|_, item| {
|
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
let row = crate::article_row::ArticleRow::new();
|
|
item.set_child(Some(&row));
|
|
});
|
|
factory.connect_bind(|_, item| {
|
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
if let Some(obj) = item.item().and_downcast::<ArticleObject>() {
|
|
let row = item.child().and_downcast::<crate::article_row::ArticleRow>().unwrap();
|
|
row.bind(&obj);
|
|
}
|
|
});
|
|
factory.connect_unbind(|_, item| {
|
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
if let Some(row) = item.child().and_downcast::<crate::article_row::ArticleRow>() {
|
|
row.unbind();
|
|
}
|
|
});
|
|
|
|
self.article_list_view.set_factory(Some(&factory));
|
|
self.article_list_view.set_model(Some(&selection));
|
|
|
|
let win_weak = self.obj().downgrade();
|
|
selection.connect_selected_item_notify(move |sel| {
|
|
if let Some(win) = win_weak.upgrade() {
|
|
if let Some(obj) = sel.selected_item().and_downcast::<ArticleObject>() {
|
|
win.imp().on_article_selected(obj);
|
|
}
|
|
}
|
|
});
|
|
|
|
*self.selection.borrow_mut() = Some(selection);
|
|
}
|
|
|
|
fn on_article_selected(&self, obj: ArticleObject) {
|
|
// Mark previous article as read (unless guard is set)
|
|
if !*self.mark_unread_guard.borrow() {
|
|
if let Some(prev_id) = self.current_article_id.borrow().clone() {
|
|
if prev_id != obj.article().id {
|
|
self.bg_mark_read(prev_id);
|
|
}
|
|
}
|
|
}
|
|
*self.mark_unread_guard.borrow_mut() = false;
|
|
|
|
let article = obj.article().clone();
|
|
*self.current_article_id.borrow_mut() = Some(article.id.clone());
|
|
|
|
self.article_menu_button.set_visible(true);
|
|
|
|
// Load in webview
|
|
self.load_article_in_webview(&article);
|
|
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);
|
|
let json = serde_json::json!({
|
|
"id": article.id,
|
|
"title": article.title,
|
|
"feed_title": article.feed_title,
|
|
"link": article.link,
|
|
"updated": article.published,
|
|
"content": content,
|
|
"author": article.author,
|
|
"unread": article.unread,
|
|
});
|
|
let js = format!("window.setArticle({})", json);
|
|
self.web_view.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
|
|
self.content_stack.set_visible_child_name("webview");
|
|
}
|
|
|
|
// ── WebView ───────────────────────────────────────────────────────────
|
|
|
|
fn setup_webview(&self) {
|
|
let wv = &*self.web_view;
|
|
if let Some(ctx) = wv.web_context() {
|
|
crate::image_cache::register_scheme(&ctx);
|
|
}
|
|
|
|
// Load content.html from GResource, inlining the CSS so WebKit
|
|
// doesn't need to fetch it over a custom scheme.
|
|
let load = |path: &str| {
|
|
String::from_utf8(
|
|
gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE)
|
|
.unwrap()
|
|
.to_vec(),
|
|
)
|
|
.unwrap()
|
|
};
|
|
let css = load("/net/jeena/FeedTheMonkey/html/content.css");
|
|
let html = load("/net/jeena/FeedTheMonkey/html/content.html")
|
|
.replace("/*INJECT_CSS*/", &css);
|
|
wv.load_html(&html, Some("feedthemonkey://localhost/"));
|
|
|
|
// Apply Adwaita color scheme: set data-dark on <html> so CSS
|
|
// custom properties (--bg, --fg, etc.) resolve to the right values.
|
|
let style_manager = libadwaita::StyleManager::default();
|
|
let wv_weak = wv.downgrade();
|
|
let apply_scheme = move |sm: &libadwaita::StyleManager| {
|
|
let is_dark = sm.is_dark();
|
|
let js = format!("setDark({})", if is_dark { "true" } else { "false" });
|
|
if let Some(wv) = wv_weak.upgrade() {
|
|
wv.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
|
|
}
|
|
};
|
|
// Apply now (after load-changed fires, see below) and on every change
|
|
let sm_clone = style_manager.clone();
|
|
let wv_weak2 = wv.downgrade();
|
|
let win_weak = self.obj().downgrade();
|
|
wv.connect_load_changed(move |_, event| {
|
|
if event == webkit6::LoadEvent::Finished {
|
|
apply_scheme(&sm_clone);
|
|
// Restore cached article now that window.setArticle() exists.
|
|
if let Some(win) = win_weak.upgrade() {
|
|
let imp = win.imp();
|
|
if let Some(id) = imp.pending_restore_id.borrow_mut().take() {
|
|
if let Some(store) = imp.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 {
|
|
let article = obj.article().clone();
|
|
*imp.current_article_id.borrow_mut() = Some(article.id.clone());
|
|
imp.article_menu_button.set_visible(true);
|
|
imp.load_article_in_webview(&article);
|
|
imp.content_stack.set_visible_child_name("webview");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let wv_weak3 = wv.downgrade();
|
|
style_manager.connect_notify_local(Some("dark"), move |sm, _| {
|
|
let is_dark = sm.is_dark();
|
|
let js = format!("setDark({})", if is_dark { "true" } else { "false" });
|
|
if let Some(wv) = wv_weak3.upgrade() {
|
|
wv.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
|
|
}
|
|
});
|
|
let _ = wv_weak2; // suppress unused warning
|
|
|
|
// Disable the default WebKit context menu (right-click) — the
|
|
// reload/inspect items don't make sense in an embedded reader.
|
|
wv.connect_context_menu(|_, _, _| true);
|
|
|
|
// Handle navigation policy
|
|
let win_weak = self.obj().downgrade();
|
|
wv.connect_decide_policy(move |_, decision, decision_type| {
|
|
if decision_type != webkit6::PolicyDecisionType::NavigationAction {
|
|
return false;
|
|
}
|
|
let nav = decision.downcast_ref::<webkit6::NavigationPolicyDecision>().unwrap();
|
|
let uri = nav.navigation_action()
|
|
.and_then(|a| a.request())
|
|
.and_then(|r| r.uri())
|
|
.unwrap_or_default();
|
|
|
|
if uri.starts_with("feedthemonkey://localhost/") || uri.is_empty() {
|
|
return false; // allow initial load
|
|
}
|
|
|
|
// Handle in-page keyboard navigation commands (not user-gesture links)
|
|
if uri.starts_with("feedthemonkey:") {
|
|
nav.ignore();
|
|
if let Some(win) = win_weak.upgrade() {
|
|
match uri.as_str() {
|
|
"feedthemonkey:previous" => win.imp().navigate_by(-1),
|
|
"feedthemonkey:next" => win.imp().navigate_by(1),
|
|
"feedthemonkey:open" => win.imp().do_open_in_browser(),
|
|
_ => {}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Only open external URLs in the browser when the user explicitly
|
|
// clicked a link (NavigationType::LinkClicked). Everything else —
|
|
// iframe/embed loads, programmatic navigation — stays in the WebView.
|
|
let is_link_click = nav.navigation_action()
|
|
.map(|a| a.navigation_type() == webkit6::NavigationType::LinkClicked)
|
|
.unwrap_or(false);
|
|
|
|
if is_link_click {
|
|
nav.ignore();
|
|
open_uri(&uri);
|
|
true
|
|
} else {
|
|
false // let iframes/embeds load normally
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Sidebar toggle ────────────────────────────────────────────────────
|
|
|
|
fn setup_sidebar_toggle(&self) {}
|
|
|
|
fn do_toggle_sidebar(&self) {
|
|
let sidebar = &*self.sidebar_toolbar;
|
|
if sidebar.is_visible() {
|
|
sidebar.set_visible(false);
|
|
} else {
|
|
sidebar.set_visible(true);
|
|
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
|
let saved = settings.int("sidebar-width");
|
|
self.paned.set_position(if saved > 0 { saved } else { 280 });
|
|
}
|
|
}
|
|
|
|
// ── Capture-phase key handler ─────────────────────────────────────────
|
|
// ShortcutScope::Global doesn't reliably intercept keys when a ListView
|
|
// has focus (the list view consumes them first). A Capture-phase
|
|
// EventControllerKey on the window fires before any widget sees the event.
|
|
|
|
fn setup_capture_keys(&self) {
|
|
let controller = gtk4::EventControllerKey::new();
|
|
controller.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
|
let win_weak = self.obj().downgrade();
|
|
controller.connect_key_pressed(move |_, key, _, mods| {
|
|
use gtk4::gdk::Key;
|
|
if !mods.is_empty() {
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
let Some(win) = win_weak.upgrade() else {
|
|
return glib::Propagation::Proceed;
|
|
};
|
|
// Don't steal keys from text inputs (login dialog, etc.)
|
|
let focused: Option<gtk4::Widget> = gtk4::prelude::GtkWindowExt::focus(&win);
|
|
if focused.is_some_and(|w| {
|
|
w.is::<gtk4::Text>() || w.is::<gtk4::Entry>() || w.is::<gtk4::SearchEntry>()
|
|
}) {
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
match key {
|
|
Key::Left | Key::k => {
|
|
win.imp().navigate_by(-1);
|
|
glib::Propagation::Stop
|
|
}
|
|
Key::Right | Key::j => {
|
|
win.imp().navigate_by(1);
|
|
glib::Propagation::Stop
|
|
}
|
|
_ => glib::Propagation::Proceed,
|
|
}
|
|
});
|
|
self.obj().add_controller(controller);
|
|
}
|
|
|
|
// ── Cache ─────────────────────────────────────────────────────────────
|
|
|
|
fn restore_from_cache(&self) {
|
|
let Some(cache) = crate::cache::load() else { return };
|
|
if cache.articles.is_empty() { return; }
|
|
|
|
let store = self.article_store.borrow();
|
|
let store = store.as_ref().unwrap();
|
|
|
|
for a in &cache.articles {
|
|
store.append(&ArticleObject::new(a.clone()));
|
|
}
|
|
|
|
// Find and scroll to the previously selected item immediately so
|
|
// the list looks right, but defer loading the article into the
|
|
// WebView until the base HTML has finished loading (see setup_webview).
|
|
let mut select_idx = 0u32;
|
|
if !cache.selected_id.is_empty() {
|
|
for i in 0..store.n_items() {
|
|
if store.item(i).and_downcast::<ArticleObject>()
|
|
.map(|o| o.article().id == cache.selected_id)
|
|
.unwrap_or(false)
|
|
{
|
|
select_idx = i;
|
|
break;
|
|
}
|
|
}
|
|
*self.pending_restore_id.borrow_mut() = Some(cache.selected_id);
|
|
}
|
|
|
|
let sel = self.selection.borrow();
|
|
let sel = sel.as_ref().unwrap();
|
|
*self.mark_unread_guard.borrow_mut() = true;
|
|
sel.set_selected(select_idx);
|
|
self.article_list_view.scroll_to(select_idx, gtk4::ListScrollFlags::SELECT, None);
|
|
self.sidebar_content.set_visible_child_name("list");
|
|
}
|
|
|
|
fn save_cache(&self) {
|
|
let store = self.article_store.borrow();
|
|
let Some(store) = store.as_ref() else { return };
|
|
let articles: Vec<crate::model::Article> = (0..store.n_items())
|
|
.filter_map(|i| store.item(i).and_downcast::<ArticleObject>())
|
|
.map(|o| o.article().clone())
|
|
.collect();
|
|
let selected_id = self.current_article_id.borrow().clone().unwrap_or_default();
|
|
crate::cache::save(&articles, &selected_id);
|
|
}
|
|
|
|
// ── Login ─────────────────────────────────────────────────────────────
|
|
|
|
fn auto_login(&self) {
|
|
if let Some((server_url, username, password)) = credentials::load_credentials() {
|
|
self.do_login(server_url, username, password, false);
|
|
} else {
|
|
self.show_login_dialog();
|
|
}
|
|
}
|
|
|
|
fn show_login_dialog(&self) {
|
|
let dialog = LoginDialog::new();
|
|
let win = self.obj();
|
|
let win_weak = win.downgrade();
|
|
dialog.connect_local("logged-in", false, move |args| {
|
|
let server_url = args[1].get::<String>().unwrap();
|
|
let username = args[2].get::<String>().unwrap();
|
|
let password = args[3].get::<String>().unwrap();
|
|
if let Some(win) = win_weak.upgrade() {
|
|
win.imp().do_login(server_url, username, password, true);
|
|
}
|
|
None
|
|
});
|
|
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
|
|
}
|
|
|
|
fn do_login(&self, server_url: String, username: String, password: String, store: bool) {
|
|
let is_auto = !store;
|
|
let win_weak = self.obj().downgrade();
|
|
crate::runtime::spawn(
|
|
async move { Api::login(&server_url, &username, &password).await
|
|
.map(|api| (api, server_url, username, password)) },
|
|
move |result| {
|
|
let Some(win) = win_weak.upgrade() else { return };
|
|
match result {
|
|
Ok((api, server_url, username, password)) => {
|
|
if store {
|
|
credentials::store_credentials(&server_url, &username, &password);
|
|
}
|
|
// Fetch write token in background (non-critical)
|
|
let api_clone = api.clone();
|
|
let win_weak2 = win.downgrade();
|
|
crate::runtime::spawn(
|
|
async move { api_clone.fetch_write_token().await },
|
|
move |wt_result| {
|
|
if let Some(win) = win_weak2.upgrade() {
|
|
match wt_result {
|
|
Ok(wt) => *win.imp().write_token.borrow_mut() = Some(wt),
|
|
Err(e) => eprintln!("Write token error: {e}"),
|
|
}
|
|
}
|
|
},
|
|
);
|
|
*win.imp().api.borrow_mut() = Some(api);
|
|
win.imp().fetch_articles();
|
|
}
|
|
Err(e) => {
|
|
let has_cache = win.imp().article_store.borrow()
|
|
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
|
|
if is_auto && has_cache {
|
|
// Offline with cached articles — just show a toast.
|
|
let toast = libadwaita::Toast::new("Offline — showing cached articles");
|
|
win.imp().toast_overlay.add_toast(toast);
|
|
} else {
|
|
win.imp().show_login_dialog();
|
|
win.imp().show_error_dialog("Login Failed", &e);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn show_error_dialog(&self, title: &str, body: &str) {
|
|
let dialog = libadwaita::AlertDialog::new(Some(title), Some(body));
|
|
dialog.add_response("ok", "_OK");
|
|
dialog.set_default_response(Some("ok"));
|
|
dialog.present(Some(self.obj().upcast_ref::<gtk4::Widget>()));
|
|
}
|
|
|
|
fn do_logout(&self) {
|
|
let win_weak = self.obj().downgrade();
|
|
let dialog = libadwaita::AlertDialog::new(
|
|
Some("Log Out?"),
|
|
Some("Are you sure you want to log out?"),
|
|
);
|
|
dialog.add_response("cancel", "_Cancel");
|
|
dialog.add_response("logout", "_Log Out");
|
|
dialog.set_response_appearance("logout", libadwaita::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.connect_response(None, move |_, response| {
|
|
if response == "logout" {
|
|
if let Some(win) = win_weak.upgrade() {
|
|
credentials::clear_credentials();
|
|
*win.imp().api.borrow_mut() = None;
|
|
*win.imp().write_token.borrow_mut() = None;
|
|
if let Some(store) = win.imp().article_store.borrow().as_ref() {
|
|
store.remove_all();
|
|
}
|
|
win.imp().sidebar_content.set_visible_child_name("placeholder");
|
|
win.imp().article_menu_button.set_visible(false);
|
|
win.imp().show_login_dialog();
|
|
}
|
|
}
|
|
});
|
|
dialog.present(Some(self.obj().upcast_ref::<gtk4::Widget>()));
|
|
}
|
|
|
|
// ── Fetch articles ────────────────────────────────────────────────────
|
|
|
|
fn do_reload(&self) {
|
|
self.fetch_articles();
|
|
}
|
|
|
|
fn fetch_articles(&self) {
|
|
let api = self.api.borrow().clone();
|
|
let Some(api) = api else { return };
|
|
|
|
// Reload filter rules on every refresh so edits take effect immediately.
|
|
*self.filter_rules.borrow_mut() = crate::filters::load_rules();
|
|
|
|
self.refresh_stack.set_visible_child_name("spinner");
|
|
|
|
// Only show the loading screen if there's nothing to show yet.
|
|
let has_articles = self.article_store.borrow()
|
|
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
|
|
if !has_articles {
|
|
self.sidebar_content.set_visible_child_name("loading");
|
|
}
|
|
|
|
let saved_id = self.current_article_id.borrow().clone();
|
|
|
|
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
|
let cache_images = settings.boolean("cache-images")
|
|
&& !gio::NetworkMonitor::default().is_network_metered();
|
|
|
|
let write_token = self.write_token.borrow().clone();
|
|
let win_weak = self.obj().downgrade();
|
|
crate::runtime::spawn(
|
|
async move {
|
|
// Flush any read/unread actions that failed to sync earlier.
|
|
if let Some(ref wt) = write_token {
|
|
crate::pending_actions::flush(&api, wt).await;
|
|
}
|
|
let articles = api.fetch_unread().await?;
|
|
let articles = if cache_images {
|
|
let processed = crate::image_cache::process(articles);
|
|
crate::runtime::spawn_bg(crate::image_cache::prefetch(processed.clone()));
|
|
processed
|
|
} else {
|
|
articles
|
|
};
|
|
Ok::<_, String>(articles)
|
|
},
|
|
move |result| {
|
|
let Some(win) = win_weak.upgrade() else { return };
|
|
let imp = win.imp();
|
|
match result {
|
|
Ok(articles) => {
|
|
let store = imp.article_store.borrow();
|
|
let store = store.as_ref().unwrap();
|
|
let sel = imp.selection.borrow();
|
|
let sel = sel.as_ref().unwrap();
|
|
|
|
store.remove_all();
|
|
for a in &articles {
|
|
store.append(&ArticleObject::new(a.clone()));
|
|
}
|
|
|
|
// Save cache and clean up unreferenced images.
|
|
let sel_id = saved_id.as_deref().unwrap_or("");
|
|
crate::cache::save(&articles, sel_id);
|
|
crate::image_cache::cleanup(&articles);
|
|
|
|
if store.n_items() == 0 {
|
|
imp.sidebar_content.set_visible_child_name("empty");
|
|
*imp.current_article_id.borrow_mut() = None;
|
|
imp.article_menu_button.set_visible(false);
|
|
imp.content_stack.set_visible_child_name("empty");
|
|
} else {
|
|
// Try to re-select the same article; fall back to first.
|
|
let mut select_idx = 0u32;
|
|
if let Some(ref id) = saved_id {
|
|
for i in 0..store.n_items() {
|
|
if store.item(i).and_downcast::<ArticleObject>()
|
|
.map(|o| o.article().id == *id)
|
|
.unwrap_or(false)
|
|
{
|
|
select_idx = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
*imp.mark_unread_guard.borrow_mut() = true;
|
|
sel.set_selected(select_idx);
|
|
imp.article_list_view.scroll_to(
|
|
select_idx,
|
|
gtk4::ListScrollFlags::SELECT,
|
|
None,
|
|
);
|
|
imp.sidebar_content.set_visible_child_name("list");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// If we already have cached articles, just show a toast.
|
|
let has_articles = imp.article_store.borrow()
|
|
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
|
|
if has_articles {
|
|
let toast = libadwaita::Toast::new(&format!("Refresh failed: {e}"));
|
|
imp.toast_overlay.add_toast(toast);
|
|
} else {
|
|
imp.error_status.set_description(Some(&e));
|
|
imp.sidebar_content.set_visible_child_name("error");
|
|
}
|
|
}
|
|
}
|
|
imp.refresh_stack.set_visible_child_name("button");
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Read state ────────────────────────────────────────────────────────
|
|
|
|
fn bg_mark_read(&self, item_id: String) {
|
|
let api = self.api.borrow().clone();
|
|
let wt = self.write_token.borrow().clone();
|
|
if let (Some(api), Some(wt)) = (api, wt) {
|
|
crate::runtime::spawn_bg(async move {
|
|
if api.mark_read(&wt, &item_id).await.is_err() {
|
|
crate::pending_actions::add(
|
|
crate::pending_actions::Action::Read,
|
|
&item_id,
|
|
);
|
|
}
|
|
});
|
|
} else {
|
|
// Offline or write token not yet available — queue for later.
|
|
crate::pending_actions::add(
|
|
crate::pending_actions::Action::Read,
|
|
&item_id,
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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>() {
|
|
if obj.article().id == id {
|
|
obj.set_unread(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
if let (Some(api), Some(wt)) = (api, wt) {
|
|
let id_clone = id.clone();
|
|
crate::runtime::spawn_bg(async move {
|
|
if api.mark_unread(&wt, &id_clone).await.is_err() {
|
|
crate::pending_actions::add(
|
|
crate::pending_actions::Action::Unread,
|
|
&id_clone,
|
|
);
|
|
}
|
|
});
|
|
} else {
|
|
crate::pending_actions::add(
|
|
crate::pending_actions::Action::Unread,
|
|
&id,
|
|
);
|
|
}
|
|
|
|
let toast = libadwaita::Toast::new("Marked as unread");
|
|
self.toast_overlay.add_toast(toast);
|
|
}
|
|
|
|
// ── Navigation ────────────────────────────────────────────────────────
|
|
|
|
pub fn navigate_by(&self, delta: i32) {
|
|
let sel = self.selection.borrow();
|
|
let Some(sel) = sel.as_ref() else { return };
|
|
let n = sel.n_items();
|
|
if n == 0 { return }
|
|
let current = sel.selected();
|
|
let next = if delta > 0 {
|
|
(current + 1).min(n - 1)
|
|
} else {
|
|
current.saturating_sub(1)
|
|
};
|
|
if next != current {
|
|
sel.set_selected(next);
|
|
self.article_list_view.scroll_to(next, gtk4::ListScrollFlags::SELECT, None);
|
|
}
|
|
}
|
|
|
|
// ── Open in browser ───────────────────────────────────────────────────
|
|
|
|
fn do_open_in_browser(&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 {
|
|
let link = obj.article().link.clone();
|
|
if !link.is_empty() {
|
|
open_uri(&link);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Zoom ──────────────────────────────────────────────────────────────
|
|
|
|
fn update_sidebar_zoom(&self, level: f64) {
|
|
if let Some(css) = self.sidebar_zoom_css.get() {
|
|
css.load_from_string(&format!(
|
|
".sidebar-content {{ font-size: {level}em; }}"
|
|
));
|
|
}
|
|
}
|
|
|
|
fn zoom(&self, factor: f64) {
|
|
let wv = &*self.web_view;
|
|
let new_level = (wv.zoom_level() * factor).clamp(0.25, 5.0);
|
|
wv.set_zoom_level(new_level);
|
|
self.update_sidebar_zoom(new_level);
|
|
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
|
settings.set_double("zoom-level", new_level).ok();
|
|
}
|
|
|
|
fn zoom_reset(&self) {
|
|
self.web_view.set_zoom_level(1.0);
|
|
self.update_sidebar_zoom(1.0);
|
|
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
|
settings.set_double("zoom-level", 1.0).ok();
|
|
}
|
|
}
|
|
|
|
impl WidgetImpl for FeedTheMonkeyWindow {}
|
|
|
|
impl WindowImpl for FeedTheMonkeyWindow {
|
|
fn close_request(&self) -> glib::Propagation {
|
|
self.save_cache();
|
|
// Cancel any in-flight requests by dropping the Api
|
|
*self.api.borrow_mut() = None;
|
|
self.parent_close_request()
|
|
}
|
|
}
|
|
|
|
impl ApplicationWindowImpl for FeedTheMonkeyWindow {}
|
|
impl AdwApplicationWindowImpl for FeedTheMonkeyWindow {}
|
|
}
|
|
|
|
fn open_uri(uri: &str) {
|
|
let launcher = gtk4::UriLauncher::new(uri);
|
|
launcher.launch(gtk4::Window::NONE, gio::Cancellable::NONE, |_| {});
|
|
}
|