# FeedTheMonkey v3 — Backlog A rewrite of FeedTheMonkey from Qt5/C++/QML to Rust + GTK4 + libadwaita. The app is a FreshRSS client using the Google Reader compatible API. ## Design principles - Idiomatic libadwaita: follows the GNOME HIG, not a port of the Qt UI - Dark mode follows the system (AdwStyleManager), no in-app toggle - Credentials stored in the system keyring (libsecret), not a config file - App settings stored in GSettings ## Tech stack - Language: Rust (stable) - UI: GTK4 + libadwaita - Web content: webkit6 crate (webkitgtk-6.0) - UI templates: Blueprint (.blp) compiled at build time via blueprint-compiler - HTTP: reqwest (async, TLS) - JSON: serde + serde_json - Async: tokio - Credentials: libsecret crate - Settings: GSettings (net.jeena.FeedTheMonkey schema) - API: FreshRSS Google Reader compatible API ## API reference Base URL: `https://rss.jeena.net/api/greader.php` | Purpose | Method | Path | Notes | |---------------|--------|-----------------------------------------------|--------------------------------------------| | Login | POST | /accounts/ClientLogin | Body: `Email=&Passwd=` → `Auth=` | | Write token | GET | /reader/api/0/token | Header: `Authorization: GoogleLogin auth=` | | Fetch unread | GET | /reader/api/0/stream/contents/reading-list | `?xt=user/-/state/com.google/read&n=200&output=json` | | Mark read | POST | /reader/api/0/edit-tag | Body: `i=&a=user/-/state/com.google/read&T=` | | Mark unread | POST | /reader/api/0/edit-tag | Body: `i=&r=user/-/state/com.google/read&T=` | ### Article JSON → content.html field mapping | Google Reader field | JS template field | |-----------------------|-------------------| | title | title | | origin.title | feed_title | | canonical[0].href | link | | published (unix ts) | updated | | summary.content | content | | author | author | | unread | categories does NOT contain `user/-/state/com.google/read` | ## UI structure ``` AdwApplicationWindow └── AdwToastOverlay └── AdwNavigationSplitView ├── sidebar: AdwNavigationPage (title="FeedTheMonkey") │ └── AdwToolbarView │ ├── top: AdwHeaderBar │ │ ├── start: GtkButton (view-refresh-symbolic) │ │ │ → AdwSpinner while loading (via GtkStack) │ │ └── end: GtkMenuButton (open-menu-symbolic, primary=True) │ │ └── GMenu: Log Out, Keyboard Shortcuts, About │ └── content: GtkScrolledWindow → GtkListView (ArticleRow) │ or AdwStatusPage (empty / loading / error) └── content: AdwNavigationPage (title = article title, dynamic) └── AdwToolbarView (top-bar-style=raised) ├── top: AdwHeaderBar (auto back button on narrow) │ └── end: GtkMenuButton (view-more-symbolic) │ └── GMenu: Mark Unread, Open in Browser └── content: WebKitWebView (loads html/content.html) or AdwStatusPage ("Select an article") ``` ## GSettings schema Schema ID: `net.jeena.FeedTheMonkey` | Key | Type | Default | Notes | |------------------|--------|---------|----------------------------| | window-width | int | 900 | | | window-height | int | 600 | | | window-maximized | bool | false | | | sidebar-width | int | 280 | | | zoom-level | double | 1.0 | WebKitWebView zoom level | Credentials (libsecret, not GSettings): - server-url - username - password --- ## Epic 1 — Project scaffold Set up a working Rust + GTK4 + libadwaita project that opens a blank AdwApplicationWindow. No logic, just the skeleton. ### Stories **1.1** Create the `v3` git branch from `master`. **1.2** Write `Cargo.toml` with all dependencies: - gtk4, libadwaita, webkit6, gio, glib - reqwest (with rustls-tls feature, no native-tls) - serde, serde_json - tokio (full features) - secret (libsecret) - blueprint-compiler is a build-time tool, not a crate **1.3** Write `build.rs` that: 1. Runs `blueprint-compiler batch-compile` on all `.blp` files in `data/ui/` to produce `.ui` files alongside them 2. Runs `glib-compile-resources` on `data/resources.gresource.xml` to produce a compiled `.gresource` file, then registers it **1.4** Write `data/resources.gresource.xml` bundling: - Compiled `.ui` files from `data/ui/` - `html/content.html` and `html/content.css` **1.5** Write the GSettings schema `data/net.jeena.FeedTheMonkey.gschema.xml` with the keys listed above. **1.6** Write `data/ui/window.blp` — a minimal `AdwApplicationWindow` containing `AdwNavigationSplitView` with placeholder sidebar and content pages. **1.7** Write `src/main.rs` and `src/app.rs` — `AdwApplication` with APP_ID `net.jeena.FeedTheMonkey`, activates the main window. **1.8** Write `src/window.rs` — `AdwApplicationWindow` GObject subclass that loads the window Blueprint template and restores window size from GSettings. **1.9** Verify the app compiles and opens a blank window. --- ## Epic 2 — Authentication Login dialog, credential storage, session management, logout. ### Stories **2.1** Write `data/ui/login_dialog.blp` — an `AdwDialog` with: - `AdwToolbarView` + `AdwHeaderBar` (title "Log In") - `AdwClamp` → `AdwPreferencesGroup` containing: - `AdwEntryRow` — Server URL (placeholder: `https://example.com`) - `AdwEntryRow` — Username - `AdwPasswordEntryRow` — Password - `AdwButtonRow` — "Log In" (style: `.suggested-action`) **2.2** Write `src/login_dialog.rs` — `LoginDialog` GObject subclass using the Blueprint template. Emits a `logged-in` signal on success. **2.3** Write `src/settings.rs` — libsecret helpers: - `store_credentials(server_url, username, password)` - `load_credentials() -> Option<(server_url, username, password)>` - `clear_credentials()` **2.4** Write `src/api.rs` — `Api` struct with: - `login(server_url, username, password) -> Result` Calls `POST /accounts/ClientLogin`, parses `Auth=` line from response - `fetch_write_token(auth_token) -> Result` Calls `GET /reader/api/0/token` **2.5** Connect login dialog to `Api::login()`. On success: - Store credentials via libsecret - Store `AuthToken` and `WriteToken` in application state - Close the dialog, trigger article fetch (Epic 3) **2.6** On app start: call `load_credentials()`. If found, call `Api::login()` automatically and skip showing the login dialog. **2.7** Implement logout: clear libsecret credentials, discard tokens, show the login dialog again. **2.8** Show login errors via `AdwAlertDialog` (title "Login Failed", body = server error message, single "OK" button). --- ## Epic 3 — Article fetching Fetch unread articles from the server and populate the list model. ### Stories **3.1** Write `src/model.rs` — `Article` struct (plain Rust, not GObject): ``` id: String title: String feed_title: String author: String link: String published: i64 ← unix timestamp content: String excerpt: String ← plain text, first ~150 chars of content stripped of HTML unread: bool ``` Also a GObject wrapper `ArticleObject` (implementing `glib::Object`) so it can be stored in a `gio::ListStore`. **3.2** Add `Api::fetch_unread(auth_token) -> Result>` that: - Calls `GET /reader/api/0/stream/contents/reading-list?xt=...&n=200&output=json` - Deserializes the JSON response - Maps Google Reader fields to `Article` struct - Derives `unread` from `categories` not containing `user/-/state/com.google/read` **3.3** In `src/window.rs`: after successful login, call `fetch_unread()` in a background tokio task and populate a `gio::ListStore`. **3.4** Show `AdwStatusPage` with `AdwSpinnerPaintable` and title "Loading…" in the sidebar while fetching is in progress. **3.5** Show `AdwStatusPage` with icon `rss-symbolic` and title "No unread articles" when the list is empty after a successful fetch. **3.6** Show `AdwStatusPage` with icon `network-error-symbolic` and title "Could not load articles" (description = error message) on network/API error. Include a "Try Again" button that re-triggers the fetch. **3.7** Implement reload: the refresh button in the sidebar header bar re-runs `fetch_unread()`, replaces the list store contents, and scrolls the list back to the top. --- ## Epic 4 — Sidebar Article list view with custom rows, navigation, and split view behavior. ### Stories **4.1** Write `data/ui/article_row.blp` — composite template for `ArticleRow`: ``` GtkBox (orientation=vertical, margin=12, spacing=4) ├── GtkBox (orientation=horizontal, spacing=4) │ ├── GtkLabel (id=feed_title, hexpand=True, xalign=0, .dim-label, small font) │ └── GtkLabel (id=date, xalign=1, .dim-label, small font) ├── GtkLabel (id=title, xalign=0, wrap=True, lines=2) └── GtkLabel (id=excerpt, xalign=0, .dim-label, small font, ellipsize=end, lines=1) ``` **4.2** Write `src/article_row.rs` — `ArticleRow` GObject subclass. - Bind `ArticleObject` properties to labels - Apply `.dim-label` style to the title label when article is read - Format `published` timestamp as relative time ("2 hours ago", "Yesterday", etc.) **4.3** Set up `GtkListView` in the sidebar with a `GtkSignalListItemFactory` that creates and binds `ArticleRow` widgets. **4.4** Connect list selection (`GtkSingleSelection`) to show the selected article in the content pane and call `adw_navigation_split_view_set_show_content(true)` on narrow screens. **4.5** Implement `next_article()` and `previous_article()` functions that move the selection in the list view (used by keyboard shortcuts). **4.6** Persist and restore `sidebar-width` from GSettings using `AdwNavigationSplitView::sidebar-width-fraction` or equivalent. --- ## Epic 5 — Content pane WebKitWebView loading content.html, link handling, empty state. ### Stories **5.1** Update `html/content.html`: - Change `` from "TTRSS" to "FeedTheMonkey" - Remove `setFont()` and `setNightmode()` JavaScript functions (no longer needed) - Remove `"loading"` and `"empty"` string handling from `setArticle()` (these states are now handled by `AdwStatusPage` in Rust) - Remove the `"logout"` string handling - Keep `setArticle(article)` for JSON object input - Keep `checkKey()` and the keyboard event listener **5.2** Update `html/content.css`: - Replace the `.nightmode` body class block with `@media (prefers-color-scheme: dark) { ... }` - Keep all existing styles, just change the selector **5.3** Write `src/content_view.rs` — wrapper around `webkit::WebView`: - Loads `content.html` from GResource on init - Exposes `set_article(article: &Article)` which calls `window.setArticle()` via `WebView::evaluate_javascript()` - Handles `decide-policy` signal: intercept `feedthemonkey:previous`, `feedthemonkey:next`, `feedthemonkey:open` URIs and emit corresponding GObject signals; intercept all other link clicks and open with `gtk::show_uri()` - Exposes `zoom_level` property (get/set), used by Epic 8 **5.4** Show `AdwStatusPage` with icon `document-open-symbolic` and title "No Article Selected" when nothing is selected in the sidebar. **5.5** Ensure WebKit loads the local `content.html` from GResource using a custom URI scheme or `load_html()` with a base URI so that `content.css` is resolved correctly. --- ## Epic 6 — Read state Auto-mark-read, manual mark-unread, API sync. ### Stories **6.1** Add `Api::mark_read(auth_token, write_token, item_id) -> Result<()>` that POSTs to `/reader/api/0/edit-tag` with `i=<id>&a=user/-/state/com.google/read&T=<write_token>`. **6.2** Add `Api::mark_unread(auth_token, write_token, item_id) -> Result<()>` that POSTs to `/reader/api/0/edit-tag` with `i=<id>&r=user/-/state/com.google/read&T=<write_token>`. **6.3** When the selected article changes (user navigates to next/previous), mark the *previously selected* article as read via `Api::mark_read()`. Update `ArticleObject::unread` to `false` so the row styling updates immediately (optimistic update — no waiting for the API response). **6.4** Implement "Mark Unread" action (key `u`, content pane menu): - Set `ArticleObject::unread` to `true` (optimistic update) - Call `Api::mark_unread()` in the background - Show an `AdwToast` "Marked as unread" in the `AdwToastOverlay` - Set a guard flag so navigating away does not re-mark the article as read **6.5** Handle API errors from mark_read/mark_unread silently (log to stderr, do not show a dialog — these are background operations). --- ## Epic 7 — Keyboard shortcuts Full keyboard navigation matching the feature set of v2. ### Stories **7.1** Register `GtkShortcutController` on the main window for all shortcuts: | Key(s) | Action | |-----------------|-----------------------------------------| | j, Right | Next article | | k, Left | Previous article | | Enter, n | Open current article in browser | | r | Reload articles | | u | Mark current article unread | | Space, Page_Down| Scroll content down one page | | Page_Up | Scroll content up one page | | Home | Scroll content to top | | End | Scroll content to bottom | | Ctrl+plus | Zoom in | | Ctrl+minus | Zoom out | | Ctrl+0 | Reset zoom | | F11 | Toggle fullscreen | | Ctrl+W | Close window | | Ctrl+Q | Quit application | **7.2** Write `data/ui/shortcuts.blp` — `AdwShortcutsDialog` documenting all shortcuts, grouped by category (Navigation, Article, View, Application). **7.3** Connect the "Keyboard Shortcuts" menu item to show the shortcuts dialog. Also register `F1` as an alias. --- ## Epic 8 — Zoom WebView zoom with keyboard shortcuts and persistence. ### Stories **8.1** Implement `zoom_in()`, `zoom_out()`, `zoom_reset()` in `content_view.rs`: - `zoom_in`: multiply `WebView::zoom_level` by 1.1 - `zoom_out`: divide by 1.1 - `zoom_reset`: set to 1.0 **8.2** On zoom change, save the new zoom level to GSettings (`zoom-level` key). **8.3** On app start, read `zoom-level` from GSettings and apply to the WebView. --- ## Epic 9 — Window state persistence Save and restore window size and sidebar width. ### Stories **9.1** On window close, save `window-width`, `window-height`, `window-maximized` to GSettings. **9.2** On window open, restore width, height, and maximized state from GSettings. **9.3** Save `sidebar-width` to GSettings when the sidebar is resized. Restore on startup. --- ## Epic 10 — Polish Final details for a shippable app. ### Stories **10.1** Write `data/net.jeena.FeedTheMonkey.desktop` — `.desktop` file with `Name=FeedTheMonkey`, `Exec=feedthemonkey`, `Icon=feedthemonkey`, `Categories=Network;Feed;`. **10.2** Include the existing `misc/feedthemonkey.xpm` icon in the GResource bundle and reference it in the `.desktop` file. **10.3** Implement `AdwAboutDialog` with app name, version, license (GPL-3.0), website, and developer name. Connect to "About" menu item. **10.4** Handle the write token expiry: if an `edit-tag` call returns 401, fetch a new write token and retry once. **10.5** Add `AdwAlertDialog` for logout confirmation ("Are you sure you want to log out?" with "Log Out" destructive button and "Cancel"). **10.6** Graceful shutdown: cancel in-flight API requests when the window closes.