app: implement Epics 2–10

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
This commit is contained in:
Jeena 2026-03-20 11:57:06 +00:00
parent 8db0b16954
commit 813dda3579
22 changed files with 1838 additions and 42 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
.DS_Store
target/
data/gschemas.compiled

View file

@ -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]

BIN
data/gschemas.compiled Normal file

Binary file not shown.

View file

@ -2,6 +2,9 @@
<gresources>
<gresource prefix="/net/jeena/FeedTheMonkey">
<file preprocess="xml-stripblanks">ui/window.ui</file>
<file preprocess="xml-stripblanks">ui/login_dialog.ui</file>
<file preprocess="xml-stripblanks">ui/article_row.ui</file>
<file preprocess="xml-stripblanks">ui/shortcuts.ui</file>
<file>html/content.html</file>
<file>html/content.css</file>
</gresource>

42
data/ui/article_row.blp Normal file
View file

@ -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"]
}
}

62
data/ui/article_row.ui Normal file
View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
DO NOT EDIT!
This file was @generated by blueprint-compiler. Instead, edit the
corresponding .blp file and regenerate this file with blueprint-compiler.
-->
<interface>
<requires lib="gtk" version="4.0"/>
<template class="ArticleRow" parent="GtkBox">
<property name="orientation">1</property>
<property name="margin-top">12</property>
<property name="margin-bottom">12</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="spacing">4</property>
<child>
<object class="GtkBox">
<property name="orientation">0</property>
<property name="spacing">4</property>
<child>
<object class="GtkLabel" id="feed_title_label">
<property name="hexpand">true</property>
<property name="xalign">0</property>
<property name="ellipsize">3</property>
<style>
<class name="dim-label"/>
<class name="caption"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel" id="date_label">
<property name="xalign">1</property>
<style>
<class name="dim-label"/>
<class name="caption"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkLabel" id="title_label">
<property name="xalign">0</property>
<property name="wrap">true</property>
<property name="lines">2</property>
<property name="ellipsize">3</property>
</object>
</child>
<child>
<object class="GtkLabel" id="excerpt_label">
<property name="xalign">0</property>
<property name="ellipsize">3</property>
<property name="lines">1</property>
<style>
<class name="dim-label"/>
<class name="caption"/>
</style>
</object>
</child>
</template>
</interface>

41
data/ui/login_dialog.blp Normal file
View file

@ -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"]
}
}
}
}
}

58
data/ui/login_dialog.ui Normal file
View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
DO NOT EDIT!
This file was @generated by blueprint-compiler. Instead, edit the
corresponding .blp file and regenerate this file with blueprint-compiler.
-->
<interface>
<requires lib="gtk" version="4.0"/>
<template class="LoginDialog" parent="AdwDialog">
<property name="title" translatable="yes">Log In</property>
<property name="content-width">360</property>
<child>
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"></object>
</child>
<child>
<object class="AdwClamp">
<property name="margin-top">12</property>
<property name="margin-bottom">24</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwEntryRow" id="server_url_row">
<property name="title" translatable="yes">Server URL</property>
<property name="input-hints">2</property>
<property name="input-purpose">5</property>
</object>
</child>
<child>
<object class="AdwEntryRow" id="username_row">
<property name="title" translatable="yes">Username</property>
<property name="input-hints">2</property>
</object>
</child>
<child>
<object class="AdwPasswordEntryRow" id="password_row">
<property name="title" translatable="yes">Password</property>
</object>
</child>
<child>
<object class="AdwButtonRow" id="login_button">
<property name="title" translatable="yes">Log In</property>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</template>
</interface>

107
data/ui/shortcuts.blp Normal file
View file

@ -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: "<Control>plus";
}
ShortcutsShortcut {
title: _("Zoom out");
accelerator: "<Control>minus";
}
ShortcutsShortcut {
title: _("Reset zoom");
accelerator: "<Control>0";
}
ShortcutsShortcut {
title: _("Toggle fullscreen");
accelerator: "F11";
}
}
ShortcutsGroup {
title: _("Application");
ShortcutsShortcut {
title: _("Keyboard shortcuts");
accelerator: "F1";
}
ShortcutsShortcut {
title: _("Close window");
accelerator: "<Control>w";
}
ShortcutsShortcut {
title: _("Quit");
accelerator: "<Control>q";
}
}
}
}

134
data/ui/shortcuts.ui Normal file
View file

@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
DO NOT EDIT!
This file was @generated by blueprint-compiler. Instead, edit the
corresponding .blp file and regenerate this file with blueprint-compiler.
-->
<interface>
<requires lib="gtk" version="4.0"/>
<object class="GtkShortcutsWindow" id="help_overlay">
<property name="modal">true</property>
<child>
<object class="GtkShortcutsSection">
<property name="section-name">shortcuts</property>
<property name="max-height">10</property>
<child>
<object class="GtkShortcutsGroup">
<property name="title" translatable="yes">Navigation</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Next article</property>
<property name="accelerator">j Right</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Previous article</property>
<property name="accelerator">k Left</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkShortcutsGroup">
<property name="title" translatable="yes">Article</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Open in browser</property>
<property name="accelerator">Return n</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Mark as unread</property>
<property name="accelerator">u</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Reload articles</property>
<property name="accelerator">r</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkShortcutsGroup">
<property name="title" translatable="yes">View</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Scroll down</property>
<property name="accelerator">space Page_Down</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Scroll up</property>
<property name="accelerator">Page_Up</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Scroll to top</property>
<property name="accelerator">Home</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Scroll to bottom</property>
<property name="accelerator">End</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Zoom in</property>
<property name="accelerator">&lt;Control&gt;plus</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Zoom out</property>
<property name="accelerator">&lt;Control&gt;minus</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Reset zoom</property>
<property name="accelerator">&lt;Control&gt;0</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Toggle fullscreen</property>
<property name="accelerator">F11</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkShortcutsGroup">
<property name="title" translatable="yes">Application</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Keyboard shortcuts</property>
<property name="accelerator">F1</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Close window</property>
<property name="accelerator">&lt;Control&gt;w</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes">Quit</property>
<property name="accelerator">&lt;Control&gt;q</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -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 {};
}
}
}
};

View file

@ -54,10 +54,80 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
</object>
</child>
<child>
<object class="AdwStatusPage" id="placeholder_page">
<property name="icon-name">rss-symbolic</property>
<property name="title" translatable="yes">FeedTheMonkey</property>
<property name="description" translatable="yes">Log in to load your articles</property>
<object class="GtkStack" id="sidebar_content">
<child>
<object class="GtkStackPage">
<property name="name">placeholder</property>
<property name="child">
<object class="AdwStatusPage">
<property name="icon-name">rss-symbolic</property>
<property name="title" translatable="yes">FeedTheMonkey</property>
<property name="description" translatable="yes">Log in to load your articles</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="AdwStatusPage">
<property name="paintable">
<object class="AdwSpinnerPaintable"></object>
</property>
<property name="title" translatable="yes">Loading…</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">empty</property>
<property name="child">
<object class="AdwStatusPage">
<property name="icon-name">rss-symbolic</property>
<property name="title" translatable="yes">No Unread Articles</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="child">
<object class="AdwStatusPage" id="error_status">
<property name="icon-name">network-error-symbolic</property>
<property name="title" translatable="yes">Could Not Load Articles</property>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">Try Again</property>
<property name="halign">3</property>
<property name="action-name">win.reload</property>
<style>
<class name="pill"/>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">list</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">2</property>
<child>
<object class="GtkListView" id="article_list_view">
<property name="single-click-activate">true</property>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
@ -82,9 +152,26 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
</object>
</child>
<child>
<object class="AdwStatusPage">
<property name="icon-name">document-open-symbolic</property>
<property name="title" translatable="yes">No Article Selected</property>
<object class="GtkStack" id="content_stack">
<child>
<object class="GtkStackPage">
<property name="name">empty</property>
<property name="child">
<object class="AdwStatusPage">
<property name="icon-name">document-open-symbolic</property>
<property name="title" translatable="yes">No Article Selected</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">webview</property>
<property name="child">
<object class="WebKitWebView" id="web_view"></object>
</property>
</object>
</child>
</object>
</child>
</object>

View file

@ -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);
}
}

View file

@ -6,14 +6,44 @@
<link rel="stylesheet" href="content.css">
</head>
<body>
<div id="header">
<div id="feed-title"></div>
<h1 id="title"></h1>
<div id="meta">
<span id="author"></span>
<span id="date"></span>
</div>
<a id="link" href="#" onclick="window.location='feedthemonkey:open'; return false;">Open in Browser</a>
</div>
<div id="content"></div>
<script>
function setArticle(article) {
document.title = article.title || '';
document.getElementById('content').innerHTML =
'<h1>' + (article.title || '') + '</h1>' +
(article.content || '');
document.getElementById('feed-title').textContent = article.feed_title || '';
document.getElementById('title').textContent = article.title || '';
document.getElementById('author').textContent = article.author || '';
document.getElementById('content').innerHTML = article.content || '';
var ts = article.updated;
if (ts) {
document.getElementById('date').textContent = new Date(ts * 1000).toLocaleDateString();
}
window.scrollTo(0, 0);
window._article = article;
}
function checkKey(e) {
if (e.key === 'ArrowRight' || e.key === 'j') {
window.location = 'feedthemonkey:next';
} else if (e.key === 'ArrowLeft' || e.key === 'k') {
window.location = 'feedthemonkey:previous';
} else if (e.key === 'Enter' || e.key === 'n') {
window.location = 'feedthemonkey:open';
}
}
document.addEventListener('keydown', checkKey);
</script>
</body>
</html>

204
src/api.rs Normal file
View file

@ -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<Item>,
}
#[derive(Debug, Deserialize)]
struct Item {
id: String,
title: Option<String>,
origin: Option<Origin>,
canonical: Option<Vec<Link>>,
published: Option<i64>,
summary: Option<Summary>,
author: Option<String>,
categories: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct Origin {
title: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Link {
href: String,
}
#[derive(Debug, Deserialize)]
struct Summary {
content: Option<String>,
}
use crate::model::Article;
impl Api {
pub async fn login(
server_url: &str,
username: &str,
password: &str,
) -> Result<Self, String> {
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<String, String> {
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<Vec<Article>, 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::<Vec<_>>().join(" ");
if collapsed.chars().count() <= max_chars {
collapsed
} else {
collapsed.chars().take(max_chars).collect::<String>() + ""
}
}

View file

@ -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::<gtk4::Widget>()));
}
});
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: &gtk4::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);
}

132
src/article_row.rs Normal file
View file

@ -0,0 +1,132 @@
use gtk4::glib;
use gtk4::subclass::prelude::ObjectSubclassIsExt;
glib::wrapper! {
pub struct ArticleRow(ObjectSubclass<imp::ArticleRow>)
@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<gtk4::Label>,
#[template_child]
pub date_label: TemplateChild<gtk4::Label>,
#[template_child]
pub title_label: TemplateChild<gtk4::Label>,
#[template_child]
pub excerpt_label: TemplateChild<gtk4::Label>,
pub bindings: RefCell<Vec<glib::Binding>>,
pub unread_handler: RefCell<Option<(ArticleObject, glib::SignalHandlerId)>>,
}
#[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<Self>) {
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")
}
}

65
src/credentials.rs Normal file
View file

@ -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}");
}
}

105
src/login_dialog.rs Normal file
View file

@ -0,0 +1,105 @@
use gtk4::glib;
glib::wrapper! {
pub struct LoginDialog(ObjectSubclass<imp::LoginDialog>)
@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<libadwaita::EntryRow>,
#[template_child]
pub username_row: TemplateChild<libadwaita::EntryRow>,
#[template_child]
pub password_row: TemplateChild<libadwaita::PasswordEntryRow>,
#[template_child]
pub login_button: TemplateChild<libadwaita::ButtonRow>,
}
#[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<Self>) {
obj.init_template();
}
}
impl ObjectImpl for LoginDialog {
fn signals() -> &'static [glib::subclass::Signal] {
use std::sync::OnceLock;
static SIGNALS: OnceLock<Vec<glib::subclass::Signal>> = 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 {}
}

View file

@ -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"));
}

76
src/model.rs Normal file
View file

@ -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<imp::ArticleObject>);
}
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<Article>,
}
#[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<Vec<glib::ParamSpec>> = 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!(),
}
}
}
}

View file

@ -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<imp::FeedTheMonkeyWindow>)
@ -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<libadwaita::NavigationPage>,
#[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 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>,
}
#[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<Self>) {
@ -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::<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());
// 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::<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
}
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::<String>().unwrap();
let username = args[2].get::<String>().unwrap();
let password = args[3].get::<String>().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::<gtk4::Widget>()));
}
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::<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) {
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::<ArticleObject>() {
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::<ArticleObject>() {
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, |_| {});
}