FeedTheMonkey/BACKLOG.md
Jeena 3339bb5ec8 scaffold: Epic 1 — project scaffold
Add the full Rust + GTK4 + libadwaita project skeleton:
- Cargo.toml with all dependencies (gtk4 0.11, libadwaita 0.9,
  webkit6 0.6, reqwest, serde, tokio, libsecret)
- build.rs that compiles Blueprint .blp files and bundles a GResource
- data/ui/window.blp — AdwApplicationWindow with AdwNavigationSplitView,
  sidebar with refresh button/spinner and primary menu,
  content page with article menu
- data/resources.gresource.xml bundling UI, HTML, and CSS
- data/net.jeena.FeedTheMonkey.gschema.xml with all GSettings keys
- html/content.html and html/content.css (minimal placeholders)
- src/main.rs, src/app.rs — AdwApplication with APP_ID net.jeena.FeedTheMonkey
- src/window.rs — AdwApplicationWindow GObject subclass loading the
  Blueprint template and persisting window size in GSettings
- COPYING (GPL-3.0) restored from master

The app compiles and the binary is ready to open a blank window.
2026-03-20 11:22:19 +00:00

16 KiB

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=<token>
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=<id>&a=user/-/state/com.google/read&T=<token>
Mark unread POST /reader/api/0/edit-tag Body: i=<id>&r=user/-/state/com.google/read&T=<token>

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.rsAdwApplication with APP_ID net.jeena.FeedTheMonkey, activates the main window.

1.8 Write src/window.rsAdwApplicationWindow 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")
  • AdwClampAdwPreferencesGroup containing:
    • AdwEntryRow — Server URL (placeholder: https://example.com)
    • AdwEntryRow — Username
    • AdwPasswordEntryRow — Password
    • AdwButtonRow — "Log In" (style: .suggested-action)

2.2 Write src/login_dialog.rsLoginDialog 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.rsApi struct with:

  • login(server_url, username, password) -> Result<AuthToken> Calls POST /accounts/ClientLogin, parses Auth= line from response
  • fetch_write_token(auth_token) -> Result<WriteToken> 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.rsArticle 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<Vec<Article>> 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<ArticleObject>.

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.rsArticleRow 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 <title> 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.blpAdwShortcutsDialog 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.