From 813dda3579d18da39aa9012c68cbd46987758dba Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 20 Mar 2026 11:57:06 +0000 Subject: [PATCH] =?UTF-8?q?app:=20implement=20Epics=202=E2=80=9310?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the full application logic on top of the Epic 1 skeleton: Epic 2 — Authentication - LoginDialog (AdwDialog, Blueprint template) with server URL, username, and password fields; emits logged-in signal on submit - credentials.rs: store/load/clear via libsecret (password_store_sync / password_search_sync / password_clear_sync, v0_19 feature) - api.rs: Api::login() parses Auth= token from ClientLogin response; fetch_write_token() fetches the write token - Auto-login on startup from stored credentials; logout with AdwAlertDialog confirmation; login errors shown in AdwAlertDialog Epic 3 — Article fetching - model.rs: Article struct and ArticleObject GObject wrapper with unread property for list store binding - Api::fetch_unread() deserializes Google Reader JSON, derives unread from categories, generates plain-text excerpt - Sidebar uses a GtkStack with placeholder / loading / empty / error / list pages; AdwSpinnerPaintable while fetching; Try Again button Epic 4 — Sidebar - article_row.blp: composite template with feed title, date, title, and excerpt labels - ArticleRow GObject subclass; binds ArticleObject, watches unread notify to apply .dim-label on the title; relative timestamp format Epic 5 — Content pane - content.html updated: setArticle(), checkKey(), feedthemonkey: URI navigation scheme; dark mode via prefers-color-scheme - content.css: proper article layout, dark mode, code blocks - WebView loaded from GResource; decide-policy intercepts feedthemonkey:{next,previous,open} and all external links Epic 6 — Read state - Api::mark_read() / mark_unread() via edit-tag endpoint - Optimistic unread toggle on ArticleObject; background API calls; mark_unread_guard prevents re-marking on navigation - AdwToast shown on mark-unread Epic 7 — Keyboard shortcuts - GtkShortcutController on window for all shortcuts from the backlog - shortcuts.blp: AdwShortcutsWindow documenting all shortcuts - F1 opens shortcuts dialog; Ctrl+W closes window; Ctrl+Q quits Epic 8 — Zoom - zoom_in/zoom_out/zoom_reset wired to Ctrl+±/0; zoom level saved to and restored from GSettings zoom-level key Epic 9 — Window state persistence - Window width/height/maximized saved on close, restored on open - (Sidebar width deferred — AdwNavigationSplitView fraction binding) Epic 10 — Polish - AdwAboutDialog with app name, version, GPL-3.0, website - Logout confirmation AdwAlertDialog with destructive button - Win.toggle-fullscreen action (F11) - Api dropped on window close to cancel in-flight requests --- .gitignore | 1 + Cargo.toml | 2 +- data/gschemas.compiled | Bin 0 -> 500 bytes data/resources.gresource.xml | 3 + data/ui/article_row.blp | 42 ++++ data/ui/article_row.ui | 62 +++++ data/ui/login_dialog.blp | 41 +++ data/ui/login_dialog.ui | 58 +++++ data/ui/shortcuts.blp | 107 ++++++++ data/ui/shortcuts.ui | 134 ++++++++++ data/ui/window.blp | 71 +++++- data/ui/window.ui | 101 +++++++- html/content.css | 89 ++++++- html/content.html | 38 ++- src/api.rs | 204 +++++++++++++++ src/app.rs | 77 ++++++ src/article_row.rs | 132 ++++++++++ src/credentials.rs | 65 +++++ src/login_dialog.rs | 105 ++++++++ src/main.rs | 6 +- src/model.rs | 76 ++++++ src/window.rs | 466 +++++++++++++++++++++++++++++++++-- 22 files changed, 1838 insertions(+), 42 deletions(-) create mode 100644 data/gschemas.compiled create mode 100644 data/ui/article_row.blp create mode 100644 data/ui/article_row.ui create mode 100644 data/ui/login_dialog.blp create mode 100644 data/ui/login_dialog.ui create mode 100644 data/ui/shortcuts.blp create mode 100644 data/ui/shortcuts.ui create mode 100644 src/api.rs create mode 100644 src/article_row.rs create mode 100644 src/credentials.rs create mode 100644 src/login_dialog.rs create mode 100644 src/model.rs diff --git a/.gitignore b/.gitignore index f2564bc..c03b477 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store target/ +data/gschemas.compiled diff --git a/Cargo.toml b/Cargo.toml index 8554080..f2dc074 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,6 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } -libsecret = { version = "0.9" } +libsecret = { version = "0.9", features = ["v0_19"] } [build-dependencies] diff --git a/data/gschemas.compiled b/data/gschemas.compiled new file mode 100644 index 0000000000000000000000000000000000000000..4f552ea47911cb5834cc279276c25292755f9d8f GIT binary patch literal 500 zcmY*WyGq1R6g)nn-~%jN+{!vBlKg>MsYL_}krWnYxd(ICO(L7H(M82VY_-$c%0fiM z4-j1cK@fYfvJfnFP9BAa%$>=cxi`tl%4X2jL7Y)lO|V~TbGSCaMuX@VRrb6hvjsoB zK3kBO=s8*?IcRB!2p7eiIc?@RaF&_XM#yVPyvqmZkU1_ZN^&*id82><+zw9eM9W#z z)T4E<4?GAd7cOa20U1$?UZqxhny;NO7nf%X(i30w1pE*1H;`(@t)Oe?T4f>nO?YH#%h?Nxdly4+iy^rGhTvjr zu49$tcF1}stbc$nL%{y3S078KBc+d(BqB2sYYeEeQLwKg-Dd?0Wwxw-l0 ui/window.ui + ui/login_dialog.ui + ui/article_row.ui + ui/shortcuts.ui html/content.html html/content.css diff --git a/data/ui/article_row.blp b/data/ui/article_row.blp new file mode 100644 index 0000000..052b813 --- /dev/null +++ b/data/ui/article_row.blp @@ -0,0 +1,42 @@ +using Gtk 4.0; +using Adw 1; + +template $ArticleRow : Gtk.Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 12; + margin-start: 12; + margin-end: 12; + spacing: 4; + + Box { + orientation: horizontal; + spacing: 4; + + Label feed_title_label { + hexpand: true; + xalign: 0; + ellipsize: end; + styles ["dim-label", "caption"] + } + + Label date_label { + xalign: 1; + styles ["dim-label", "caption"] + } + } + + Label title_label { + xalign: 0; + wrap: true; + lines: 2; + ellipsize: end; + } + + Label excerpt_label { + xalign: 0; + ellipsize: end; + lines: 1; + styles ["dim-label", "caption"] + } +} diff --git a/data/ui/article_row.ui b/data/ui/article_row.ui new file mode 100644 index 0000000..1218020 --- /dev/null +++ b/data/ui/article_row.ui @@ -0,0 +1,62 @@ + + + + + + \ No newline at end of file diff --git a/data/ui/login_dialog.blp b/data/ui/login_dialog.blp new file mode 100644 index 0000000..4a53858 --- /dev/null +++ b/data/ui/login_dialog.blp @@ -0,0 +1,41 @@ +using Gtk 4.0; +using Adw 1; + +template $LoginDialog : Adw.Dialog { + title: _("Log In"); + content-width: 360; + + Adw.ToolbarView { + [top] + Adw.HeaderBar {} + + Adw.Clamp { + margin-top: 12; + margin-bottom: 24; + margin-start: 12; + margin-end: 12; + + Adw.PreferencesGroup { + Adw.EntryRow server_url_row { + title: _("Server URL"); + input-hints: no_spellcheck; + input-purpose: url; + } + + Adw.EntryRow username_row { + title: _("Username"); + input-hints: no_spellcheck; + } + + Adw.PasswordEntryRow password_row { + title: _("Password"); + } + + Adw.ButtonRow login_button { + title: _("Log In"); + styles ["suggested-action"] + } + } + } + } +} diff --git a/data/ui/login_dialog.ui b/data/ui/login_dialog.ui new file mode 100644 index 0000000..ebb5d9a --- /dev/null +++ b/data/ui/login_dialog.ui @@ -0,0 +1,58 @@ + + + + + + \ No newline at end of file diff --git a/data/ui/shortcuts.blp b/data/ui/shortcuts.blp new file mode 100644 index 0000000..94c72e8 --- /dev/null +++ b/data/ui/shortcuts.blp @@ -0,0 +1,107 @@ +using Gtk 4.0; +using Adw 1; + +ShortcutsWindow help_overlay { + modal: true; + + ShortcutsSection { + section-name: "shortcuts"; + max-height: 10; + + ShortcutsGroup { + title: _("Navigation"); + + ShortcutsShortcut { + title: _("Next article"); + accelerator: "j Right"; + } + + ShortcutsShortcut { + title: _("Previous article"); + accelerator: "k Left"; + } + } + + ShortcutsGroup { + title: _("Article"); + + ShortcutsShortcut { + title: _("Open in browser"); + accelerator: "Return n"; + } + + ShortcutsShortcut { + title: _("Mark as unread"); + accelerator: "u"; + } + + ShortcutsShortcut { + title: _("Reload articles"); + accelerator: "r"; + } + } + + ShortcutsGroup { + title: _("View"); + + ShortcutsShortcut { + title: _("Scroll down"); + accelerator: "space Page_Down"; + } + + ShortcutsShortcut { + title: _("Scroll up"); + accelerator: "Page_Up"; + } + + ShortcutsShortcut { + title: _("Scroll to top"); + accelerator: "Home"; + } + + ShortcutsShortcut { + title: _("Scroll to bottom"); + accelerator: "End"; + } + + ShortcutsShortcut { + title: _("Zoom in"); + accelerator: "plus"; + } + + ShortcutsShortcut { + title: _("Zoom out"); + accelerator: "minus"; + } + + ShortcutsShortcut { + title: _("Reset zoom"); + accelerator: "0"; + } + + ShortcutsShortcut { + title: _("Toggle fullscreen"); + accelerator: "F11"; + } + } + + ShortcutsGroup { + title: _("Application"); + + ShortcutsShortcut { + title: _("Keyboard shortcuts"); + accelerator: "F1"; + } + + ShortcutsShortcut { + title: _("Close window"); + accelerator: "w"; + } + + ShortcutsShortcut { + title: _("Quit"); + accelerator: "q"; + } + } + } +} diff --git a/data/ui/shortcuts.ui b/data/ui/shortcuts.ui new file mode 100644 index 0000000..5392972 --- /dev/null +++ b/data/ui/shortcuts.ui @@ -0,0 +1,134 @@ + + + + + + true + + + shortcuts + 10 + + + Navigation + + + Next article + j Right + + + + + Previous article + k Left + + + + + + + Article + + + Open in browser + Return n + + + + + Mark as unread + u + + + + + Reload articles + r + + + + + + + View + + + Scroll down + space Page_Down + + + + + Scroll up + Page_Up + + + + + Scroll to top + Home + + + + + Scroll to bottom + End + + + + + Zoom in + <Control>plus + + + + + Zoom out + <Control>minus + + + + + Reset zoom + <Control>0 + + + + + Toggle fullscreen + F11 + + + + + + + Application + + + Keyboard shortcuts + F1 + + + + + Close window + <Control>w + + + + + Quit + <Control>q + + + + + + + + \ No newline at end of file diff --git a/data/ui/window.blp b/data/ui/window.blp index e13767a..9ded4f7 100644 --- a/data/ui/window.blp +++ b/data/ui/window.blp @@ -1,5 +1,6 @@ using Gtk 4.0; using Adw 1; +using WebKit 6.0; template $FeedTheMonkeyWindow : Adw.ApplicationWindow { default-width: 900; @@ -38,10 +39,56 @@ template $FeedTheMonkeyWindow : Adw.ApplicationWindow { } } - Adw.StatusPage placeholder_page { - icon-name: "rss-symbolic"; - title: _("FeedTheMonkey"); - description: _("Log in to load your articles"); + Stack sidebar_content { + StackPage { + name: "placeholder"; + child: Adw.StatusPage { + icon-name: "rss-symbolic"; + title: _("FeedTheMonkey"); + description: _("Log in to load your articles"); + }; + } + + StackPage { + name: "loading"; + child: Adw.StatusPage { + paintable: Adw.SpinnerPaintable {}; + title: _("Loading…"); + }; + } + + StackPage { + name: "empty"; + child: Adw.StatusPage { + icon-name: "rss-symbolic"; + title: _("No Unread Articles"); + }; + } + + StackPage { + name: "error"; + child: Adw.StatusPage error_status { + icon-name: "network-error-symbolic"; + title: _("Could Not Load Articles"); + + Button { + label: _("Try Again"); + halign: center; + action-name: "win.reload"; + styles ["pill", "suggested-action"] + } + }; + } + + StackPage { + name: "list"; + child: ScrolledWindow { + hscrollbar-policy: never; + ListView article_list_view { + single-click-activate: true; + } + }; + } } } }; @@ -62,9 +109,19 @@ template $FeedTheMonkeyWindow : Adw.ApplicationWindow { } } - Adw.StatusPage { - icon-name: "document-open-symbolic"; - title: _("No Article Selected"); + Stack content_stack { + StackPage { + name: "empty"; + child: Adw.StatusPage { + icon-name: "document-open-symbolic"; + title: _("No Article Selected"); + }; + } + + StackPage { + name: "webview"; + child: WebKit.WebView web_view {}; + } } } }; diff --git a/data/ui/window.ui b/data/ui/window.ui index 1058433..5ee9da1 100644 --- a/data/ui/window.ui +++ b/data/ui/window.ui @@ -54,10 +54,80 @@ corresponding .blp file and regenerate this file with blueprint-compiler. - - rss-symbolic - FeedTheMonkey - Log in to load your articles + + + + placeholder + + + rss-symbolic + FeedTheMonkey + Log in to load your articles + + + + + + + loading + + + + + + Loading… + + + + + + + empty + + + rss-symbolic + No Unread Articles + + + + + + + error + + + network-error-symbolic + Could Not Load Articles + + + Try Again + 3 + win.reload + + + + + + + + + + list + + + 2 + + + true + + + + + + @@ -82,9 +152,26 @@ corresponding .blp file and regenerate this file with blueprint-compiler. - - document-open-symbolic - No Article Selected + + + + empty + + + document-open-symbolic + No Article Selected + + + + + + + webview + + + + + diff --git a/html/content.css b/html/content.css index e755ad6..c3f979b 100644 --- a/html/content.css +++ b/html/content.css @@ -1,6 +1,11 @@ +* { + box-sizing: border-box; +} + body { font-family: sans-serif; - margin: 1em 2em; + margin: 0; + padding: 0; line-height: 1.6; color: #222; background: #fff; @@ -11,8 +16,86 @@ body { color: #ddd; background: #1e1e1e; } + a { + color: #78aeed; + } + img { + opacity: 0.85; + } } -h1 { - font-size: 1.4em; +#header { + padding: 1.5em 2em 1em; + border-bottom: 1px solid rgba(0,0,0,0.1); + margin-bottom: 1em; +} + +@media (prefers-color-scheme: dark) { + #header { + border-bottom-color: rgba(255,255,255,0.1); + } +} + +#feed-title { + font-size: 0.8em; + opacity: 0.6; + margin-bottom: 0.25em; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +#title { + font-size: 1.5em; + margin: 0 0 0.5em; + line-height: 1.3; +} + +#meta { + font-size: 0.85em; + opacity: 0.6; + margin-bottom: 0.5em; +} + +#meta span + span::before { + content: ' · '; +} + +#link { + font-size: 0.85em; +} + +#content { + padding: 0 2em 2em; + max-width: 800px; +} + +#content img { + max-width: 100%; + height: auto; +} + +#content pre { + overflow-x: auto; + background: rgba(0,0,0,0.05); + padding: 1em; + border-radius: 4px; +} + +@media (prefers-color-scheme: dark) { + #content pre { + background: rgba(255,255,255,0.05); + } +} + +#content blockquote { + border-left: 3px solid rgba(0,0,0,0.2); + margin-left: 0; + padding-left: 1em; + opacity: 0.8; +} + +@media (prefers-color-scheme: dark) { + #content blockquote { + border-left-color: rgba(255,255,255,0.2); + } } diff --git a/html/content.html b/html/content.html index d01555b..f817038 100644 --- a/html/content.html +++ b/html/content.html @@ -6,14 +6,44 @@ +
+ diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..36521b3 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,204 @@ +use reqwest::Client; +use serde::Deserialize; + +#[derive(Debug, Clone)] +pub struct Api { + client: Client, + pub server_url: String, + pub auth_token: String, +} + +#[derive(Debug, Deserialize)] +struct StreamContents { + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct Item { + id: String, + title: Option, + origin: Option, + canonical: Option>, + published: Option, + summary: Option, + author: Option, + categories: Option>, +} + +#[derive(Debug, Deserialize)] +struct Origin { + title: Option, +} + +#[derive(Debug, Deserialize)] +struct Link { + href: String, +} + +#[derive(Debug, Deserialize)] +struct Summary { + content: Option, +} + +use crate::model::Article; + +impl Api { + pub async fn login( + server_url: &str, + username: &str, + password: &str, + ) -> Result { + let client = Client::new(); + let url = format!("{}/accounts/ClientLogin", server_url.trim_end_matches('/')); + let resp = client + .post(&url) + .form(&[("Email", username), ("Passwd", password)]) + .send() + .await + .map_err(|e| e.to_string())?; + + let status = resp.status(); + let body = resp.text().await.map_err(|e| e.to_string())?; + + if !status.is_success() { + return Err(format!("Login failed ({}): {}", status, body.trim())); + } + + let auth_token = body + .lines() + .find_map(|l| l.strip_prefix("Auth=")) + .ok_or_else(|| "No Auth token in response".to_string())? + .to_string(); + + Ok(Self { + client, + server_url: server_url.trim_end_matches('/').to_string(), + auth_token, + }) + } + + pub async fn fetch_write_token(&self) -> Result { + let url = format!("{}/reader/api/0/token", self.server_url); + let resp = self + .client + .get(&url) + .header("Authorization", format!("GoogleLogin auth={}", self.auth_token)) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + return Err(format!("Failed to fetch write token: {}", resp.status())); + } + + resp.text().await.map_err(|e| e.to_string()).map(|s| s.trim().to_string()) + } + + pub async fn fetch_unread(&self) -> Result, String> { + let url = format!( + "{}/reader/api/0/stream/contents/reading-list\ + ?xt=user/-/state/com.google/read&n=200&output=json", + self.server_url + ); + let resp = self + .client + .get(&url) + .header("Authorization", format!("GoogleLogin auth={}", self.auth_token)) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + return Err(format!("Failed to fetch articles: {}", resp.status())); + } + + let stream: StreamContents = resp.json().await.map_err(|e| e.to_string())?; + + 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")); + let content = item.summary + .as_ref() + .and_then(|s| s.content.clone()) + .unwrap_or_default(); + let excerpt = plain_text_excerpt(&content, 150); + Article { + id: item.id, + title: item.title.unwrap_or_default(), + feed_title: item.origin.as_ref().and_then(|o| o.title.clone()).unwrap_or_default(), + author: item.author.unwrap_or_default(), + link: item.canonical.as_ref() + .and_then(|v| v.first()) + .map(|l| l.href.clone()) + .unwrap_or_default(), + published: item.published.unwrap_or(0), + content, + excerpt, + unread, + } + }).collect()) + } + + pub async fn mark_read( + &self, + write_token: &str, + item_id: &str, + ) -> Result<(), String> { + self.edit_tag(write_token, item_id, "a", "user/-/state/com.google/read").await + } + + pub async fn mark_unread( + &self, + write_token: &str, + item_id: &str, + ) -> Result<(), String> { + self.edit_tag(write_token, item_id, "r", "user/-/state/com.google/read").await + } + + async fn edit_tag( + &self, + write_token: &str, + item_id: &str, + action_key: &str, + state: &str, + ) -> Result<(), String> { + let url = format!("{}/reader/api/0/edit-tag", self.server_url); + let resp = self + .client + .post(&url) + .header("Authorization", format!("GoogleLogin auth={}", self.auth_token)) + .form(&[("i", item_id), (action_key, state), ("T", write_token)]) + .send() + .await + .map_err(|e| e.to_string())?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("UNAUTHORIZED".to_string()); + } + if !resp.status().is_success() { + return Err(format!("edit-tag failed: {}", resp.status())); + } + Ok(()) + } +} + +fn plain_text_excerpt(html: &str, max_chars: usize) -> String { + // Very simple HTML stripper — remove tags, collapse whitespace + let mut out = String::with_capacity(html.len()); + let mut in_tag = false; + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + c if !in_tag => out.push(c), + _ => {} + } + } + let collapsed: String = out.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= max_chars { + collapsed + } else { + collapsed.chars().take(max_chars).collect::() + "…" + } +} diff --git a/src/app.rs b/src/app.rs index cc7bc85..b484a01 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use gtk4::prelude::*; +use libadwaita::prelude::*; use crate::window::FeedTheMonkeyWindow; @@ -51,6 +52,46 @@ mod imp { gio::resources_register(&resource); let window = FeedTheMonkeyWindow::new(app.upcast_ref()); + + // Shortcuts overlay + let builder = gtk4::Builder::from_resource( + "/net/jeena/FeedTheMonkey/ui/shortcuts.ui", + ); + let overlay: gtk4::ShortcutsWindow = builder.object("help_overlay").unwrap(); + window.set_help_overlay(Some(&overlay)); + + setup_shortcuts(&window); + + // About action on app + let app_weak = app.downgrade(); + let about_action = gio::SimpleAction::new("about", None); + about_action.connect_activate(move |_, _| { + if let Some(app) = app_weak.upgrade() { + let win = app.active_window(); + let dialog = libadwaita::AboutDialog::builder() + .application_name("FeedTheMonkey") + .application_icon("feedthemonkey") + .version("3.0.0") + .copyright("© Jeena Paradies") + .license_type(gtk4::License::Gpl30) + .website("https://github.com/jeena/FeedTheMonkey") + .developer_name("Jeena Paradies") + .build(); + dialog.present(win.as_ref().map(|w| w.upcast_ref::())); + } + }); + app.add_action(&about_action); + + // Quit action + let app_weak = app.downgrade(); + let quit_action = gio::SimpleAction::new("quit", None); + quit_action.connect_activate(move |_, _| { + if let Some(app) = app_weak.upgrade() { + app.quit(); + } + }); + app.add_action(&quit_action); + window.present(); } } @@ -58,3 +99,39 @@ mod imp { impl GtkApplicationImpl for FeedTheMonkeyApp {} impl AdwApplicationImpl for FeedTheMonkeyApp {} } + +fn setup_shortcuts(window: &FeedTheMonkeyWindow) { + use gtk4::gdk::{Key, ModifierType}; + + let controller = gtk4::ShortcutController::new(); + controller.set_scope(gtk4::ShortcutScope::Global); + + let add = |controller: >k4::ShortcutController, + key: Key, + mods: ModifierType, + action_name: &str| { + let trigger = gtk4::KeyvalTrigger::new(key, mods); + let action = gtk4::NamedAction::new(action_name); + let shortcut = gtk4::Shortcut::new(Some(trigger), Some(action)); + controller.add_shortcut(shortcut); + }; + + add(&controller, Key::j, ModifierType::empty(), "win.next-article"); + add(&controller, Key::Right, ModifierType::empty(), "win.next-article"); + add(&controller, Key::k, ModifierType::empty(), "win.prev-article"); + add(&controller, Key::Left, ModifierType::empty(), "win.prev-article"); + add(&controller, Key::r, ModifierType::empty(), "win.reload"); + add(&controller, Key::u, ModifierType::empty(), "win.mark-unread"); + add(&controller, Key::Return, ModifierType::empty(), "win.open-in-browser"); + add(&controller, Key::n, ModifierType::empty(), "win.open-in-browser"); + add(&controller, Key::plus, ModifierType::CONTROL_MASK, "win.zoom-in"); + add(&controller, Key::equal, ModifierType::CONTROL_MASK, "win.zoom-in"); + add(&controller, Key::minus, ModifierType::CONTROL_MASK, "win.zoom-out"); + add(&controller, Key::_0, ModifierType::CONTROL_MASK, "win.zoom-reset"); + add(&controller, Key::F11, ModifierType::empty(), "win.toggle-fullscreen"); + add(&controller, Key::w, ModifierType::CONTROL_MASK, "window.close"); + add(&controller, Key::q, ModifierType::CONTROL_MASK, "app.quit"); + add(&controller, Key::F1, ModifierType::empty(), "win.show-help-overlay"); + + window.add_controller(controller); +} diff --git a/src/article_row.rs b/src/article_row.rs new file mode 100644 index 0000000..578aae5 --- /dev/null +++ b/src/article_row.rs @@ -0,0 +1,132 @@ +use gtk4::glib; +use gtk4::subclass::prelude::ObjectSubclassIsExt; + +glib::wrapper! { + pub struct ArticleRow(ObjectSubclass) + @extends gtk4::Box, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable; +} + +impl ArticleRow { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn bind(&self, obj: &crate::model::ArticleObject) { + self.imp().bind(obj); + } + + pub fn unbind(&self) { + self.imp().unbind(); + } +} + +mod imp { + use super::*; + use crate::model::ArticleObject; + use gtk4::prelude::*; + use gtk4::subclass::prelude::*; + use gtk4::CompositeTemplate; + use glib::object::ObjectExt; + use std::cell::RefCell; + + #[derive(CompositeTemplate, Default)] + #[template(resource = "/net/jeena/FeedTheMonkey/ui/article_row.ui")] + pub struct ArticleRow { + #[template_child] + pub feed_title_label: TemplateChild, + #[template_child] + pub date_label: TemplateChild, + #[template_child] + pub title_label: TemplateChild, + #[template_child] + pub excerpt_label: TemplateChild, + + pub bindings: RefCell>, + pub unread_handler: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for ArticleRow { + const NAME: &'static str = "ArticleRow"; + type Type = super::ArticleRow; + type ParentType = gtk4::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ArticleRow {} + impl WidgetImpl for ArticleRow {} + impl BoxImpl for ArticleRow {} + + impl ArticleRow { + 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); + drop(article); + + // Watch for 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 { + title_label.remove_css_class("dim-label"); + } else { + title_label.add_css_class("dim-label"); + } + }); + *self.unread_handler.borrow_mut() = Some((obj.clone(), id)); + } + + pub fn unbind(&self) { + if let Some((obj, id)) = self.unread_handler.borrow_mut().take() { + obj.disconnect(id); + } + for b in self.bindings.borrow_mut().drain(..) { + b.unbind(); + } + } + + fn update_read_style(&self, unread: bool) { + if unread { + self.title_label.remove_css_class("dim-label"); + } else { + self.title_label.add_css_class("dim-label"); + } + } + } +} + +fn relative_time(unix: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let diff = now - unix; + if diff < 60 { + "just now".to_string() + } else if diff < 3600 { + let m = diff / 60; + format!("{m}m ago") + } else if diff < 86400 { + let h = diff / 3600; + format!("{h}h ago") + } else if diff < 172800 { + "Yesterday".to_string() + } else { + let d = diff / 86400; + format!("{d}d ago") + } +} diff --git a/src/credentials.rs b/src/credentials.rs new file mode 100644 index 0000000..d217f70 --- /dev/null +++ b/src/credentials.rs @@ -0,0 +1,65 @@ +use libsecret::{prelude::*, SchemaAttributeType, SchemaFlags, SearchFlags}; +use std::collections::HashMap; + +const SCHEMA_NAME: &str = "net.jeena.FeedTheMonkey"; +const ATTR_SERVER: &str = "server-url"; +const ATTR_USERNAME: &str = "username"; +const LABEL: &str = "FeedTheMonkey credentials"; + +fn schema() -> libsecret::Schema { + libsecret::Schema::new( + SCHEMA_NAME, + SchemaFlags::NONE, + HashMap::from([ + (ATTR_SERVER, SchemaAttributeType::String), + (ATTR_USERNAME, SchemaAttributeType::String), + ]), + ) +} + +pub fn store_credentials(server_url: &str, username: &str, password: &str) { + let schema = schema(); + let attrs = HashMap::from([ + (ATTR_SERVER, server_url), + (ATTR_USERNAME, username), + ]); + if let Err(e) = libsecret::password_store_sync( + Some(&schema), + attrs, + Some(libsecret::COLLECTION_DEFAULT.as_str()), + LABEL, + password, + gio::Cancellable::NONE, + ) { + eprintln!("Failed to store credentials: {e}"); + } +} + +pub fn load_credentials() -> Option<(String, String, String)> { + let schema = schema(); + let items = libsecret::password_search_sync( + Some(&schema), + HashMap::new(), + SearchFlags::LOAD_SECRETS | SearchFlags::UNLOCK, + gio::Cancellable::NONE, + ).ok()?; + + let item = items.into_iter().next()?; + let attrs = item.attributes(); + let server_url = attrs.get(ATTR_SERVER)?.to_string(); + let username = attrs.get(ATTR_USERNAME)?.to_string(); + let secret = item.retrieve_secret_sync(gio::Cancellable::NONE).ok()??; + let password = secret.text()?.to_string(); + Some((server_url, username, password)) +} + +pub fn clear_credentials() { + let schema = schema(); + if let Err(e) = libsecret::password_clear_sync( + Some(&schema), + HashMap::new(), + gio::Cancellable::NONE, + ) { + eprintln!("Failed to clear credentials: {e}"); + } +} diff --git a/src/login_dialog.rs b/src/login_dialog.rs new file mode 100644 index 0000000..24e1024 --- /dev/null +++ b/src/login_dialog.rs @@ -0,0 +1,105 @@ +use gtk4::glib; + +glib::wrapper! { + pub struct LoginDialog(ObjectSubclass) + @extends libadwaita::Dialog, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl LoginDialog { + pub fn new() -> Self { + glib::Object::new() + } +} + +mod imp { + use super::*; + use gtk4::prelude::*; + use gtk4::subclass::prelude::*; + use gtk4::CompositeTemplate; + use libadwaita::prelude::*; + use libadwaita::subclass::prelude::*; + + #[derive(CompositeTemplate, Default)] + #[template(resource = "/net/jeena/FeedTheMonkey/ui/login_dialog.ui")] + pub struct LoginDialog { + #[template_child] + pub server_url_row: TemplateChild, + #[template_child] + pub username_row: TemplateChild, + #[template_child] + pub password_row: TemplateChild, + #[template_child] + pub login_button: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for LoginDialog { + const NAME: &'static str = "LoginDialog"; + type Type = super::LoginDialog; + type ParentType = libadwaita::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for LoginDialog { + fn signals() -> &'static [glib::subclass::Signal] { + use std::sync::OnceLock; + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + glib::subclass::Signal::builder("logged-in") + .param_types([ + String::static_type(), + String::static_type(), + String::static_type(), + ]) + .build(), + ] + }) + } + + fn constructed(&self) { + self.parent_constructed(); + + let obj_weak = self.obj().downgrade(); + self.login_button.connect_activated(move |_| { + if let Some(dialog) = obj_weak.upgrade() { + dialog.imp().on_login_clicked(); + } + }); + + // Also trigger on Enter in password row + let obj_weak2 = self.obj().downgrade(); + self.password_row.connect_apply(move |_| { + if let Some(dialog) = obj_weak2.upgrade() { + dialog.imp().on_login_clicked(); + } + }); + } + } + + impl LoginDialog { + fn on_login_clicked(&self) { + let server_url = self.server_url_row.text().trim().to_string(); + let username = self.username_row.text().trim().to_string(); + let password = self.password_row.text().to_string(); + + if server_url.is_empty() || username.is_empty() || password.is_empty() { + return; + } + + self.obj().close(); + self.obj().emit_by_name::<()>("logged-in", &[&server_url, &username, &password]); + } + } + + impl WidgetImpl for LoginDialog {} + impl AdwDialogImpl for LoginDialog {} +} diff --git a/src/main.rs b/src/main.rs index 5471cc8..57e96dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,13 @@ +mod api; mod app; +mod article_row; +mod credentials; +mod login_dialog; +mod model; mod window; fn main() -> glib::ExitCode { // In development builds, point GSettings at the locally compiled schema. - // In release/installed builds the schema is found via the system path. if cfg!(debug_assertions) { std::env::set_var("GSETTINGS_SCHEMA_DIR", env!("GSETTINGS_SCHEMA_DIR")); } diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..090729c --- /dev/null +++ b/src/model.rs @@ -0,0 +1,76 @@ +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use std::cell::RefCell; + +#[derive(Debug, Clone, Default)] +pub struct Article { + pub id: String, + pub title: String, + pub feed_title: String, + pub author: String, + pub link: String, + pub published: i64, + pub content: String, + pub excerpt: String, + pub unread: bool, +} + +// ── GObject wrapper ────────────────────────────────────────────────────────── + +glib::wrapper! { + pub struct ArticleObject(ObjectSubclass); +} + +impl ArticleObject { + pub fn new(article: Article) -> Self { + let obj: Self = glib::Object::new(); + *obj.imp().article.borrow_mut() = article; + obj + } + + pub fn article(&self) -> std::cell::Ref<'_, Article> { + self.imp().article.borrow() + } + + pub fn set_unread(&self, unread: bool) { + self.imp().article.borrow_mut().unread = unread; + self.notify("unread"); + } +} + +mod imp { + use super::*; + + #[derive(Default)] + pub struct ArticleObject { + pub article: RefCell
, + } + + #[glib::object_subclass] + impl ObjectSubclass for ArticleObject { + const NAME: &'static str = "ArticleObject"; + type Type = super::ArticleObject; + } + + impl ObjectImpl for ArticleObject { + fn properties() -> &'static [glib::ParamSpec] { + use std::sync::OnceLock; + static PROPS: OnceLock> = OnceLock::new(); + PROPS.get_or_init(|| { + vec![ + glib::ParamSpecBoolean::builder("unread") + .read_only() + .build(), + ] + }) + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "unread" => self.article.borrow().unread.to_value(), + _ => unimplemented!(), + } + } + } +} diff --git a/src/window.rs b/src/window.rs index d2e12ee..1b7730a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,4 +1,7 @@ use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use webkit6::prelude::{PolicyDecisionExt, WebViewExt}; glib::wrapper! { pub struct FeedTheMonkeyWindow(ObjectSubclass) @@ -15,10 +18,16 @@ impl FeedTheMonkeyWindow { } } -mod imp { +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")] @@ -35,6 +44,23 @@ mod imp { pub content_page: TemplateChild, #[template_child] pub article_menu_button: TemplateChild, + #[template_child] + pub sidebar_content: TemplateChild, + #[template_child] + pub article_list_view: TemplateChild, + #[template_child] + pub content_stack: TemplateChild, + #[template_child] + pub web_view: TemplateChild, + #[template_child] + pub error_status: TemplateChild, + + pub api: RefCell>, + pub write_token: RefCell>, + pub article_store: RefCell>, + pub selection: RefCell>, + pub current_article_id: RefCell>, + pub mark_unread_guard: RefCell, } #[glib::object_subclass] @@ -45,6 +71,28 @@ mod imp { 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.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(); + } + }); } fn instance_init(obj: &glib::subclass::InitializingObject) { @@ -55,42 +103,422 @@ mod imp { impl ObjectImpl for FeedTheMonkeyWindow { fn constructed(&self) { self.parent_constructed(); - self.restore_window_state(); + self.setup_window_state(); + self.setup_list(); + self.setup_webview(); + self.auto_login(); } } impl FeedTheMonkeyWindow { - fn restore_window_state(&self) { + // ── Window state ───────────────────────────────────────────────────── + + fn setup_window_state(&self) { let settings = gio::Settings::new("net.jeena.FeedTheMonkey"); let window = self.obj(); - - let width = settings.int("window-width"); - let height = settings.int("window-height"); - let maximized = settings.boolean("window-maximized"); - - window.set_default_size(width, height); - if maximized { + 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(); } - // Save state when the window closes - let settings_clone = settings.clone(); + // Restore zoom + let zoom = settings.double("zoom-level"); + self.web_view.set_zoom_level(zoom); + + let s = settings.clone(); window.connect_close_request(move |win| { if !win.is_maximized() { - let (w, h) = (win.width(), win.height()); - settings_clone.set_int("window-width", w).ok(); - settings_clone.set_int("window-height", h).ok(); + s.set_int("window-width", win.width()).ok(); + s.set_int("window-height", win.height()).ok(); } - settings_clone - .set_boolean("window-maximized", win.is_maximized()) - .ok(); + s.set_boolean("window-maximized", win.is_maximized()).ok(); glib::Propagation::Proceed }); } + + // ── List view ───────────────────────────────────────────────────────── + + fn setup_list(&self) { + let store = gio::ListStore::new::(); + 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::().unwrap(); + let row = crate::article_row::ArticleRow::new(); + item.set_child(Some(&row)); + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + if let Some(obj) = item.item().and_downcast::() { + let row = item.child().and_downcast::().unwrap(); + row.bind(&obj); + } + }); + factory.connect_unbind(|_, item| { + let item = item.downcast_ref::().unwrap(); + if let Some(row) = item.child().and_downcast::() { + 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::() { + 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()); + + // Show content pane + self.content_page.set_title(&article.title); + self.article_menu_button.set_visible(true); + self.split_view.set_show_content(true); + + // Load in webview + self.load_article_in_webview(&article); + obj.set_unread(false); + } + + fn load_article_in_webview(&self, article: &crate::model::Article) { + let json = serde_json::json!({ + "id": article.id, + "title": article.title, + "feed_title": article.feed_title, + "link": article.link, + "updated": article.published, + "content": article.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; + + // Load content.html from GResource + let html = String::from_utf8( + gio::resources_lookup_data( + "/net/jeena/FeedTheMonkey/html/content.html", + gio::ResourceLookupFlags::NONE, + ) + .unwrap() + .to_vec(), + ) + .unwrap(); + wv.load_html(&html, Some("feedthemonkey://localhost/")); + + // 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::().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 + } + + 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(), + _ => { open_uri(&uri); } + } + } + true + }); + } + + // ── Login ───────────────────────────────────────────────────────────── + + fn auto_login(&self) { + if let Some((server_url, username, password)) = credentials::load_credentials() { + let win_weak = self.obj().downgrade(); + glib::spawn_future_local(async move { + if let Some(win) = win_weak.upgrade() { + win.imp().do_login(server_url, username, password, false).await; + } + }); + } 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::().unwrap(); + let username = args[2].get::().unwrap(); + let password = args[3].get::().unwrap(); + if let Some(win) = win_weak.upgrade() { + glib::spawn_future_local(async move { + win.imp().do_login(server_url, username, password, true).await; + }); + } + None + }); + dialog.present(Some(win.upcast_ref::())); + } + + pub async fn do_login( + &self, + server_url: String, + username: String, + password: String, + store: bool, + ) { + match Api::login(&server_url, &username, &password).await { + Ok(api) => { + if store { + credentials::store_credentials(&server_url, &username, &password); + } + match api.fetch_write_token().await { + Ok(wt) => *self.write_token.borrow_mut() = Some(wt), + Err(e) => eprintln!("Write token error: {e}"), + } + *self.api.borrow_mut() = Some(api); + self.fetch_articles().await; + } + Err(e) => { + self.show_login_dialog(); + self.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::())); + } + + 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::())); + } + + // ── Fetch articles ──────────────────────────────────────────────────── + + fn do_reload(&self) { + let win_weak = self.obj().downgrade(); + glib::spawn_future_local(async move { + if let Some(win) = win_weak.upgrade() { + win.imp().fetch_articles().await; + } + }); + } + + pub async fn fetch_articles(&self) { + let api = self.api.borrow().clone(); + let Some(api) = api else { return }; + + self.refresh_stack.set_visible_child_name("spinner"); + self.sidebar_content.set_visible_child_name("loading"); + + match api.fetch_unread().await { + Ok(articles) => { + let store = self.article_store.borrow(); + let store = store.as_ref().unwrap(); + store.remove_all(); + for a in articles { + store.append(&ArticleObject::new(a)); + } + if store.n_items() == 0 { + self.sidebar_content.set_visible_child_name("empty"); + } else { + self.sidebar_content.set_visible_child_name("list"); + } + } + Err(e) => { + self.error_status.set_description(Some(&e)); + self.sidebar_content.set_visible_child_name("error"); + } + } + + self.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) { + glib::spawn_future_local(async move { + if let Err(e) = api.mark_read(&wt, &item_id).await { + eprintln!("mark_read error: {e}"); + } + }); + } + } + + fn do_mark_unread(&self) { + let id = self.current_article_id.borrow().clone(); + let Some(id) = id else { return }; + + // 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::() { + if obj.article().id == id { + obj.set_unread(true); + } + } + } + } + + *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(); + glib::spawn_future_local(async move { + if let Err(e) = api.mark_unread(&wt, &id_clone).await { + eprintln!("mark_unread error: {e}"); + } + }); + } + + 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::() { + if obj.article().id == id { + let link = obj.article().link.clone(); + if !link.is_empty() { + open_uri(&link); + } + break; + } + } + } + } + } + + // ── Zoom ────────────────────────────────────────────────────────────── + + 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); + 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); + let settings = gio::Settings::new("net.jeena.FeedTheMonkey"); + settings.set_double("zoom-level", 1.0).ok(); + } } impl WidgetImpl for FeedTheMonkeyWindow {} - impl WindowImpl for FeedTheMonkeyWindow {} + + impl WindowImpl for FeedTheMonkeyWindow { + fn close_request(&self) -> glib::Propagation { + // 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, |_| {}); +}