Previously the sidebar immediately showed an article as read the
moment it was selected, while the server was only notified when
the user moved to the next article. Align the two: mark the
previous article as read in both the sidebar and on the server
at the same time, when the user navigates away from it.
After a server refresh the article list is rebuilt, which previously
always reloaded the WebView and reset the scroll position.
- Disable SingleSelection autoselect so store rebuilds don't trigger
spurious selection changes.
- Skip the WebView reload in on_article_selected when the same article
is re-selected, preserving the user's scroll position.
- When the previously-read article is no longer in the server response
(read on another device), leave the sidebar unselected and keep the
old article visible so the user can finish reading.
- Handle no-selection state in navigate_by() so j/k still work.
- Show a toast with the unread count after every successful fetch.
Scroll events were going to the sidebar list because GTK's default
focus traversal landed on the article_list_view. Call grab_focus()
on the WebView at the end of constructed() so the content area
receives input by default.
image_cache::process() percent-encodes image URLs into feedthemonkey-img://
scheme URIs (e.g. /thumbs/ becomes %2Fthumbs%2F). Filters applied after
this step can no longer match the original substrings, so a rule like
www.stuttmann-karikaturen.de /thumbs/ /
had no effect when image caching was enabled.
Fix by applying filters to the raw article content before passing it to
image_cache::process(), so the corrected URLs are what get encoded into
the cache scheme. The render-time filter pass in load_article_in_webview
remains as a no-op for already-filtered content and still works correctly
for the non-cached code path.
Add shrink-end-child: false and width-request: 320 on the content
ToolbarView so the divider cannot be dragged all the way to the right
and hide the article view.
Remove shrink-start-child: false so the paned divider can actually be
dragged below the sidebar's natural minimum width. Defer the snap-close
via idle_add_local_once so widget visibility is not changed mid-drag.
When the paned divider is dragged left below 120 px, snap the sidebar
closed instead of leaving a sliver. This replaces the previous
minimum-width approach.
When the sidebar is hidden (via toggle or snap-close), show the refresh
button and primary menu in the content header so they remain reachable.
Both stacks are kept in sync (spinner/button) with their sidebar
counterparts during article fetches.
Add shrink-end-child: false to the Paned so the content panel cannot be
squeezed below its minimum size, and set width-request: 360 on the
content ToolbarView so there is always a visible reading area.
The check `.contains("user/-/state/com.google/read")` was a substring
match, which also matched "user/-/state/com.google/reading-list" — a
category present on every article fetched from the reading list. This
caused all articles to be treated as read, so nothing ever appeared
bold in the sidebar.
Fix by using == for exact string comparison.
Set unread bold state directly in bind() instead of relying on
obj.notify("unread"), which was unreliable during list factory binding
(GLib may defer or drop notifications during initial bind).
Also add a right-click context menu on each article row with a single
"Mark as Unread" item. The menu is a GtkPopover positioned at the
cursor. Clicking it activates the new win.mark-article-unread action,
which takes the article ID as a string parameter and reuses the
existing mark-unread logic.
Refactor do_mark_unread() to delegate to the new do_mark_article_unread()
so the behaviour is consistent whether triggered from the toolbar button,
keyboard shortcut, or right-click menu.
Miniflux serves the Greader API at the server root while FreshRSS uses
/api/greader.php. Instead of hardcoding the FreshRSS suffix, try the
URL as-is first (works for Miniflux) and fall back to appending
/api/greader.php (works for FreshRSS). The user just enters the server
URL without needing to know the API path.
After a successful article refresh, all images referenced in article
content are downloaded in the background so articles can be read
offline. The prefetch only runs when the cache-images setting is
enabled and the connection is not metered.
Read/unread state changes that fail to reach the server (e.g. when
offline) are now persisted to a local queue in
~/.cache/net.jeena.FeedTheMonkey/pending_sync.json. The queue is
flushed at the start of the next successful fetch.
process() was downloading all images before returning, blocking the
article list update for potentially minutes on a first run or after a
cache wipe. Move all network I/O out of process():
- process() now only rewrites src="https://..." to the custom
feedthemonkey-img:/// scheme — it is synchronous and instant.
- The URI scheme handler already downloads and caches on demand, so
images are fetched the first time the WebView requests them and
served from disk on every subsequent view.
This means the article list appears immediately after a server fetch
regardless of how many images need caching.
Instead of rewriting img src to file:// URIs, rewrite to a custom
feedthemonkey-img:/// scheme. A WebKit URI scheme handler is registered
on the WebView's WebContext that:
- Serves the image directly from the cache directory if present.
- On a cache miss (e.g. after the user deletes ~/.cache), spawns a
reqwest download in the tokio runtime, then resumes on the GLib main
loop via glib::spawn_future_local and serves the freshly downloaded
bytes — all transparent to the WebView.
This means deleting the cache directory never results in permanently
broken images; they are silently re-fetched on first access.
Both cache.json (article list) and the images directory are
regeneratable from the server, so they belong in XDG_CACHE_HOME
(~/.cache/net.jeena.FeedTheMonkey/) rather than XDG_DATA_HOME.
After fetching articles, all remote images referenced in article content
are downloaded to ~/.local/share/net.jeena.FeedTheMonkey/images/ and
their src attributes rewritten to file:// URIs. Subsequent loads of the
same article (including from the cache on the next startup) display
images without a network connection.
Metered-connection awareness: image caching is skipped automatically
when GIO reports the network connection as metered, regardless of the
preference setting.
A "Cache Images" toggle in Preferences lets the user disable caching
entirely (stored in the cache-images GSettings key).
After each refresh, images no longer referenced by any article in the
current unread list are deleted from the cache directory to prevent
unbounded disk growth.
On shutdown the full article list (including current read/unread state)
and the ID of the open article are saved to
~/.local/share/net.jeena.FeedTheMonkey/cache.json.
On next launch:
- The cached articles are loaded into the list immediately, before any
network request, so the sidebar is populated and the previously open
article is visible without waiting for the server.
- The article content is injected into the WebView once its base HTML
finishes loading (LoadEvent::Finished), avoiding a race where
window.setArticle() did not yet exist.
- A background refresh then fetches fresh data from the server; if the
previously open article still exists its selection is preserved,
otherwise the first item is selected.
- Network errors during a background refresh show a toast instead of
replacing the visible article list with an error page.
Sidebar layout:
- Replace AdwNavigationSplitView with GtkPaned for a resizable sidebar
with a persistent width stored in GSettings.
- Apply navigation-sidebar CSS class to the content Stack only (not the
ToolbarView) so both header bars share the same colour and height.
- Override Adwaita's automatic paned-first-child header tint and gap via
application-level CSS.
- Remove the gap between the sidebar header and the first list item.
- Add toggle-sidebar button and F9 shortcut; sidebar visibility and width
are persisted across restarts.
Loading indicator:
- Replace the large AdwSpinner status page + header Stack with a small
Gtk.Spinner (16×16) in the header Stack so the header height never
changes during loading.
Article row:
- Add hexpand to title and excerpt labels so text reflows when the
sidebar is resized.
Content:
- Inline CSS into the HTML template at load time (/*INJECT_CSS*/
placeholder) so WebKit does not need a custom URI scheme handler.
- Fix max-width centering and padding for article body and header.
- Fix embedded video/iframe auto-opening in browser by checking
NavigationType::LinkClicked instead of is_user_gesture().
Content filters:
- Add Preferences dialog with a TextView for content-rewrite rules
stored in GSettings (content-filters key).
- Rule format: "domain find replace [find replace …]" one per line.
- Rules are applied to article HTML before display and reloaded on
every refresh.
Shortcuts:
- Add Ctrl+W to close, Ctrl+Q to quit, F1 for keyboard shortcuts
overlay, j/k and arrow-key navigation via a capture-phase controller
so keys work regardless of which widget has focus.
Misc:
- Set window title to "FeedTheMonkey" (fixes Hyprland title bar).
- Update About dialog website URL.
When the server returns an HTML response (wrong URL, redirect to a
login page), the error dialog previously showed the full HTML body.
Now detect HTML responses and show a short actionable message:
- 404 with HTML: 'API endpoint not found. Check your server URL.'
- 401/403 with HTML: 'Wrong username or password.'
- 200 with HTML (no Auth= token): explain the endpoint is not FreshRSS
- Non-HTML bodies are shown as-is (they are already readable)
Three bugs fixed:
- No tokio reactor: glib::spawn_future_local does not provide a
tokio context, so reqwest/hyper panicked at runtime. Introduce
src/runtime.rs with a multi-thread tokio Runtime (init() called
from main before the GTK app starts). runtime::spawn() posts the
async result back to GTK via a tokio oneshot channel awaited by
glib::spawn_future_local, which only polls a flag (no I/O).
runtime::spawn_bg() is used for fire-and-forget background calls.
- Enter key didn't submit login: connect_apply on AdwEntryRow only
fires when show-apply-button is true. Switch to connect_entry_activated
which fires on Return in all three login rows.
- Wrong API URL: the app constructed /accounts/ClientLogin directly
off the server host, yielding a 404. Add normalize_base_url() in
api.rs that appends /api/greader.php when the URL doesn't already
contain it, so users can enter just https://rss.example.com.
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
build.rs now runs glib-compile-schemas on data/ so that debug builds
can find the schema without a system-wide install. main.rs sets
GSETTINGS_SCHEMA_DIR from the build-time constant when running in
debug mode.
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.