Compare commits

..

52 commits

Author SHA1 Message Date
41312f48f3 window: only mark articles as read when navigating away
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.
2026-03-28 22:57:04 +00:00
d39c7f824b window: preserve reading position across server reloads
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.
2026-03-27 23:35:39 +00:00
07b41c7407 window: focus content area instead of sidebar on startup
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.
2026-03-27 23:35:32 +00:00
0917f57dbd filters: apply content filters before image-cache URL rewriting
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.
2026-03-22 11:09:28 +00:00
126bd19770 build: regenerate window.ui from blueprint 2026-03-22 04:22:21 +00:00
60fc9d7cfd window: prevent paned from covering the content area
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.
2026-03-22 04:21:28 +00:00
d75c63a8ce window: fix sidebar snap-collapse when dragged narrow
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.
2026-03-22 04:20:02 +00:00
6d6d928733 window: collapse sidebar when dragged too narrow; keep controls accessible
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.
2026-03-22 02:10:37 +00:00
db41a691e6 window: prevent sidebar from covering the content area
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.
2026-03-22 02:06:57 +00:00
2c5217e744 api: fix unread detection broken by substring match
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.
2026-03-22 01:58:51 +00:00
9a4bf4b9f8 article-row: fix bold on initial load, add right-click menu
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.
2026-03-22 01:51:12 +00:00
571d80fa6b api: decode HTML entities in article excerpts 2026-03-22 01:17:49 +00:00
81439edf87 sidebar: use Pango markup for bold, fix zoom CSS specificity 2026-03-22 00:40:20 +00:00
e5f2d5c941 filters: reload current article immediately when preferences closes 2026-03-22 00:37:10 +00:00
b63549ae0a sidebar: fix unread bold via notify, larger title font, sidebar zoom 2026-03-22 00:34:51 +00:00
c19c2cbd1d window: scale sidebar fonts with zoom level 2026-03-21 13:09:28 +00:00
3bcd6af23c sidebar: use CSS class for bold unread title 2026-03-21 12:51:49 +00:00
a77fa3ae03 sidebar: bold title for unread articles 2026-03-21 12:49:58 +00:00
03b1936740 docs: fix image caching description in README 2026-03-21 12:47:06 +00:00
b280d83234 docs: add features list and river of news description to README 2026-03-21 12:44:31 +00:00
a4a01a6394 docs: add screenshot and trivia section to README 2026-03-21 12:39:41 +00:00
de47b21a52 docs: add app icon to README 2026-03-21 12:32:13 +00:00
8dc71214aa data: replace generated SVG icon with original PNG 2026-03-21 12:27:13 +00:00
dec1bfdc7e docs: remove completed backlog 2026-03-21 12:25:19 +00:00
e17f0c622b login: update compiled UI with server URL hint 2026-03-21 12:24:19 +00:00
88afb27a22 login: add server URL format hint to login dialog 2026-03-21 12:17:55 +00:00
f05eec6d53 docs: fix README intro formatting 2026-03-21 12:02:48 +00:00
68c4f6ccfe docs: clarify server URL format for FreshRSS and Miniflux 2026-03-21 12:01:48 +00:00
ff92499406 docs: name supported servers explicitly instead of implying universal detection 2026-03-21 11:59:00 +00:00
b49cc69c49 api: auto-detect Greader API path for Miniflux and FreshRSS
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.
2026-03-21 11:57:19 +00:00
82aabc080a docs: remove TT-RSS from compatible servers list 2026-03-21 11:53:36 +00:00
6afab6f421 docs: clarify app works with any Greader API server 2026-03-21 11:37:18 +00:00
ed10ba1310 docs: add README with build and runtime dependencies 2026-03-21 11:36:44 +00:00
85b05a14bc data: add app icon, desktop entry, and install script 2026-03-21 03:14:15 +00:00
668c73c8d2 window: show toast instead of login dialog when offline with cached articles 2026-03-21 03:05:19 +00:00
d6858b62a7 webview: disable context menu 2026-03-21 02:49:05 +00:00
9bed643023 window: prefetch images and queue offline read/unread actions
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.
2026-03-21 02:45:45 +00:00
3f759bce2e image-cache: rewrite URLs eagerly, download lazily via scheme handler
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.
2026-03-21 02:37:06 +00:00
00700c3211 image-cache: use custom URI scheme for transparent cache-miss re-download
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.
2026-03-21 01:33:40 +00:00
8e21c80a33 cache: store all cached data under XDG_CACHE_HOME
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.
2026-03-21 01:28:11 +00:00
fda441bebd feature: cache article images for offline reading
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.
2026-03-21 01:19:49 +00:00
183191727b window: persist article list and open article across restarts
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.
2026-03-21 01:13:09 +00:00
8fd52dd8a0 ui: overhaul sidebar, add content filters and state improvements
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.
2026-03-21 01:13:01 +00:00
141f9ee32d fix: show human-readable login errors instead of raw HTML
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)
2026-03-20 12:21:01 +00:00
5dee5cc52b fix: tokio runtime, Enter-to-login, and server URL handling
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.
2026-03-20 12:17:27 +00:00
d157f3f244 gitignore: exclude compiled schema file 2026-03-20 11:57:09 +00:00
813dda3579 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
2026-03-20 11:57:06 +00:00
8db0b16954 scaffold: compile GSettings schema at build time for dev runs
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.
2026-03-20 11:36:12 +00:00
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
3196988c98 scaffold: start v3 rewrite from scratch
Remove all Qt5/C++/QML source files to begin a full rewrite in
Rust + GTK4 + libadwaita. The BACKLOG.md describes the plan.
2026-03-20 11:16:27 +00:00
efbd570830
fix typo in README.md 2025-10-01 22:21:01 +02:00
3f492b6160 Fix problem with HDPI screens
On high dpi screens the thml was shown way too small, this let's it
scale properly.
2021-05-24 22:10:23 +02:00
43 changed files with 5712 additions and 10652 deletions

6
.gitignore vendored
View file

@ -1,3 +1,3 @@
#Added by cargo
/target
.DS_Store
target/
data/gschemas.compiled

33
.travis.yml Normal file
View file

@ -0,0 +1,33 @@
language: cpp
compiler: gcc
sudo: require
dist: trusty
before_install:
- sudo add-apt-repository ppa:beineri/opt-qt58-trusty -y
- sudo apt-get update -qq
install:
- sudo apt-get -y install qt58base qt58webengine qt58quickcontrols
- source /opt/qt58/bin/qt58-env.sh
script:
- qmake PREFIX=/usr
- make -j4
- sudo make INSTALL_ROOT=appdir install ; sudo chown -R $USER appdir ; find appdir/
- wget -c "https://github.com/probonopd/linuxdeployqt/releases/download/continuous/linuxdeployqt-continuous-x86_64.AppImage"
- chmod a+x linuxdeployqt*.AppImage
- unset QTDIR; unset QT_PLUGIN_PATH ; unset LD_LIBRARY_PATH
- "./linuxdeployqt*.AppImage ./appdir/usr/share/applications/*.desktop -qmldir=./qml/
-bundle-non-qt-libs"
- "./linuxdeployqt*.AppImage ./appdir/usr/share/applications/*.desktop -qmldir=./qml/
-appimage"
- find ./appdir -executable -type f -exec ldd {} \; | grep " => /usr" | cut -d " "
-f 2-3 | sort | uniq
- mv FeedTheMonkey*.AppImage FeedTheMonkey.AppImage
deploy:
provider: releases
api_key:
secure: d+hHwOnmeLPVvuue6VDCs2LwLS+BFzJF/BB5iObtkCYBwQ8ybnVzUcgnjJKOt37SHI0T9kLegI+Lq/843ECYiGiDjQg4PvCF69V8ODgHv3v1qiN5oG/eroBXd83a0+xhi4BuJt0SwcV9mcv4uD9bCPhj944rmMLH+3qD4ysgImBmbYSbbLecE9+QAs7bfrCwQRfdCePBORX3FHa/p12NEtln7xv6ZRyku9LdJSzAcdgm4zc95ggTAVC1+aQB6J0q2QzWPlQcOkLx+ZYmOqClhbSMFpIyPXP8UpXjYyvUlTAd0+wH8BGf0O3lpOqACc7IKIbj9d5oPmghVZo55SyW+RR77G+az+IbGJ7iXZsMfQZsMvtB7hNYhNvUUxQrAau7Y/ve+6sMQmvA7aMHV8kDUvnNW/c2r2jAWwk+N8QzGcP/rclDCKeOWZqZABmrzTViXZVAeXh4hJ8r6mbq8iwagBUPCsVYhVuerQt/KIoWxyn6/1GmMfKGi3dA/v3u1qU61vzrz3yLlJBmUAVPxZdVmqfRweh4BXjImxFMFmf5PYm5FnDg1gmw8rWsgii7+IPYw7DjTAHpjYbtXvDwDgG1nRXiRp2TGtPPgKW1/Uk8r/j5vfB5WcEZ7exLUgsPPjny5MGvzjqOxeLvwK1Pg9jFBFXIx7l1tNMJQxQU0r3DmBg=
file: FeedTheMonkey.AppImage
on:
repo: jeena/FeedTheMonkey
skip_cleanup: true
draft: true

45
.vscode/launch.json vendored
View file

@ -1,45 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'feedthemonkey'",
"cargo": {
"args": [
"build",
"--bin=feedthemonkey",
"--package=feedthemonkey"
],
"filter": {
"name": "feedthemonkey",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'feedthemonkey'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=feedthemonkey",
"--package=feedthemonkey"
],
"filter": {
"name": "feedthemonkey",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

2308
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,23 @@
[package]
name = "feedthemonkey"
version = "3.0.0"
authors = ["Jeena <hello@jeena.net>"]
edition = "2018"
readme = "README.md"
repository = "https://github.com/jeena/feedthemonkey"
description = "Desktop client for the TinyTinyRSS feed reader"
license = "GPL-3.0-or-later"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "feedthemonkey"
path = "src/main.rs"
[dependencies]
url = { version = "2.1.1", features = ["serde"] }
url_serde = "0.2.0"
confy = "0.3.1"
serde = { version = "1.0", features = ["derive"] }
gtk4 = { version = "0.11", features = ["v4_14"] }
libadwaita = { version = "0.9", features = ["v1_6"] }
webkit6 = { version = "0.6" }
gio = { version = "0.22" }
glib = { version = "0.22" }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
libsecret = { version = "0.9", features = ["v0_19"] }
[dependencies.gtk]
version = "0.9.0"
features = ["v3_16"]
[dependencies.gio]
version = ""
features = ["v2_44"]
[build-dependencies]

121
README.md
View file

@ -1,58 +1,101 @@
# FeedTheMonkey
<img align=right src="http://jeena.net/feedthemonkey/feedthemonkey-icon.png" width='256' alt='Icon'>
<img align="right" src="data/icons/net.jeena.FeedTheMonkey.png" width="256" alt="Icon">
FeedTheMonkey is a desktop client for [TinyTinyRSS](http://tt-rss.org). That means that
it doesn't work as a standalone feed reader but only as a client for the TinyTinyRSS API
which it uses to get the normalized feeds and to synchronize the "article read" marks.
FeedTheMonkey is a desktop client for any server that implements the
[Greader API](https://github.com/theoldreader/api).
It is written in Rust and GTK. You need to have an account on a TinyTinyRSS server.
It doesn't work as a standalone feed reader — it connects to a server to fetch articles and sync read state.
## Installation
It follows the [river of news](http://scripting.com/2014/06/02/whatIsARiverOfNewsAggregator.html) philosophy: all unread articles appear in a single flat list and are automatically marked as read as you flip through them one by one.
TBD
## Features
## Keyboard shortcuts
- **River of news**: all unread articles in one flat list, auto-marked as read as you flip through them one by one
- **Offline reading**: article content and images are cached locally so you can read without a connection
- **Image caching**: images are pre-fetched after each reload; can be disabled in preferences and is always skipped on metered connections
- **Offline sync**: read/unread state changes made offline are queued and pushed to the server next time you're online
- **Persistent state**: the article list, selected article, and scroll position are restored when you reopen the app
- **Dark mode**: follows the system color scheme automatically
- **Keyboard navigation**: vi-style shortcuts for hands-free reading
- **Zoom**: adjustable content zoom, persisted across restarts
- **Fullscreen** and toggleable sidebar
The keyboard shortcuts are inspired by other feed readers which are inspired by the text editor vi.
When logging in, enter:
`j` or `→` show nex article
`k` or `←` show previous article
`n` or `Return` open current article in the default browser
`r` reload articles
`F11` full screen
`1` night mode
`Ctrl Q` quit
`Ctrl +` zoom in
`Ctrl -` zoom out
`Ctrl 0` reset zoom
- **FreshRSS**: `https://example.com/api/greader.php`
- **Miniflux**: `https://example.com`
- Other compatible servers: consult your server's documentation for the Greader API endpoint
## Dependencies
### Runtime
- GTK 4 (`gtk4`)
- libadwaita (`libadwaita`)
- WebKitGTK 6 (`webkitgtk-6.0`)
- GLib / GIO (`glib-2.0`, `gio-2.0`)
On Arch Linux: `sudo pacman -S gtk4 libadwaita webkitgtk-6.0`
### Build
- Rust toolchain (`rustup` / `cargo`)
- `blueprint-compiler` — compiles `.blp` UI files to `.ui`
- `glib-compile-schemas` — compiles GSettings schemas (part of `glib2`)
- `glib-compile-resources` — compiles GResource bundles (part of `glib2`)
On Arch Linux: `sudo pacman -S blueprint-compiler glib2`
## Building
```sh
cargo build --release
```
The binary is at `target/release/feedthemonkey`.
## Installing
```sh
sudo ./install.sh
```
This installs the binary, icon, desktop entry, and GSettings schema to `/usr/local`.
Set `PREFIX` to install elsewhere:
```sh
sudo PREFIX=/usr ./install.sh
```
## Trivia
This is version 3 of FeedTheMonkey, you can find version 1 which was written
in PyQt in the v1 branch of this repo and version 2 which was written in C++
and Qt/QML in the v2 branch.
This is version 3 of FeedTheMonkey, rewritten in Rust with GTK4 and libadwaita.
Version 2 was written in C++ with Qt and QML, and version 1 in PyQt — you can find
them in the `v2` and `v1` branches of this repo.
## Screenshot
![Feed the Monkey screenshot](http://jeena.net/feedthemonkey/feedthemonkey-dark.png)
![FeedTheMonkey screenshot](data/screenshot.png)
## Keyboard shortcuts
| Key | Action |
|-----|--------|
| `j` or `→` | Next article |
| `k` or `←` | Previous article |
| `Return` | Open in browser |
| `r` | Reload |
| `F11` | Toggle fullscreen |
| `Ctrl+W` | Quit |
| `Ctrl++` | Zoom in |
| `Ctrl+-` | Zoom out |
| `Ctrl+0` | Reset zoom |
## License
This file is part of FeedTheMonkey.
Copyright 20152026 Jeena
Copyright 2020 Jeena
FeedTheMonkey is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FeedTheMonkey is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FeedTheMonkey. If not, see <http://www.gnu.org/licenses/>.
FeedTheMonkey is free software: you can redistribute it and/or modify it under the terms
of the GNU General Public License as published by the Free Software Foundation, either
version 3 of the License, or (at your option) any later version.

66
build.rs Normal file
View file

@ -0,0 +1,66 @@
use std::path::PathBuf;
use std::process::Command;
fn main() {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let data_dir = manifest_dir.join("data");
let ui_dir = data_dir.join("ui");
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
// Compile all .blp files to .ui files
let blp_files: Vec<_> = std::fs::read_dir(&ui_dir)
.expect("data/ui/ directory not found")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map_or(false, |ext| ext == "blp"))
.collect();
for blp in &blp_files {
println!("cargo:rerun-if-changed={}", blp.display());
}
if !blp_files.is_empty() {
let status = Command::new("blueprint-compiler")
.arg("batch-compile")
.arg(&ui_dir)
.arg(&ui_dir)
.args(&blp_files)
.status()
.expect("failed to run blueprint-compiler — is it installed?");
assert!(status.success(), "blueprint-compiler failed");
}
// Compile GSettings schema into data/ so dev builds can find it
let schema_file = data_dir.join("net.jeena.FeedTheMonkey.gschema.xml");
println!("cargo:rerun-if-changed={}", schema_file.display());
let status = Command::new("glib-compile-schemas")
.arg(&data_dir)
.status()
.expect("failed to run glib-compile-schemas — is it installed?");
assert!(status.success(), "glib-compile-schemas failed");
println!("cargo:rustc-env=GSETTINGS_SCHEMA_DIR={}", data_dir.display());
// Compile GResource
let gresource_xml = data_dir.join("resources.gresource.xml");
println!("cargo:rerun-if-changed={}", gresource_xml.display());
// Watch HTML/CSS so changes trigger a resource rebuild
let html_dir = manifest_dir.join("html");
if let Ok(entries) = std::fs::read_dir(&html_dir) {
for entry in entries.filter_map(|e| e.ok()) {
println!("cargo:rerun-if-changed={}", entry.path().display());
}
}
let gresource_out = out_dir.join("feedthemonkey.gresource");
let status = Command::new("glib-compile-resources")
.arg(format!("--sourcedir={}", data_dir.display()))
.arg(format!("--sourcedir={}", manifest_dir.display()))
.arg(format!("--target={}", gresource_out.display()))
.arg(&gresource_xml)
.status()
.expect("failed to run glib-compile-resources — is it installed?");
assert!(status.success(), "glib-compile-resources failed");
println!("cargo:rustc-env=GRESOURCE_FILE={}", gresource_out.display());
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -0,0 +1,11 @@
[Desktop Entry]
Name=FeedTheMonkey
GenericName=Feed Reader
Comment=A desktop client for the Tiny Tiny RSS feed reader
Exec=feedthemonkey
Icon=net.jeena.FeedTheMonkey
Terminal=false
Type=Application
Categories=Network;News;GTK;
StartupNotify=true
StartupWMClass=feedthemonkey

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="net.jeena.FeedTheMonkey" path="/net/jeena/FeedTheMonkey/">
<key name="window-width" type="i">
<default>900</default>
<summary>Window width</summary>
</key>
<key name="window-height" type="i">
<default>600</default>
<summary>Window height</summary>
</key>
<key name="window-maximized" type="b">
<default>false</default>
<summary>Window maximized state</summary>
</key>
<key name="sidebar-width" type="i">
<default>280</default>
<summary>Sidebar width in pixels</summary>
</key>
<key name="zoom-level" type="d">
<default>1.0</default>
<summary>WebView zoom level</summary>
</key>
<key name="content-filters" type="s">
<default>''</default>
<summary>Content rewrite rules, one per line: domain from to [from to …]</summary>
</key>
<key name="cache-images" type="b">
<default>true</default>
<summary>Download and cache article images for offline reading (skipped on metered connections)</summary>
</key>
</schema>
</schemalist>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<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 preprocess="xml-stripblanks">ui/preferences_dialog.ui</file>
<file>html/content.html</file>
<file>html/content.css</file>
</gresource>
</gresources>

BIN
data/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

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

@ -0,0 +1,45 @@
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 {
hexpand: true;
xalign: 0;
wrap: true;
lines: 2;
ellipsize: end;
styles ["article-title"]
}
Label excerpt_label {
hexpand: true;
xalign: 0;
ellipsize: end;
lines: 1;
styles ["dim-label", "caption"]
}
}

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

@ -0,0 +1,67 @@
<?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="hexpand">true</property>
<property name="xalign">0</property>
<property name="wrap">true</property>
<property name="lines">2</property>
<property name="ellipsize">3</property>
<style>
<class name="article-title"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel" id="excerpt_label">
<property name="hexpand">true</property>
<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>

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

@ -0,0 +1,43 @@
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 {
description: _("FreshRSS: https://example.com/api/greader.php\nMiniflux: https://example.com");
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"]
}
}
}
}
}

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

@ -0,0 +1,60 @@
<?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">
<property name="description" translatable="yes">FreshRSS: https://example.com/api/greader.php
Miniflux: https://example.com</property>
<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>

View file

@ -0,0 +1,38 @@
using Gtk 4.0;
using Adw 1;
template $PreferencesDialog : Adw.Dialog {
title: _("Preferences");
content-width: 500;
content-height: 400;
Adw.ToolbarView {
[top]
Adw.HeaderBar {}
Adw.PreferencesPage {
Adw.PreferencesGroup {
title: _("Images");
Adw.SwitchRow cache_images_row {
title: _("Cache Images");
subtitle: _("Download images for offline reading (skipped on metered connections)");
}
}
Adw.PreferencesGroup {
title: _("Content Filters");
description: _("One rule per line: domain find replace [find replace …]\n\nExample:\n www.imycomic.com -150x150.jpg .jpg");
TextView filters_text_view {
monospace: true;
wrap-mode: word;
top-margin: 8;
bottom-margin: 8;
left-margin: 8;
right-margin: 8;
}
}
}
}
}

View file

@ -0,0 +1,55 @@
<?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="PreferencesDialog" parent="AdwDialog">
<property name="title" translatable="yes">Preferences</property>
<property name="content-width">500</property>
<property name="content-height">400</property>
<child>
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"></object>
</child>
<child>
<object class="AdwPreferencesPage">
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Images</property>
<child>
<object class="AdwSwitchRow" id="cache_images_row">
<property name="title" translatable="yes">Cache Images</property>
<property name="subtitle" translatable="yes">Download images for offline reading (skipped on metered connections)</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Content Filters</property>
<property name="description" translatable="yes">One rule per line: domain find replace [find replace …]
Example:
www.imycomic.com -150x150.jpg .jpg</property>
<child>
<object class="GtkTextView" id="filters_text_view">
<property name="monospace">true</property>
<property name="wrap-mode">2</property>
<property name="top-margin">8</property>
<property name="bottom-margin">8</property>
<property name="left-margin">8</property>
<property name="right-margin">8</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</template>
</interface>

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

@ -0,0 +1,112 @@
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 sidebar");
accelerator: "F9";
}
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";
}
}
}
}

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

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

221
data/ui/window.blp Normal file
View file

@ -0,0 +1,221 @@
using Gtk 4.0;
using Adw 1;
using WebKit 6.0;
template $FeedTheMonkeyWindow : Adw.ApplicationWindow {
default-width: 900;
default-height: 600;
Adw.ToastOverlay toast_overlay {
Paned paned {
focusable: false;
resize-start-child: false;
shrink-end-child: false;
start-child: Adw.ToolbarView sidebar_toolbar {
top-bar-style: raised;
[top]
Adw.HeaderBar {
show-start-title-buttons: false;
show-end-title-buttons: false;
title-widget: Box {};
[start]
Stack refresh_stack {
StackPage {
name: "button";
child: Button refresh_button {
icon-name: "view-refresh-symbolic";
tooltip-text: _("Refresh");
action-name: "win.reload";
};
}
StackPage {
name: "spinner";
child: Spinner {
spinning: true;
width-request: 16;
height-request: 16;
};
}
}
[end]
MenuButton menu_button {
icon-name: "open-menu-symbolic";
primary: true;
menu-model: primary_menu;
}
}
Stack sidebar_content {
styles ["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 {
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: false;
show-separators: true;
}
};
}
}
};
end-child: Adw.ToolbarView {
top-bar-style: raised;
width-request: 320;
[top]
Adw.HeaderBar {
[start]
Button toggle_sidebar_button {
icon-name: "sidebar-show-symbolic";
tooltip-text: _("Toggle Sidebar");
action-name: "win.toggle-sidebar";
}
[start]
Stack content_refresh_stack {
visible: false;
StackPage {
name: "button";
child: Button {
icon-name: "view-refresh-symbolic";
tooltip-text: _("Refresh");
action-name: "win.reload";
};
}
StackPage {
name: "spinner";
child: Spinner {
spinning: true;
width-request: 16;
height-request: 16;
};
}
}
title-widget: Adw.WindowTitle {
title: _("FeedTheMonkey");
};
[end]
MenuButton content_menu_button {
icon-name: "open-menu-symbolic";
primary: true;
menu-model: primary_menu;
visible: false;
}
[end]
MenuButton article_menu_button {
icon-name: "view-more-symbolic";
menu-model: article_menu;
visible: false;
}
}
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 {};
}
}
};
}
}
}
menu primary_menu {
section {
item {
label: _("Log Out");
action: "win.logout";
}
}
section {
item {
label: _("Preferences");
action: "win.preferences";
}
}
section {
item {
label: _("Keyboard Shortcuts");
action: "win.show-help-overlay";
}
item {
label: _("About FeedTheMonkey");
action: "app.about";
}
}
}
menu article_menu {
section {
item {
label: _("Mark Unread");
action: "win.mark-unread";
}
item {
label: _("Open in Browser");
action: "win.open-in-browser";
}
}
}

276
data/ui/window.ui Normal file
View file

@ -0,0 +1,276 @@
<?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="FeedTheMonkeyWindow" parent="AdwApplicationWindow">
<property name="default-width">900</property>
<property name="default-height">600</property>
<child>
<object class="AdwToastOverlay" id="toast_overlay">
<child>
<object class="GtkPaned" id="paned">
<property name="focusable">false</property>
<property name="resize-start-child">false</property>
<property name="shrink-end-child">false</property>
<property name="start-child">
<object class="AdwToolbarView" id="sidebar_toolbar">
<property name="top-bar-style">1</property>
<child type="top">
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkBox"></object>
</property>
<child type="start">
<object class="GtkStack" id="refresh_stack">
<child>
<object class="GtkStackPage">
<property name="name">button</property>
<property name="child">
<object class="GtkButton" id="refresh_button">
<property name="icon-name">view-refresh-symbolic</property>
<property name="tooltip-text" translatable="yes">Refresh</property>
<property name="action-name">win.reload</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">spinner</property>
<property name="child">
<object class="GtkSpinner">
<property name="spinning">true</property>
<property name="width-request">16</property>
<property name="height-request">16</property>
</object>
</property>
</object>
</child>
</object>
</child>
<child type="end">
<object class="GtkMenuButton" id="menu_button">
<property name="icon-name">open-menu-symbolic</property>
<property name="primary">true</property>
<property name="menu-model">primary_menu</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkStack" id="sidebar_content">
<style>
<class name="sidebar-content"/>
</style>
<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="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">false</property>
<property name="show-separators">true</property>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</property>
<property name="end-child">
<object class="AdwToolbarView">
<property name="top-bar-style">1</property>
<property name="width-request">320</property>
<child type="top">
<object class="AdwHeaderBar">
<child type="start">
<object class="GtkButton" id="toggle_sidebar_button">
<property name="icon-name">sidebar-show-symbolic</property>
<property name="tooltip-text" translatable="yes">Toggle Sidebar</property>
<property name="action-name">win.toggle-sidebar</property>
</object>
</child>
<child type="start">
<object class="GtkStack" id="content_refresh_stack">
<property name="visible">false</property>
<child>
<object class="GtkStackPage">
<property name="name">button</property>
<property name="child">
<object class="GtkButton">
<property name="icon-name">view-refresh-symbolic</property>
<property name="tooltip-text" translatable="yes">Refresh</property>
<property name="action-name">win.reload</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">spinner</property>
<property name="child">
<object class="GtkSpinner">
<property name="spinning">true</property>
<property name="width-request">16</property>
<property name="height-request">16</property>
</object>
</property>
</object>
</child>
</object>
</child>
<property name="title-widget">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">FeedTheMonkey</property>
</object>
</property>
<child type="end">
<object class="GtkMenuButton" id="content_menu_button">
<property name="icon-name">open-menu-symbolic</property>
<property name="primary">true</property>
<property name="menu-model">primary_menu</property>
<property name="visible">false</property>
</object>
</child>
<child type="end">
<object class="GtkMenuButton" id="article_menu_button">
<property name="icon-name">view-more-symbolic</property>
<property name="menu-model">article_menu</property>
<property name="visible">false</property>
</object>
</child>
</object>
</child>
<child>
<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>
</property>
</object>
</child>
</object>
</child>
</template>
<menu id="primary_menu">
<section>
<item>
<attribute name="label" translatable="yes">Log Out</attribute>
<attribute name="action">win.logout</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Preferences</attribute>
<attribute name="action">win.preferences</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Keyboard Shortcuts</attribute>
<attribute name="action">win.show-help-overlay</attribute>
</item>
<item>
<attribute name="label" translatable="yes">About FeedTheMonkey</attribute>
<attribute name="action">app.about</attribute>
</item>
</section>
</menu>
<menu id="article_menu">
<section>
<item>
<attribute name="label" translatable="yes">Mark Unread</attribute>
<attribute name="action">win.mark-unread</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Open in Browser</attribute>
<attribute name="action">win.open-in-browser</attribute>
</item>
</section>
</menu>
</interface>

View file

@ -1,112 +1,131 @@
/*
* This file is part of FeedTheMonkey.
*
* Copyright 2015 Jeena
*
* FeedTheMonkey is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* FeedTheMonkey is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with FeedTheMonkey. If not, see <http://www.gnu.org/licenses/>.
*/
/* CSS custom properties are set from Rust via AdwStyleManager.
The :root defaults below act as a light-mode fallback only. */
:root {
--bg: #ffffff;
--fg: #1a1a1a;
--fg-dim: rgba(0,0,0,0.55);
--border: rgba(0,0,0,0.12);
--header-bg: #f6f5f4;
--link: #1c71d8;
--code-bg: rgba(0,0,0,0.06);
--blockquote-border: rgba(0,0,0,0.2);
--font: sans-serif;
--font-size: 15px;
}
:root[data-dark="1"] {
--bg: #1e1e1e;
--fg: rgba(255,255,255,0.87);
--fg-dim: rgba(255,255,255,0.5);
--border: rgba(255,255,255,0.12);
--header-bg: #242424;
--link: #78aeed;
--code-bg: rgba(255,255,255,0.06);
--blockquote-border: rgba(255,255,255,0.2);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
background: #eee;
font-family: sans-serif;
word-wrap: break-word;
}
.nightmode {
background: #353535;
color: #ddd;
}
.nightmode::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.nightmode::-webkit-scrollbar-track-piece {
background-color: #111;
}
.nightmode::-webkit-scrollbar-thumb:vertical {
height: 30px;
background-color: #444;
border-radius: 5px;
}
h1 {
font-size: 1.4em;
margin: 0;
padding: 0;
}
.starred:after {
content: "*";
}
header {
padding: 2em;
border-bottom: 1px solid #aaa;
}
.nightmode header {
border-bottom-color: #222;
}
header p {
color: #666;
margin: 0;
padding: 0;
}
.nightmode header p {
color: #888;
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: var(--font-size);
word-wrap: break-word;
}
a {
color: inherit;
text-decoration: none;
}
article {
line-height: 1.6;
margin: 2em;
color: var(--link);
text-decoration: none;
}
article a {
text-decoration: underline;
text-decoration: underline;
}
blockquote {
font-style: italic;
header {
padding: 1.5em 2em 1em;
background: var(--header-bg);
border-bottom: 1px solid var(--border);
}
header > .inner,
article {
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
header > .inner {
padding: 0 2em;
}
header h1 {
font-size: 1.3em;
margin: 0.2em 0 0.4em;
padding: 0;
line-height: 1.3;
}
header h1 a {
color: var(--fg);
}
header p {
color: var(--fg-dim);
margin: 0;
padding: 0;
font-size: 0.85em;
}
article {
line-height: 1.6;
padding: 1.5em 2em 2em;
}
img {
max-width: 100%;
height: auto;
max-width: 100%;
height: auto;
}
div > a:only-child img, figure > a:only-child img, p > a:only-child img,
figure > img:only-child, div > img:only-child, p > img:only-child {
display: block;
margin: 1em auto;
float: none !important;
div > a:only-child img,
figure > a:only-child img,
p > a:only-child img,
figure > img:only-child,
div > img:only-child,
p > img:only-child {
display: block;
margin: 1em auto;
float: none !important;
}
pre {
overflow: auto;
overflow: auto;
background: var(--code-bg);
padding: 1em;
border-radius: 6px;
font-size: 0.9em;
}
code {
background: var(--code-bg);
padding: 0.15em 0.35em;
border-radius: 3px;
font-size: 0.9em;
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 3px solid var(--blockquote-border);
margin-left: 0;
padding-left: 1em;
color: var(--fg-dim);
font-style: italic;
}

View file

@ -1,107 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>TTRSS</title>
<link href="content.css" media="all" rel="stylesheet">
<script type="text/javascript">
/*
* This file is part of FeedTheMonkey.
*
* Copyright 2015 Jeena
*
* FeedTheMonkey is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* FeedTheMonkey is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with FeedTheMonkey. If not, see <http://www.gnu.org/licenses/>.
*/
function $(id) {
return document.getElementById(id);
}
<meta charset="UTF-8">
<meta name="color-scheme" content="light dark">
<title>FeedTheMonkey</title>
<style>/*INJECT_CSS*/</style>
</head>
<body>
<header>
<div class="inner">
<p><span id="feed_title"></span> <span id="author"></span></p>
<h1><a id="title" href=""></a></h1>
<p><time id="date"></time></p>
</div>
</header>
<article id="article"></article>
<script>
function setArticle(article) {
window.scrollTo(0, 0);
$("date").innerHTML = "";
$("title").innerHTML = "";
$("title").href = "";
$("title").title = "";
$("feed_title").innerHTML = "";
$("author").innerHTML = "";
$("article").innerHTML = "";
if(article === "empty") {
$("article").innerHTML = "No unread articles to display.";
} else if(article === "loading") {
$("article").innerHTML = "Loading <blink>&hellip;</blink>";
} else if (article === "logout") {
} else if(article) {
$("date").innerHTML = (new Date(parseInt(article.updated, 10) * 1000));
$("title").innerHTML = article.title;
$("title").href = article.link;
$("title").title = article.link;
$("feed_title").innerHTML = article.feed_title;
$("title").className = article.marked ? "starred" : "";
$("author").innerHTML = "";
if(article.author && article.author.length > 0)
$("author").innerHTML = "&ndash; " + article.author
$("article").innerHTML = article.content;
var as = $("article").getElementsByTagName("a");
for(var i = 0; i < as.length; i++) {
as[i].target = "";
}
}
window.scrollTo(0, 0);
document.getElementById('date').textContent = '';
document.getElementById('title').textContent = '';
document.getElementById('title').href = '';
document.getElementById('feed_title').textContent = '';
document.getElementById('author').textContent = '';
document.getElementById('article').innerHTML = '';
if (!article) return;
document.getElementById('date').textContent =
new Date(parseInt(article.updated, 10) * 1000).toLocaleDateString();
document.getElementById('title').textContent = article.title || '';
document.getElementById('title').href = article.link || '';
document.getElementById('feed_title').textContent = article.feed_title || '';
if (article.author && article.author.length > 0)
document.getElementById('author').textContent = '\u2013 ' + article.author;
document.getElementById('article').innerHTML = article.content || '';
}
function setFont(font, size) {
document.body.style.fontFamily = font;
document.body.style.fontSize = size + "pt";
function setDark(isDark) {
document.documentElement.setAttribute('data-dark', isDark ? '1' : '0');
}
function setNightmode(nightmode) {
if(nightmode) document.body.className = "nightmode";
else document.body.className = "";
function setFont(family, sizePx) {
document.documentElement.style.setProperty('--font', family);
document.documentElement.style.setProperty('--font-size', sizePx + 'px');
}
function checkKey(e) {
e = e || window.event;
if (e.keyCode === 37) {
window.location.href = "feedthemonkey:previous";
} else if (e.keyCode === 39) {
window.location.href = "feedthemonkey:next";
} else if(e.keyCode == 13) {
window.location.href = "feedthemonkey:open";
}
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';
}
}
window.addEventListener("keydown", checkKey);
</script>
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
</head>
<body class=''>
<header>
<p><span id="feed_title"></span> <span id="author"></span></p>
<h1><a id="title" href=""></a></h1>
<p><timedate id="date"></timedate></p>
</header>
<article id="article"></article>
document.addEventListener('keydown', checkKey);
</script>
</body>
</html>

31
install.sh Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Install FeedTheMonkey system-wide (requires root or sudo).
# Run after: cargo build --release
set -e
PREFIX="${PREFIX:-/usr/local}"
BINARY="target/release/feedthemonkey"
if [ ! -f "$BINARY" ]; then
echo "Binary not found. Run 'cargo build --release' first."
exit 1
fi
install -Dm755 "$BINARY" "$PREFIX/bin/feedthemonkey"
install -Dm644 data/net.jeena.FeedTheMonkey.desktop \
"$PREFIX/share/applications/net.jeena.FeedTheMonkey.desktop"
install -Dm644 data/icons/net.jeena.FeedTheMonkey.png \
"$PREFIX/share/icons/hicolor/256x256/apps/net.jeena.FeedTheMonkey.png"
# Install GSettings schema
install -Dm644 data/net.jeena.FeedTheMonkey.gschema.xml \
"$PREFIX/share/glib-2.0/schemas/net.jeena.FeedTheMonkey.gschema.xml"
glib-compile-schemas "$PREFIX/share/glib-2.0/schemas/"
# Update icon cache if gtk-update-icon-cache is available
if command -v gtk-update-icon-cache &>/dev/null; then
gtk-update-icon-cache -f -t "$PREFIX/share/icons/hicolor"
fi
echo "Installed to $PREFIX"

Binary file not shown.

View file

@ -1,11 +0,0 @@
[Desktop Entry]
Comment=A desktop client for the TinyTinyRSS feed reader.
Exec=feedthemonkey
GenericName=Feed Reader
Icon=feedthemonkey
Name=FeedTheMonkey
NoDisplay=false
StartupNotify=true
Terminal=false
Type=Application
Categories=Network;Qt;

File diff suppressed because it is too large Load diff

268
src/api.rs Normal file
View file

@ -0,0 +1,268 @@
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;
/// Return the candidate base URLs to try in order.
/// Miniflux serves the Greader API at the server root;
/// FreshRSS serves it at /api/greader.php.
fn candidate_base_urls(server_url: &str) -> Vec<String> {
let base = server_url.trim_end_matches('/');
if base.ends_with("/api/greader.php") {
vec![base.to_string()]
} else {
vec![
base.to_string(),
format!("{base}/api/greader.php"),
]
}
}
impl Api {
pub async fn login(
server_url: &str,
username: &str,
password: &str,
) -> Result<Self, String> {
let client = Client::new();
let candidates = candidate_base_urls(server_url);
let mut last_err = String::new();
for base in candidates {
let url = format!("{base}/accounts/ClientLogin");
let resp = match client
.post(&url)
.form(&[("Email", username), ("Passwd", password)])
.send()
.await
{
Ok(r) => r,
Err(e) => { last_err = e.to_string(); continue; }
};
let status = resp.status();
let body = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
last_err = format!("Login failed ({}): {}", status.as_u16(), human_error(&body, status.as_u16()));
continue;
}
let auth_token = match body.lines().find_map(|l| l.strip_prefix("Auth=")) {
Some(t) => t.to_string(),
None => {
last_err = if looks_like_html(&body) {
format!(
"The server at {base} does not appear to be a \
Greader API endpoint. Check your server URL."
)
} else {
format!("Unexpected response from server: {}", body.trim())
};
continue;
}
};
return Ok(Self { client, server_url: base, auth_token });
}
Err(last_err)
}
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 == "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 looks_like_html(body: &str) -> bool {
let trimmed = body.trim_start();
trimmed.starts_with("<!") || trimmed.to_ascii_lowercase().starts_with("<html")
}
fn human_error(body: &str, status: u16) -> String {
if looks_like_html(body) {
match status {
401 | 403 => "Wrong username or password.".to_string(),
404 => "API endpoint not found. Check your server URL.".to_string(),
_ => format!("Server returned HTTP {status}. Check your server URL."),
}
} else {
let trimmed = body.trim();
if trimmed.is_empty() {
format!("Server returned HTTP {status} with no message.")
} else {
trimmed.to_string()
}
}
}
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(" ");
let decoded = decode_html_entities(&collapsed);
if decoded.chars().count() <= max_chars {
decoded
} else {
decoded.chars().take(max_chars).collect::<String>() + ""
}
}
fn decode_html_entities(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
}

View file

@ -1,63 +1,166 @@
extern crate gio;
extern crate gtk;
extern crate confy;
use gtk4::prelude::*;
use libadwaita::prelude::*;
use gio::prelude::*;
use gtk::prelude::*;
use std::string::String;
use crate::settings::Settings;
use crate::window::FeedTheMonkeyWindow;
const APP_ID: &str = "net.jeena.feedthemonkey";
const APP_ID: &str = "net.jeena.FeedTheMonkey";
#[derive(Debug)]
pub struct App {
uiapp: gtk::Application,
settings: Option<Settings>,
glib::wrapper! {
pub struct FeedTheMonkeyApp(ObjectSubclass<imp::FeedTheMonkeyApp>)
@extends libadwaita::Application, gtk4::Application, gio::Application,
@implements gio::ActionGroup, gio::ActionMap;
}
impl App {
impl FeedTheMonkeyApp {
pub fn new() -> Self {
let app = gtk::Application::new(
Some(&APP_ID),
gio::ApplicationFlags::FLAGS_NONE,
)
.expect("Application::new failed");
app.connect_activate(|app| {
// We create the main window.
let win = gtk::ApplicationWindow::new(app);
// Then we set its size and a title.
win.set_default_size(320, 200);
win.set_title("FeedTheMonkey");
// Don't forget to make all widgets visible.
win.show_all();
});
let conf: Result<Settings, std::io::Error> = confy::load(&APP_ID);
match conf {
Ok(s) => Self { uiapp: app, settings: Some(s) },
Err(_) => Self { uiapp: app, settings: None },
}
glib::Object::builder()
.property("application-id", APP_ID)
.property("flags", gio::ApplicationFlags::empty())
.build()
}
pub fn run(&self, argv: &[String]) {
/*
match self.settings {
Some(_) => self.show_content(),
None => match confy::store(&APP_ID, Settings::default()) { // TODO: Replace default data with login form data
Ok(_) => println!("New settings stored"),
Err(e) => eprint!("Error while storing settings: {}", e)
}
}
*/
self.uiapp.run(&argv);
pub fn run(&self) -> glib::ExitCode {
ApplicationExtManual::run(self)
}
pub fn show_content(&self) {
println!("Show content")
}
}
mod imp {
use super::*;
use libadwaita::subclass::prelude::*;
#[derive(Default)]
pub struct FeedTheMonkeyApp;
#[glib::object_subclass]
impl ObjectSubclass for FeedTheMonkeyApp {
const NAME: &'static str = "FeedTheMonkeyApp";
type Type = super::FeedTheMonkeyApp;
type ParentType = libadwaita::Application;
}
impl ObjectImpl for FeedTheMonkeyApp {}
impl ApplicationImpl for FeedTheMonkeyApp {
fn activate(&self) {
self.parent_activate();
let app = self.obj();
// Register GResource
let resource_bytes = glib::Bytes::from_static(include_bytes!(env!("GRESOURCE_FILE")));
let resource = gio::Resource::from_data(&resource_bytes)
.expect("failed to load GResource");
gio::resources_register(&resource);
// Apply application-level CSS tweaks
let css = gtk4::CssProvider::new();
css.load_from_string(
"paned > :first-child .top-bar headerbar {
background-color: @headerbar_bg_color;
box-shadow: none;
}
paned > :first-child > toolbarview > .content {
padding-top: 0;
margin-top: 0;
}
.sidebar-content row:not(:selected) {
background-color: alpha(@window_fg_color, 0.07);
}
.sidebar-content row:not(:selected):hover {
background-color: alpha(@window_fg_color, 0.14);
}
.sidebar-content row:selected {
background-color: alpha(@window_fg_color, 0.22);
}
.article-title {
font-size: 1.05em;
}"
);
gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().unwrap(),
&css,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
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://git.jeena.net/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();
}
}
impl GtkApplicationImpl for FeedTheMonkeyApp {}
impl AdwApplicationImpl for FeedTheMonkeyApp {}
}
fn setup_shortcuts(window: &FeedTheMonkeyWindow) {
use gtk4::gdk::{Key, ModifierType};
let controller = gtk4::ShortcutController::new();
controller.set_scope(gtk4::ShortcutScope::Global);
let add = |controller: &gtk4::ShortcutController,
key: Key,
mods: ModifierType,
action_name: &str| {
let trigger = gtk4::KeyvalTrigger::new(key, mods);
let action = gtk4::NamedAction::new(action_name);
let shortcut = gtk4::Shortcut::new(Some(trigger), Some(action));
controller.add_shortcut(shortcut);
};
// j/k/Left/Right are handled by a capture-phase key controller in window.rs
// so they work regardless of which widget has focus.
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::F9, ModifierType::empty(), "win.toggle-sidebar");
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);
}

193
src/article_row.rs Normal file
View file

@ -0,0 +1,193 @@
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)>>,
pub context_menu: std::cell::OnceCell<gtk4::Popover>,
}
#[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 {
fn constructed(&self) {
self.parent_constructed();
self.setup_context_menu();
}
fn dispose(&self) {
if let Some(popover) = self.context_menu.get() {
popover.unparent();
}
}
}
impl WidgetImpl for ArticleRow {}
impl BoxImpl for ArticleRow {}
impl ArticleRow {
fn setup_context_menu(&self) {
let button = gtk4::Button::with_label("Mark as Unread");
button.set_has_frame(false);
let popover = gtk4::Popover::new();
popover.set_child(Some(&button));
popover.set_parent(&*self.obj());
self.context_menu.set(popover.clone()).ok();
// Close popover and activate action when button is clicked.
let imp_weak = self.downgrade();
let popover_weak = popover.downgrade();
button.connect_clicked(move |_| {
if let Some(popover) = popover_weak.upgrade() {
popover.popdown();
}
let Some(imp) = imp_weak.upgrade() else { return };
let handler = imp.unread_handler.borrow();
let Some((obj, _)) = handler.as_ref() else { return };
let article_id = obj.article().id.clone();
drop(handler);
imp.obj()
.activate_action(
"win.mark-article-unread",
Some(&article_id.to_variant()),
)
.ok();
});
// Right-click gesture to show the popover at cursor position.
let gesture = gtk4::GestureClick::new();
gesture.set_button(3);
let popover_weak2 = popover.downgrade();
gesture.connect_pressed(move |gesture, _, x, y| {
gesture.set_state(gtk4::EventSequenceState::Claimed);
let Some(popover) = popover_weak2.upgrade() else { return };
popover.set_pointing_to(Some(&gtk4::gdk::Rectangle::new(
x as i32,
y as i32,
1,
1,
)));
popover.popup();
});
self.obj().add_controller(gesture);
}
pub fn bind(&self, obj: &ArticleObject) {
let article = obj.article();
self.feed_title_label.set_text(&article.feed_title);
self.excerpt_label.set_text(&article.excerpt);
self.date_label.set_text(&relative_time(article.published));
// Set initial bold state directly (using Pango markup to avoid
// CSS specificity issues with the zoom font-size provider).
let escaped = glib::markup_escape_text(&article.title);
if article.unread {
self.title_label.remove_css_class("dim-label");
self.title_label.set_markup(&format!("<b>{escaped}</b>"));
} else {
self.title_label.add_css_class("dim-label");
self.title_label.set_markup(&escaped);
}
drop(article);
// Connect handler for future unread state changes.
let title_label = self.title_label.clone();
let id = obj.connect_notify_local(Some("unread"), move |obj, _| {
let article = obj.article();
let escaped = glib::markup_escape_text(&article.title);
if article.unread {
title_label.remove_css_class("dim-label");
title_label.set_markup(&format!("<b>{escaped}</b>"));
} else {
title_label.add_css_class("dim-label");
title_label.set_markup(&escaped);
}
});
*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 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")
}
}

29
src/cache.rs Normal file
View file

@ -0,0 +1,29 @@
use crate::model::Article;
pub struct Cache {
pub articles: Vec<Article>,
pub selected_id: String,
}
pub fn save(articles: &[Article], selected_id: &str) {
let dir = glib::user_cache_dir().join("net.jeena.FeedTheMonkey");
std::fs::create_dir_all(&dir).ok();
let data = serde_json::json!({
"articles": articles,
"selected_id": selected_id,
});
if let Ok(s) = serde_json::to_string(&data) {
std::fs::write(dir.join("cache.json"), s).ok();
}
}
pub fn load() -> Option<Cache> {
let path = glib::user_cache_dir()
.join("net.jeena.FeedTheMonkey")
.join("cache.json");
let data = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&data).ok()?;
let articles: Vec<Article> = serde_json::from_value(json["articles"].clone()).ok()?;
let selected_id = json["selected_id"].as_str().unwrap_or("").to_string();
Some(Cache { articles, selected_id })
}

65
src/credentials.rs Normal file
View file

@ -0,0 +1,65 @@
use libsecret::{prelude::*, SchemaAttributeType, SchemaFlags, SearchFlags};
use std::collections::HashMap;
const SCHEMA_NAME: &str = "net.jeena.FeedTheMonkey";
const ATTR_SERVER: &str = "server-url";
const ATTR_USERNAME: &str = "username";
const LABEL: &str = "FeedTheMonkey credentials";
fn schema() -> libsecret::Schema {
libsecret::Schema::new(
SCHEMA_NAME,
SchemaFlags::NONE,
HashMap::from([
(ATTR_SERVER, SchemaAttributeType::String),
(ATTR_USERNAME, SchemaAttributeType::String),
]),
)
}
pub fn store_credentials(server_url: &str, username: &str, password: &str) {
let schema = schema();
let attrs = HashMap::from([
(ATTR_SERVER, server_url),
(ATTR_USERNAME, username),
]);
if let Err(e) = libsecret::password_store_sync(
Some(&schema),
attrs,
Some(libsecret::COLLECTION_DEFAULT.as_str()),
LABEL,
password,
gio::Cancellable::NONE,
) {
eprintln!("Failed to store credentials: {e}");
}
}
pub fn load_credentials() -> Option<(String, String, String)> {
let schema = schema();
let items = libsecret::password_search_sync(
Some(&schema),
HashMap::new(),
SearchFlags::LOAD_SECRETS | SearchFlags::UNLOCK,
gio::Cancellable::NONE,
).ok()?;
let item = items.into_iter().next()?;
let attrs = item.attributes();
let server_url = attrs.get(ATTR_SERVER)?.to_string();
let username = attrs.get(ATTR_USERNAME)?.to_string();
let secret = item.retrieve_secret_sync(gio::Cancellable::NONE).ok()??;
let password = secret.text()?.to_string();
Some((server_url, username, password))
}
pub fn clear_credentials() {
let schema = schema();
if let Err(e) = libsecret::password_clear_sync(
Some(&schema),
HashMap::new(),
gio::Cancellable::NONE,
) {
eprintln!("Failed to clear credentials: {e}");
}
}

68
src/filters.rs Normal file
View file

@ -0,0 +1,68 @@
/// Content rewrite rules stored in GSettings key "content-filters".
///
/// Format — one rule per line, tokens separated by spaces:
///
/// domain from to [from to …]
///
/// Examples:
///
/// www.imycomic.com -150x150.jpg .jpg
/// www.stuttmann-karikaturen.de /thumbs/ /
/// existentialcomics.com src="//static src="https://static
///
/// The domain is matched as a substring of the article's GUID and link URL.
/// Blank lines and lines starting with # are ignored.
use gtk4::gio;
use gtk4::prelude::SettingsExt;
pub struct Rule {
pub pattern: String,
pub replacements: Vec<(String, String)>,
}
/// Parse the multi-line text from the GSettings key into rules.
pub fn parse(text: &str) -> Vec<Rule> {
let mut rules = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let tokens: Vec<&str> = line.splitn(usize::MAX, ' ')
.map(str::trim)
.filter(|t| !t.is_empty())
.collect();
if tokens.len() < 3 || tokens.len() % 2 == 0 {
// Need: domain + at least one from/to pair (odd total ≥ 3)
eprintln!("filters: skipping malformed line: {line}");
continue;
}
let pattern = tokens[0].to_string();
let replacements = tokens[1..]
.chunks(2)
.map(|c| (c[0].to_string(), c[1].to_string()))
.collect();
rules.push(Rule { pattern, replacements });
}
rules
}
/// Load rules from GSettings.
pub fn load_rules() -> Vec<Rule> {
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
parse(&settings.string("content-filters"))
}
/// Apply all matching rules to `content`.
pub fn apply(rules: &[Rule], guid: &str, link: &str, content: &str) -> String {
let mut out = content.to_string();
for rule in rules {
if guid.contains(&rule.pattern) || link.contains(&rule.pattern) {
for (from, to) in &rule.replacements {
out = out.replace(from.as_str(), to.as_str());
}
}
}
out
}

211
src/image_cache.rs Normal file
View file

@ -0,0 +1,211 @@
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use crate::model::Article;
fn images_dir() -> PathBuf {
glib::user_cache_dir()
.join("net.jeena.FeedTheMonkey")
.join("images")
}
fn url_to_filename(url: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
let hash = format!("{:016x}", hasher.finish());
let ext = url.split('?').next()
.and_then(|u| u.rsplit('.').next())
.filter(|e| e.len() <= 5 && e.bytes().all(|b| b.is_ascii_alphanumeric()))
.unwrap_or("");
if ext.is_empty() { hash } else { format!("{}.{}", hash, ext) }
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-.~_".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(b) = u8::from_str_radix(
std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""),
16,
) {
out.push(b);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
const SCHEME: &str = "feedthemonkey-img";
const SCHEME_PREFIX: &str = "feedthemonkey-img:///";
fn original_url_to_scheme_uri(url: &str) -> String {
format!("{}{}", SCHEME_PREFIX, percent_encode(url))
}
/// Register the feedthemonkey-img URI scheme handler on the WebView's context.
/// Call this in setup_webview() before load_html().
pub fn register_scheme(ctx: &webkit6::WebContext) {
ctx.register_uri_scheme(SCHEME, |request| {
let uri = request.uri().unwrap_or_default().to_string();
let encoded = uri.strip_prefix(SCHEME_PREFIX).unwrap_or(&uri);
let original_url = percent_decode(encoded);
let path = images_dir().join(url_to_filename(&original_url));
if path.exists() {
serve_file(request.clone(), path);
return;
}
// Not in cache — download in tokio, serve back on the main thread.
let request = request.clone();
let (tx, rx) = tokio::sync::oneshot::channel::<bool>();
let path_dl = path.clone();
crate::runtime::spawn_bg(async move {
let ok = async {
let bytes = reqwest::get(&original_url).await?.bytes().await?;
std::fs::create_dir_all(path_dl.parent().unwrap()).ok();
std::fs::write(&path_dl, &bytes).ok();
Ok::<_, reqwest::Error>(())
}
.await
.is_ok();
let _ = tx.send(ok);
});
// spawn_future_local runs on the GLib main loop so the non-Send
// URISchemeRequest can be safely held across the await point.
glib::spawn_future_local(async move {
if rx.await.unwrap_or(false) && path.exists() {
serve_file(request, path);
} else {
request.finish_error(&mut glib::Error::new(
gio::IOErrorEnum::NotFound,
"Image unavailable",
));
}
});
});
}
fn serve_file(request: webkit6::URISchemeRequest, path: PathBuf) {
match std::fs::read(&path) {
Ok(data) => {
let mime = path.extension()
.and_then(|e| e.to_str())
.map(|ext| match ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"avif" => "image/avif",
_ => "application/octet-stream",
})
.unwrap_or("application/octet-stream");
let stream = gio::MemoryInputStream::from_bytes(&glib::Bytes::from_owned(data));
request.finish(&stream, -1, Some(mime));
}
Err(_) => {
request.finish_error(&mut glib::Error::new(
gio::IOErrorEnum::NotFound,
"Image not found",
));
}
}
}
/// Prefetch all images referenced in the articles into the cache directory.
/// Runs entirely in the background; already-cached files are skipped.
pub async fn prefetch(articles: Vec<Article>) {
let dir = images_dir();
std::fs::create_dir_all(&dir).ok();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_default();
let re = regex::Regex::new(&format!(r#"src="{}([^"]+)""#, regex::escape(SCHEME_PREFIX))).unwrap();
for article in &articles {
for cap in re.captures_iter(&article.content) {
let original_url = percent_decode(&cap[1]);
let path = dir.join(url_to_filename(&original_url));
if !path.exists() {
if let Ok(resp) = client.get(&original_url).send().await {
if let Ok(bytes) = resp.bytes().await {
std::fs::write(&path, &bytes).ok();
}
}
}
}
}
}
/// Rewrite all remote image src attributes to feedthemonkey-img:// URIs.
/// No network requests are made here — images are downloaded lazily by the
/// URI scheme handler the first time the WebView requests them, then cached.
pub fn process(articles: Vec<Article>) -> Vec<Article> {
let re = regex::Regex::new(r#"src="(https?://[^"]+)""#).unwrap();
articles
.into_iter()
.map(|mut article| {
let content = article.content.clone();
let mut rewritten = content.clone();
for cap in re.captures_iter(&content) {
let url = &cap[1];
rewritten = rewritten.replace(
&format!("src=\"{}\"", url),
&format!("src=\"{}\"", original_url_to_scheme_uri(url)),
);
}
article.content = rewritten;
article
})
.collect()
}
/// Remove cached image files no longer referenced by any article.
pub fn cleanup(articles: &[Article]) {
let dir = images_dir();
let Ok(entries) = std::fs::read_dir(&dir) else { return };
let re = regex::Regex::new(
&format!(r#"src="{}([^"]+)""#, regex::escape(SCHEME_PREFIX)),
)
.unwrap();
let mut referenced: HashSet<String> = HashSet::new();
for article in articles {
for cap in re.captures_iter(&article.content) {
referenced.insert(url_to_filename(&percent_decode(&cap[1])));
}
}
for entry in entries.filter_map(|e| e.ok()) {
let fname = entry.file_name().to_string_lossy().to_string();
if !referenced.contains(&fname) {
std::fs::remove_file(entry.path()).ok();
}
}
}

124
src/login_dialog.rs Normal file
View file

@ -0,0 +1,124 @@
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();
// Login button
let obj_weak = self.obj().downgrade();
self.login_button.connect_activated(move |_| {
if let Some(dialog) = obj_weak.upgrade() {
dialog.imp().on_login_clicked();
}
});
// Enter in any row submits the form (connect_entry_activated fires on Return)
for weak in [
self.server_url_row.downgrade(),
self.username_row.downgrade(),
] {
let obj_weak = self.obj().downgrade();
weak.upgrade().unwrap().connect_entry_activated(move |_| {
if let Some(dialog) = obj_weak.upgrade() {
dialog.imp().on_login_clicked();
}
});
}
let obj_weak2 = self.obj().downgrade();
self.password_row.connect_entry_activated(move |_| {
if let Some(dialog) = obj_weak2.upgrade() {
dialog.imp().on_login_clicked();
}
});
}
}
impl LoginDialog {
fn on_login_clicked(&self) {
let raw_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 raw_url.is_empty() || username.is_empty() || password.is_empty() {
return;
}
// Prepend https:// if no scheme given
let server_url = if raw_url.starts_with("http://") || raw_url.starts_with("https://") {
raw_url
} else {
format!("https://{raw_url}")
};
self.obj().close();
self.obj().emit_by_name::<()>("logged-in", &[&server_url, &username, &password]);
}
}
impl WidgetImpl for LoginDialog {}
impl AdwDialogImpl for LoginDialog {}
}

View file

@ -1,7 +1,27 @@
mod api;
mod app;
mod settings;
mod cache;
mod image_cache;
mod pending_actions;
mod filters;
mod preferences_dialog;
mod article_row;
mod credentials;
mod login_dialog;
mod model;
mod runtime;
mod window;
fn main() {
let app = app::App::new();
app.run(&std::env::args().collect::<Vec<_>>());
fn main() -> glib::ExitCode {
// In development builds, point GSettings at the locally compiled schema.
if cfg!(debug_assertions) {
std::env::set_var("GSETTINGS_SCHEMA_DIR", env!("GSETTINGS_SCHEMA_DIR"));
}
// Start the tokio multi-thread runtime before the GTK app so that
// reqwest/hyper can find it when API futures are spawned.
runtime::init();
let app = app::FeedTheMonkeyApp::new();
app.run()
}

76
src/model.rs Normal file
View file

@ -0,0 +1,76 @@
use gtk4::glib;
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use std::cell::RefCell;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
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!(),
}
}
}
}

65
src/pending_actions.rs Normal file
View file

@ -0,0 +1,65 @@
use std::path::PathBuf;
use crate::api::Api;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Read,
Unread,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PendingAction {
pub action: Action,
pub id: String,
}
fn path() -> PathBuf {
glib::user_cache_dir()
.join("net.jeena.FeedTheMonkey")
.join("pending_sync.json")
}
fn load() -> Vec<PendingAction> {
let Ok(data) = std::fs::read_to_string(path()) else { return Vec::new() };
serde_json::from_str(&data).unwrap_or_default()
}
fn save(actions: &[PendingAction]) {
let dir = path();
std::fs::create_dir_all(dir.parent().unwrap()).ok();
if let Ok(s) = serde_json::to_string(actions) {
std::fs::write(path(), s).ok();
}
}
/// Queue an action for an article. If the same article already has a pending
/// action, it is replaced with the new one (last writer wins).
pub fn add(action: Action, id: &str) {
let mut actions = load();
actions.retain(|a| a.id != id);
actions.push(PendingAction { action, id: id.to_string() });
save(&actions);
}
/// Send all queued actions to the server. Successfully synced actions are
/// removed; failed ones remain in the queue for the next attempt.
pub async fn flush(api: &Api, write_token: &str) {
let actions = load();
if actions.is_empty() {
return;
}
let mut remaining = Vec::new();
for pending in actions {
let result = match pending.action {
Action::Read => api.mark_read(write_token, &pending.id).await,
Action::Unread => api.mark_unread(write_token, &pending.id).await,
};
if result.is_err() {
remaining.push(pending);
}
}
save(&remaining);
}

80
src/preferences_dialog.rs Normal file
View file

@ -0,0 +1,80 @@
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use gtk4::{gio, glib};
glib::wrapper! {
pub struct PreferencesDialog(ObjectSubclass<imp::PreferencesDialog>)
@extends libadwaita::Dialog, gtk4::Widget,
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget;
}
impl PreferencesDialog {
pub fn new() -> Self {
glib::Object::builder().build()
}
}
pub mod imp {
use super::*;
use gtk4::CompositeTemplate;
use libadwaita::subclass::prelude::*;
#[derive(CompositeTemplate, Default)]
#[template(resource = "/net/jeena/FeedTheMonkey/ui/preferences_dialog.ui")]
pub struct PreferencesDialog {
#[template_child]
pub cache_images_row: TemplateChild<libadwaita::SwitchRow>,
#[template_child]
pub filters_text_view: TemplateChild<gtk4::TextView>,
}
#[glib::object_subclass]
impl ObjectSubclass for PreferencesDialog {
const NAME: &'static str = "PreferencesDialog";
type Type = super::PreferencesDialog;
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 PreferencesDialog {
fn constructed(&self) {
self.parent_constructed();
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
// Cache images switch
self.cache_images_row.set_active(settings.boolean("cache-images"));
let s = settings.clone();
self.cache_images_row.connect_active_notify(move |row| {
s.set_boolean("cache-images", row.is_active()).ok();
});
// Content filters text view
self.filters_text_view.buffer().set_text(&settings.string("content-filters"));
let obj_weak = self.obj().downgrade();
self.filters_text_view.buffer().connect_changed(move |_| {
if let Some(obj) = obj_weak.upgrade() {
obj.imp().save_filters();
}
});
}
}
impl PreferencesDialog {
fn save_filters(&self) {
let buf = self.filters_text_view.buffer();
let text = buf.text(&buf.start_iter(), &buf.end_iter(), false);
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
settings.set_string("content-filters", &text).ok();
}
}
impl WidgetImpl for PreferencesDialog {}
impl AdwDialogImpl for PreferencesDialog {}
}

50
src/runtime.rs Normal file
View file

@ -0,0 +1,50 @@
use std::sync::OnceLock;
use tokio::runtime::Runtime;
static RT: OnceLock<Runtime> = OnceLock::new();
pub fn init() {
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime")
});
}
/// Spawn `future` on the tokio runtime. When it completes, `callback`
/// is invoked on the GLib main context (GTK main thread).
///
/// Works by routing the result through a tokio oneshot channel; the
/// receiving end is awaited by `glib::spawn_future_local`, which runs
/// on the GLib event loop but does no I/O, so it never needs tokio's
/// reactor itself.
pub fn spawn<F, T, C>(future: F, callback: C)
where
F: std::future::Future<Output = T> + Send + 'static,
T: Send + 'static,
C: FnOnce(T) + 'static,
{
let (tx, rx) = tokio::sync::oneshot::channel::<T>();
RT.get().expect("runtime not initialised").spawn(async move {
let result = future.await;
let _ = tx.send(result);
});
// The receive future only polls a mutex-protected flag — no I/O,
// so running it on the GLib event loop is fine.
glib::spawn_future_local(async move {
if let Ok(value) = rx.await {
callback(value);
}
});
}
/// Fire-and-forget: spawn on the tokio runtime, no callback.
pub fn spawn_bg<F>(future: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
RT.get().expect("runtime not initialised").spawn(future);
}

View file

@ -1,20 +0,0 @@
extern crate confy;
use std::string::String;
use url::Url;
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Settings {
pub session_id: String,
pub server_url: Url,
}
impl ::std::default::Default for Settings {
fn default() -> Self {
Self {
session_id: String::from("abcdefg"),
server_url: Url::parse("https://example.com").expect("Not a URL"),
}
}
}

974
src/window.rs Normal file
View file

@ -0,0 +1,974 @@
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use gtk4::{gio, glib};
use webkit6::prelude::{PolicyDecisionExt, WebViewExt};
glib::wrapper! {
pub struct FeedTheMonkeyWindow(ObjectSubclass<imp::FeedTheMonkeyWindow>)
@extends libadwaita::ApplicationWindow, gtk4::ApplicationWindow, gtk4::Window, gtk4::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk4::Accessible, gtk4::Buildable,
gtk4::ConstraintTarget, gtk4::Native, gtk4::Root, gtk4::ShortcutManager;
}
impl FeedTheMonkeyWindow {
pub fn new(app: &libadwaita::Application) -> Self {
glib::Object::builder()
.property("application", app)
.build()
}
}
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")]
pub struct FeedTheMonkeyWindow {
#[template_child]
pub toast_overlay: TemplateChild<libadwaita::ToastOverlay>,
#[template_child]
pub paned: TemplateChild<gtk4::Paned>,
#[template_child]
pub sidebar_toolbar: TemplateChild<libadwaita::ToolbarView>,
#[template_child]
pub refresh_stack: TemplateChild<gtk4::Stack>,
#[template_child]
pub refresh_button: TemplateChild<gtk4::Button>,
#[template_child]
pub article_menu_button: TemplateChild<gtk4::MenuButton>,
#[template_child]
pub content_refresh_stack: TemplateChild<gtk4::Stack>,
#[template_child]
pub content_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 filter_rules: RefCell<Vec<crate::filters::Rule>>,
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>,
pub pending_restore_id: RefCell<Option<String>>,
pub sidebar_zoom_css: std::cell::OnceCell<gtk4::CssProvider>,
}
#[glib::object_subclass]
impl ObjectSubclass for FeedTheMonkeyWindow {
const NAME: &'static str = "FeedTheMonkeyWindow";
type Type = super::FeedTheMonkeyWindow;
type ParentType = libadwaita::ApplicationWindow;
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.mark-article-unread", Some(glib::VariantTy::STRING), |win, _, param| {
if let Some(id) = param.and_then(|p| p.get::<String>()) {
win.imp().do_mark_article_unread(id);
}
});
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();
}
});
klass.install_action("win.toggle-sidebar", None, |win, _, _| {
win.imp().do_toggle_sidebar();
});
klass.install_action("win.preferences", None, |win, _, _| {
let dialog = crate::preferences_dialog::PreferencesDialog::new();
let win_weak = win.downgrade();
dialog.connect_closed(move |_| {
if let Some(win) = win_weak.upgrade() {
let imp = win.imp();
*imp.filter_rules.borrow_mut() = crate::filters::load_rules();
imp.reload_current_article();
}
});
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
});
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for FeedTheMonkeyWindow {
fn constructed(&self) {
self.parent_constructed();
self.setup_window_state();
self.setup_list();
self.setup_webview();
self.setup_sidebar_toggle();
self.setup_capture_keys();
self.restore_from_cache();
self.auto_login();
self.web_view.grab_focus();
}
}
impl FeedTheMonkeyWindow {
// ── Window state ─────────────────────────────────────────────────────
fn setup_window_state(&self) {
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
let window = self.obj();
window.set_title(Some("FeedTheMonkey"));
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();
}
self.paned.set_position(settings.int("sidebar-width"));
let zoom = settings.double("zoom-level");
self.web_view.set_zoom_level(zoom);
// Set up sidebar font zoom CSS provider
let zoom_css = gtk4::CssProvider::new();
gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().unwrap(),
&zoom_css,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
self.sidebar_zoom_css.set(zoom_css).ok();
self.update_sidebar_zoom(zoom);
// Persist sidebar width while dragging; collapse when dragged too narrow.
let s2 = settings.clone();
let win_weak = self.obj().downgrade();
self.paned.connect_notify_local(Some("position"), move |paned, _| {
let Some(win) = win_weak.upgrade() else { return };
let imp = win.imp();
if !imp.sidebar_toolbar.is_visible() { return; }
let pos = paned.position();
if pos < 150 {
// Defer so we don't change widget visibility mid-drag.
let win_weak2 = win_weak.clone();
glib::idle_add_local_once(move || {
if let Some(win) = win_weak2.upgrade() {
win.imp().set_sidebar_visible(false);
}
});
} else {
s2.set_int("sidebar-width", pos).ok();
}
});
let s = settings.clone();
window.connect_close_request(move |win| {
if !win.is_maximized() {
s.set_int("window-width", win.width()).ok();
s.set_int("window-height", win.height()).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()));
selection.set_autoselect(false);
selection.set_can_unselect(true);
*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 the previous article as read — both on the server and in the
// sidebar — now that the user has navigated away from it.
if !*self.mark_unread_guard.borrow() {
if let Some(prev_id) = self.current_article_id.borrow().clone() {
if prev_id != obj.article().id {
self.mark_read_in_list(&prev_id);
self.bg_mark_read(prev_id);
}
}
}
*self.mark_unread_guard.borrow_mut() = false;
let article = obj.article().clone();
let same_article = self.current_article_id.borrow().as_deref() == Some(&*article.id);
*self.current_article_id.borrow_mut() = Some(article.id.clone());
self.article_menu_button.set_visible(true);
// Skip WebView reload when re-selecting the same article (e.g. after
// a server refresh) so the user's scroll position is preserved.
if !same_article {
self.load_article_in_webview(&article);
}
}
/// Mark an article as read in the sidebar list (UI only).
fn mark_read_in_list(&self, article_id: &str) {
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 == article_id {
obj.set_unread(false);
break;
}
}
}
}
}
fn reload_current_article(&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 {
self.load_article_in_webview(&obj.article().clone());
break;
}
}
}
}
}
fn load_article_in_webview(&self, article: &crate::model::Article) {
let rules = self.filter_rules.borrow();
let content = crate::filters::apply(&rules, &article.id, &article.link, &article.content);
let json = serde_json::json!({
"id": article.id,
"title": article.title,
"feed_title": article.feed_title,
"link": article.link,
"updated": article.published,
"content": 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;
if let Some(ctx) = wv.web_context() {
crate::image_cache::register_scheme(&ctx);
}
// Load content.html from GResource, inlining the CSS so WebKit
// doesn't need to fetch it over a custom scheme.
let load = |path: &str| {
String::from_utf8(
gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE)
.unwrap()
.to_vec(),
)
.unwrap()
};
let css = load("/net/jeena/FeedTheMonkey/html/content.css");
let html = load("/net/jeena/FeedTheMonkey/html/content.html")
.replace("/*INJECT_CSS*/", &css);
wv.load_html(&html, Some("feedthemonkey://localhost/"));
// Apply Adwaita color scheme: set data-dark on <html> so CSS
// custom properties (--bg, --fg, etc.) resolve to the right values.
let style_manager = libadwaita::StyleManager::default();
let wv_weak = wv.downgrade();
let apply_scheme = move |sm: &libadwaita::StyleManager| {
let is_dark = sm.is_dark();
let js = format!("setDark({})", if is_dark { "true" } else { "false" });
if let Some(wv) = wv_weak.upgrade() {
wv.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
}
};
// Apply now (after load-changed fires, see below) and on every change
let sm_clone = style_manager.clone();
let wv_weak2 = wv.downgrade();
let win_weak = self.obj().downgrade();
wv.connect_load_changed(move |_, event| {
if event == webkit6::LoadEvent::Finished {
apply_scheme(&sm_clone);
// Restore cached article now that window.setArticle() exists.
if let Some(win) = win_weak.upgrade() {
let imp = win.imp();
if let Some(id) = imp.pending_restore_id.borrow_mut().take() {
if let Some(store) = imp.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 article = obj.article().clone();
*imp.current_article_id.borrow_mut() = Some(article.id.clone());
imp.article_menu_button.set_visible(true);
imp.load_article_in_webview(&article);
imp.content_stack.set_visible_child_name("webview");
break;
}
}
}
}
}
}
}
});
let wv_weak3 = wv.downgrade();
style_manager.connect_notify_local(Some("dark"), move |sm, _| {
let is_dark = sm.is_dark();
let js = format!("setDark({})", if is_dark { "true" } else { "false" });
if let Some(wv) = wv_weak3.upgrade() {
wv.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
}
});
let _ = wv_weak2; // suppress unused warning
// Disable the default WebKit context menu (right-click) — the
// reload/inspect items don't make sense in an embedded reader.
wv.connect_context_menu(|_, _, _| true);
// 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
}
// Handle in-page keyboard navigation commands (not user-gesture links)
if uri.starts_with("feedthemonkey:") {
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(),
_ => {}
}
}
return true;
}
// Only open external URLs in the browser when the user explicitly
// clicked a link (NavigationType::LinkClicked). Everything else —
// iframe/embed loads, programmatic navigation — stays in the WebView.
let is_link_click = nav.navigation_action()
.map(|a| a.navigation_type() == webkit6::NavigationType::LinkClicked)
.unwrap_or(false);
if is_link_click {
nav.ignore();
open_uri(&uri);
true
} else {
false // let iframes/embeds load normally
}
});
}
// ── Sidebar toggle ────────────────────────────────────────────────────
fn setup_sidebar_toggle(&self) {}
fn do_toggle_sidebar(&self) {
self.set_sidebar_visible(!self.sidebar_toolbar.is_visible());
}
fn set_sidebar_visible(&self, visible: bool) {
self.sidebar_toolbar.set_visible(visible);
// Mirror refresh stack state and primary menu in the content header
// when the sidebar is hidden, so those controls remain accessible.
self.content_refresh_stack.set_visible(!visible);
self.content_menu_button.set_visible(!visible);
if visible {
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
let saved = settings.int("sidebar-width");
self.paned.set_position(if saved > 0 { saved } else { 280 });
}
}
// ── Capture-phase key handler ─────────────────────────────────────────
// ShortcutScope::Global doesn't reliably intercept keys when a ListView
// has focus (the list view consumes them first). A Capture-phase
// EventControllerKey on the window fires before any widget sees the event.
fn setup_capture_keys(&self) {
let controller = gtk4::EventControllerKey::new();
controller.set_propagation_phase(gtk4::PropagationPhase::Capture);
let win_weak = self.obj().downgrade();
controller.connect_key_pressed(move |_, key, _, mods| {
use gtk4::gdk::Key;
if !mods.is_empty() {
return glib::Propagation::Proceed;
}
let Some(win) = win_weak.upgrade() else {
return glib::Propagation::Proceed;
};
// Don't steal keys from text inputs (login dialog, etc.)
let focused: Option<gtk4::Widget> = gtk4::prelude::GtkWindowExt::focus(&win);
if focused.is_some_and(|w| {
w.is::<gtk4::Text>() || w.is::<gtk4::Entry>() || w.is::<gtk4::SearchEntry>()
}) {
return glib::Propagation::Proceed;
}
match key {
Key::Left | Key::k => {
win.imp().navigate_by(-1);
glib::Propagation::Stop
}
Key::Right | Key::j => {
win.imp().navigate_by(1);
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
}
});
self.obj().add_controller(controller);
}
// ── Cache ─────────────────────────────────────────────────────────────
fn restore_from_cache(&self) {
let Some(cache) = crate::cache::load() else { return };
if cache.articles.is_empty() { return; }
let store = self.article_store.borrow();
let store = store.as_ref().unwrap();
for a in &cache.articles {
store.append(&ArticleObject::new(a.clone()));
}
// Find and scroll to the previously selected item immediately so
// the list looks right, but defer loading the article into the
// WebView until the base HTML has finished loading (see setup_webview).
let mut select_idx = 0u32;
if !cache.selected_id.is_empty() {
for i in 0..store.n_items() {
if store.item(i).and_downcast::<ArticleObject>()
.map(|o| o.article().id == cache.selected_id)
.unwrap_or(false)
{
select_idx = i;
break;
}
}
*self.pending_restore_id.borrow_mut() = Some(cache.selected_id);
}
let sel = self.selection.borrow();
let sel = sel.as_ref().unwrap();
*self.mark_unread_guard.borrow_mut() = true;
sel.set_selected(select_idx);
self.article_list_view.scroll_to(select_idx, gtk4::ListScrollFlags::SELECT, None);
self.sidebar_content.set_visible_child_name("list");
}
fn save_cache(&self) {
let store = self.article_store.borrow();
let Some(store) = store.as_ref() else { return };
let articles: Vec<crate::model::Article> = (0..store.n_items())
.filter_map(|i| store.item(i).and_downcast::<ArticleObject>())
.map(|o| o.article().clone())
.collect();
let selected_id = self.current_article_id.borrow().clone().unwrap_or_default();
crate::cache::save(&articles, &selected_id);
}
// ── Login ─────────────────────────────────────────────────────────────
fn auto_login(&self) {
if let Some((server_url, username, password)) = credentials::load_credentials() {
self.do_login(server_url, username, password, false);
} 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() {
win.imp().do_login(server_url, username, password, true);
}
None
});
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
}
fn do_login(&self, server_url: String, username: String, password: String, store: bool) {
let is_auto = !store;
let win_weak = self.obj().downgrade();
crate::runtime::spawn(
async move { Api::login(&server_url, &username, &password).await
.map(|api| (api, server_url, username, password)) },
move |result| {
let Some(win) = win_weak.upgrade() else { return };
match result {
Ok((api, server_url, username, password)) => {
if store {
credentials::store_credentials(&server_url, &username, &password);
}
// Fetch write token in background (non-critical)
let api_clone = api.clone();
let win_weak2 = win.downgrade();
crate::runtime::spawn(
async move { api_clone.fetch_write_token().await },
move |wt_result| {
if let Some(win) = win_weak2.upgrade() {
match wt_result {
Ok(wt) => *win.imp().write_token.borrow_mut() = Some(wt),
Err(e) => eprintln!("Write token error: {e}"),
}
}
},
);
*win.imp().api.borrow_mut() = Some(api);
win.imp().fetch_articles();
}
Err(e) => {
let has_cache = win.imp().article_store.borrow()
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
if is_auto && has_cache {
// Offline with cached articles — just show a toast.
let toast = libadwaita::Toast::new("Offline — showing cached articles");
win.imp().toast_overlay.add_toast(toast);
} else {
win.imp().show_login_dialog();
win.imp().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) {
self.fetch_articles();
}
fn fetch_articles(&self) {
let api = self.api.borrow().clone();
let Some(api) = api else { return };
// Reload filter rules on every refresh so edits take effect immediately.
let filter_text = {
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
settings.string("content-filters").to_string()
};
*self.filter_rules.borrow_mut() = crate::filters::parse(&filter_text);
self.refresh_stack.set_visible_child_name("spinner");
self.content_refresh_stack.set_visible_child_name("spinner");
// Only show the loading screen if there's nothing to show yet.
let has_articles = self.article_store.borrow()
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
if !has_articles {
self.sidebar_content.set_visible_child_name("loading");
}
let saved_id = self.current_article_id.borrow().clone();
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
let cache_images = settings.boolean("cache-images")
&& !gio::NetworkMonitor::default().is_network_metered();
let write_token = self.write_token.borrow().clone();
let win_weak = self.obj().downgrade();
crate::runtime::spawn(
async move {
// Flush any read/unread actions that failed to sync earlier.
if let Some(ref wt) = write_token {
crate::pending_actions::flush(&api, wt).await;
}
let articles = api.fetch_unread().await?;
// Apply filters before image-cache processing so that rules
// such as /thumbs/ → / rewrite the original URLs before they
// are percent-encoded into feedthemonkey-img:// scheme URIs.
let rules = crate::filters::parse(&filter_text);
let articles: Vec<_> = articles
.into_iter()
.map(|mut a| {
a.content = crate::filters::apply(&rules, &a.id, &a.link, &a.content);
a
})
.collect();
let articles = if cache_images {
let processed = crate::image_cache::process(articles);
crate::runtime::spawn_bg(crate::image_cache::prefetch(processed.clone()));
processed
} else {
articles
};
Ok::<_, String>(articles)
},
move |result| {
let Some(win) = win_weak.upgrade() else { return };
let imp = win.imp();
match result {
Ok(articles) => {
let store = imp.article_store.borrow();
let store = store.as_ref().unwrap();
let sel = imp.selection.borrow();
let sel = sel.as_ref().unwrap();
store.remove_all();
for a in &articles {
store.append(&ArticleObject::new(a.clone()));
}
crate::image_cache::cleanup(&articles);
let n = store.n_items();
if n == 0 {
imp.sidebar_content.set_visible_child_name("empty");
*imp.current_article_id.borrow_mut() = None;
imp.article_menu_button.set_visible(false);
imp.content_stack.set_visible_child_name("empty");
crate::cache::save(&articles, "");
} else {
// Try to re-select the same article the user was reading.
let found_idx = saved_id.as_ref().and_then(|id| {
(0..n).find(|&i| {
store.item(i).and_downcast::<ArticleObject>()
.map(|o| o.article().id == *id)
.unwrap_or(false)
})
});
if let Some(idx) = found_idx {
// Article still unread — re-select it without
// reloading the WebView (preserves scroll position).
*imp.mark_unread_guard.borrow_mut() = true;
sel.set_selected(idx);
imp.article_list_view.scroll_to(
idx,
gtk4::ListScrollFlags::SELECT,
None,
);
crate::cache::save(&articles,
saved_id.as_deref().unwrap_or(""));
} else if saved_id.is_some() {
// Article was read elsewhere — keep it in the
// WebView so the user can finish reading but
// leave the sidebar list unselected.
crate::cache::save(&articles, "");
} else {
// No previous article (first load) — select the
// first article.
sel.set_selected(0);
imp.article_list_view.scroll_to(
0,
gtk4::ListScrollFlags::SELECT,
None,
);
crate::cache::save(&articles, "");
}
imp.sidebar_content.set_visible_child_name("list");
}
// Always notify the user that the fetch finished.
let msg = if n == 1 {
String::from("1 unread article")
} else {
format!("{n} unread articles")
};
let toast = libadwaita::Toast::new(&msg);
imp.toast_overlay.add_toast(toast);
}
Err(e) => {
// If we already have cached articles, just show a toast.
let has_articles = imp.article_store.borrow()
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
if has_articles {
let toast = libadwaita::Toast::new(&format!("Refresh failed: {e}"));
imp.toast_overlay.add_toast(toast);
} else {
imp.error_status.set_description(Some(&e));
imp.sidebar_content.set_visible_child_name("error");
}
}
}
imp.refresh_stack.set_visible_child_name("button");
imp.content_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) {
crate::runtime::spawn_bg(async move {
if api.mark_read(&wt, &item_id).await.is_err() {
crate::pending_actions::add(
crate::pending_actions::Action::Read,
&item_id,
);
}
});
} else {
// Offline or write token not yet available — queue for later.
crate::pending_actions::add(
crate::pending_actions::Action::Read,
&item_id,
);
}
}
fn do_mark_unread(&self) {
let id = self.current_article_id.borrow().clone();
let Some(id) = id else { return };
self.do_mark_article_unread(id);
}
fn do_mark_article_unread(&self, id: String) {
// 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);
}
}
}
}
// If this is the currently displayed article, guard against it
// being immediately re-marked read when the selection fires.
if self.current_article_id.borrow().as_deref() == Some(&*id) {
*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();
crate::runtime::spawn_bg(async move {
if api.mark_unread(&wt, &id_clone).await.is_err() {
crate::pending_actions::add(
crate::pending_actions::Action::Unread,
&id_clone,
);
}
});
} else {
crate::pending_actions::add(
crate::pending_actions::Action::Unread,
&id,
);
}
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 current == gtk4::INVALID_LIST_POSITION {
// Nothing selected — pick the first or last article.
if delta > 0 { 0 } else { n - 1 }
} else 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 update_sidebar_zoom(&self, level: f64) {
if let Some(css) = self.sidebar_zoom_css.get() {
css.load_from_string(&format!(
".sidebar-content {{ font-size: {level}em; }}"
));
}
}
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);
self.update_sidebar_zoom(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);
self.update_sidebar_zoom(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 {
fn close_request(&self) -> glib::Propagation {
self.save_cache();
// 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, |_| {});
}