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:
parent
8db0b16954
commit
813dda3579
22 changed files with 1838 additions and 42 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
.DS_Store
|
||||
target/
|
||||
data/gschemas.compiled
|
||||
|
|
|
|||
|
|
@ -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
BIN
data/gschemas.compiled
Normal file
Binary file not shown.
|
|
@ -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
42
data/ui/article_row.blp
Normal 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
62
data/ui/article_row.ui
Normal 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
41
data/ui/login_dialog.blp
Normal 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
58
data/ui/login_dialog.ui
Normal 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
107
data/ui/shortcuts.blp
Normal 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
134
data/ui/shortcuts.ui
Normal 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"><Control>plus</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes">Zoom out</property>
|
||||
<property name="accelerator"><Control>minus</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes">Reset zoom</property>
|
||||
<property name="accelerator"><Control>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"><Control>w</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes">Quit</property>
|
||||
<property name="accelerator"><Control>q</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
204
src/api.rs
Normal 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>() + "…"
|
||||
}
|
||||
}
|
||||
77
src/app.rs
77
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::<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: >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);
|
||||
}
|
||||
|
|
|
|||
132
src/article_row.rs
Normal file
132
src/article_row.rs
Normal 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
65
src/credentials.rs
Normal 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
105
src/login_dialog.rs
Normal 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 {}
|
||||
}
|
||||
|
|
@ -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
76
src/model.rs
Normal 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!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
466
src/window.rs
466
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<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, |_| {});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue