Compare commits

...
Sign in to create a new pull request.

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
59 changed files with 6028 additions and 11655 deletions

4
.gitignore vendored
View file

@ -1,3 +1,3 @@
.DS_Store
FeedTheMonkey.pro.user*
build
target/
data/gschemas.compiled

2161
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

23
Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "feedthemonkey"
version = "3.0.0"
edition = "2021"
[[bin]]
name = "feedthemonkey"
path = "src/main.rs"
[dependencies]
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"] }
[build-dependencies]

View file

@ -1,66 +0,0 @@
requires(contains(QT_CONFIG, accessibility))
qtHaveModule(widgets) {
QT += widgets # QApplication is required to get native styling with QtQuickControls
}
TARGET = feedthemonkey
TEMPLATE = app
QT += qml quick webenginewidgets webengine
CONFIG += c++11
CONFIG += qtquickcompiler
SOURCES += \
src/main.cpp \
src/post.cpp \
src/tinytinyrss.cpp \
src/tinytinyrsslogin.cpp
RESOURCES += \
html/html.qrc \
qml/qml.qrc \
mac {
RC_FILE = misc/Icon.icns
TARGET = FeedTheMonkey
}
unix {
isEmpty(PREFIX) {
PREFIX = /usr/local
}
target.path = $$PREFIX/bin
shortcutfiles.files = misc/feedthemonkey.desktop
shortcutfiles.path = $$PREFIX/share/applications/
data.files += misc/feedthemonkey.xpm
data.path = $$PREFIX/share/pixmaps/
INSTALLS += shortcutfiles
INSTALLS += data
}
INSTALLS += target
# Needed for bringing browser from background to foreground using QDesktopServices: http://bugreports.qt-project.org/browse/QTBUG-8336
TARGET.CAPABILITY += SwEvent
# Additional import path used to resolve QML modules in Qt Creator's code model
QML_IMPORT_PATH =
OTHER_FILES +=
HEADERS += \
src/post.h \
src/tinytinyrss.h \
src/tinytinyrsslogin.h
DISTFILES += \
misc/feedthemonkey.desktop \
misc/feedthemonkey.xpm \
misc/Icon.icns \
README.md \
LICENSE.txt

122
README.md
View file

@ -1,65 +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 C++ with Qt and QML, it also uses Blink to show the contents. You need
to have Qt 5.6 installed to be able to compile and have a 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.
If you run Linux then there is an AppImage on the [Latest release](https://github.com/jeena/FeedTheMonkey/releases/latest) page. You download it, make executable and are able to run, it should work on most of the distributions out there.
## Features
For ArchLinux I package it and it's available on https://aur.archlinux.org/packages/feedthemonkey/
- **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
You can compile and install it everywhere Qt is suported, this means on macOS, Windows
and Linux.
When logging in, enter:
## Keyboard shortcuts
- **FreshRSS**: `https://example.com/api/greader.php`
- **Miniflux**: `https://example.com`
- Other compatible servers: consult your server's documentation for the Greader API endpoint
The keyboard shortcuts are inspired by other feed readers which are inspired by the text editor vi.
## Dependencies
`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
### Runtime
On macOS use `Cmd` instead of `Ctrl`.
- 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 2 of FeedTheMonkey, you can find version 1 which was written in PyQt in the v1 branch
of this repo. My goal is to make this usable on many different targets, for now it is only for
the use on a desktop computer but I'd like to see it on a mobile device too.
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 2015-2017 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,98 +1,89 @@
/*
* 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;
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: var(--font-size);
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;
}
a {
color: inherit;
color: var(--link);
text-decoration: none;
}
article {
line-height: 1.6;
margin: 2em;
}
article a {
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 {
@ -100,8 +91,12 @@ img {
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 {
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;
@ -109,4 +104,28 @@ figure > img:only-child, div > img:only-child, p > img:only-child {
pre {
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 = "";
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)
$("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 = "";
}
}
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);
document.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>
</body>
</html>

View file

@ -1,6 +0,0 @@
<RCC>
<qresource prefix="/html">
<file>content.css</file>
<file>content.html</file>
</qresource>
</RCC>

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

View file

@ -1,7 +0,0 @@
<RCC>
<qresource prefix="/misc">
<file>feedthemonkey.xpm</file>
<file>Icon.icns</file>
<file>feedthemonkey.desktop</file>
</qresource>
</RCC>

View file

@ -1,25 +0,0 @@
# Maintainer: Jeena <hello@jeena.net>
pkgname=feedthemonkey
_name=FeedTheMonkey
pkgver=2.2.0
pkgrel=1
pkgdesc="Desktop client for the TinyTinyRSS reader"
arch=('i686' 'x86_64')
url="http://jabs.nu/feedthemonkey"
license=('GPL3')
depends=('qt5-declarative' 'qt5-quick1' 'qt5-quickcontrols' 'qt5-webengine')
source=("https://github.com/jeena/${_name}/archive/v${pkgver}.tar.gz")
md5sums=('SKIP')
build() {
cd "${_name}-$pkgver"
qmake-qt5 PREFIX=${pkgdir}/usr
make
}
package() {
cd "${_name}-$pkgver"
make install
install -D -m644 COPYING "${pkgdir}/usr/share/licenses/${pkgname}/COPYING"
}

View file

@ -1,51 +0,0 @@
#!/bin/sh
# The macdeployqt app you get while installing Qt is broken for newer Qt
# versions like 5.4 which we use, we will have to replace it.
#
# Download and compile https://github.com/MaximAlien/macdeployqt do not
# use the .dmg which is too old. Move the new macdeployqt so it is in
# $QTDIR/bin/macdeployqt.
#
# Use fixqtlibspath.sh to fix your Qt installation, you need to change the
# path in this script, you don't have to run the Predator part.
#
# Build FeedTheMonkey.app in QtCreator as Release.
if [[ "" == "$QTDIR" ]]; then
QTDIR=~/Qt/5.4/clang_64/
fi
BUILDDIR=$1
APPDIR=$BUILDDIR/FeedTheMonkey.app
CONTENTSDIR=$APPDIR/Contents
ABSPATH=$(cd "$(dirname "$0")"; pwd)
if [[ "" == "$BUILDDIR" ]]; then
echo "Usage: $0 path/to/build/"
exit 1
fi
# libexec
mkdir -p $APPDIR/Contents/libexec
cp $QTDIR/libexec/QtWebProcess $CONTENTSDIR/libexec
cat > $CONTENTSDIR/libexec/qt.conf << EOF
[Paths]
Plugins = ../PlugIns
Qml2Imports = ../Imports/qtquick2
EOF
# lab settings
mkdir -p $CONTENTSDIR/Imports/qtquick2/Qt/labs
cp -R $QTDIR/qml/Qt/labs/settings $CONTENTSDIR/Imports/qtquick2/Qt/labs
cat > $CONTENTSDIR/Resources/qt.conf << EOF
[Paths]
Plugins = PlugIns
Qml2Imports = Imports/qtquick2
EOF
# deploy
$QTDIR/bin/macdeployqt $APPDIR -no-strip -qmldir=$ABSPATH/../../qml -executable=$CONTENTSDIR/libexec/QtWebProcess
open $BUILDDIR

View file

@ -1,116 +0,0 @@
/*
* 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/>.
*/
import QtWebEngine 1.8
import QtQuick 2.0
import QtQuick.Controls 1.3
import QtQuick.Layouts 1.1
import QtQuick.Controls.Styles 1.3
import TTRSS 1.0
Item {
id: content
property Post post
property ApplicationWindow app
property int textFontSize: 14
property bool nightmode
property int scrollJump: 48
property int pageJump: parent.height
Layout.minimumWidth: 400
onTextFontSizeChanged: webView.setDefaults()
onNightmodeChanged: webView.setDefaults()
function scrollDown(jump) {
if(!jump) {
webView.runJavaScript("window.scrollTo(0, document.body.scrollHeight - " + height + ");")
} else {
webView.runJavaScript("window.scrollBy(0, " + jump + ");")
}
}
function scrollUp(jump) {
if(!jump) {
webView.runJavaScript("window.scrollTo(0, 0);")
} else {
webView.runJavaScript("window.scrollBy(0, -" + jump + ");")
}
}
function loggedOut() {
post = null
}
Label { id: fontLabel }
WebEngineView {
id: webView
anchors.fill: parent
url: "../html/content.html"
property Post post: content.post
function setPost() {
if(post) {
webView.runJavaScript("setArticle(" + post.jsonString + ")")
} else {
webView.runJavaScript("setArticle('logout')")
}
}
function setDefaults() {
// font name needs to be enclosed in single quotes
// and this is needed for El Capitain because ".SF NS Text" won't work
var defFont = ", system, -apple-system, '.SFNSDisplay-Regular', 'Helvetica Neue', 'Lucida Grande'";
var font = "'" + fontLabel.font.family + "'" + defFont;
webView.runJavaScript("document.body.style.fontFamily = \"" + font + "\";");
webView.runJavaScript("document.body.style.fontSize = '" + content.textFontSize + "pt';");
webView.runJavaScript("if(typeof setNightmode == \"function\") setNightmode(" + (content.nightmode ? "true" : "false") + ")")
}
onNavigationRequested: {
if (request.url == "feedthemonkey:previous") {
request.action = WebEngineView.IgnoreRequest;
app.showPreviousPost();
} else if (request.url == "feedthemonkey:next") {
request.action = WebEngineView.IgnoreRequest;
app.showNextPost();
} else if (request.url == "feedthemonkey:open") {
request.action = WebEngineView.IgnoreRequest;
Qt.openUrlExternally(post.link)
} else if (request.navigationType !== WebEngineNavigationRequest.LinkClickedNavigation) {
request.action = WebEngineView.AcceptRequest;
} else {
request.action = WebEngineView.IgnoreRequest;
Qt.openUrlExternally(request.url);
}
}
onLoadingChanged: {
if(!loading) {
setPost()
setDefaults()
}
}
onPostChanged: setPost()
Keys.onPressed: app.keyPressed(event)
}
}

View file

@ -1,84 +0,0 @@
/*
* 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/>.
*/
import QtQuick 2.0
import QtQuick.Controls 1.2
Rectangle {
color: "transparent"
anchors.fill: parent
property string serverUrl: serverUrl.text
property string userName: userName.text
property string password: password.text
Column {
anchors.centerIn: parent
width: parent.width / 2
anchors.margins: parent.width / 4
spacing: 10
Text {
text: qsTr("Please specify a server url, a username and a password.")
wrapMode: Text.WordWrap
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 20
font.pointSize: 20
}
TextField {
id: serverUrl
placeholderText: "http://example.com/ttrss/"
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 20
validator: RegExpValidator { regExp: /https?:\/\/.+/ }
onAccepted: login()
}
TextField {
id: userName
placeholderText: qsTr("username")
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 20
onAccepted: login()
}
TextField {
id: password
placeholderText: qsTr("password")
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 20
echoMode: TextInput.Password
onAccepted: login()
}
Button {
id: loginButton
text: "Ok"
anchors.right: parent.right
anchors.margins: 20
onClicked: login()
}
}
}

View file

@ -1,123 +0,0 @@
/*
* 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/>.
*/
import QtQuick 2.0
import QtQuick.Controls 1.3
Item {
property int textFontSize: 14
property int smallfontSize: 11
property bool nightmode
Component.onCompleted: fixFontSize()
onTextFontSizeChanged: fixFontSize()
function fixFontSize() {
smallfontSize = textFontSize * 0.8
}
id: item
height: d.height + t.height + e.height + 2
Item {
anchors.fill: parent
Item {
anchors.fill: parent
anchors.leftMargin: 15
anchors.rightMargin: 15
anchors.topMargin: 10
anchors.bottomMargin: 10
Column {
id: column
width: parent.width
Item {
width: parent.width
height: d.height
Label {
text: feedTitle
font.pointSize: smallfontSize
textFormat: Text.PlainText
color: nightmode ? "#888" : "gray"
wrapMode: Text.WrapAnywhere
renderType: Text.NativeRendering
elide: Text.ElideLeft
anchors.top: parent.top
anchors.left: parent.left
anchors.right: d.left
maximumLineCount: 1
}
Label {
id: d
text: date.toLocaleString(Qt.locale(), Locale.ShortFormat)
font.pointSize: smallfontSize
textFormat: Text.PlainText
color: nightmode ? "#888" : "gray"
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
renderType: Text.NativeRendering
anchors.right: parent.right
anchors.top: parent.top
}
}
Label {
id: t
text: title
color: nightmode ? (read ? "#888" : "#ddd") : (read ? "gray" : "black")
font.pointSize: textFontSize
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
renderType: Text.NativeRendering
width: parent.width
elide: Text.ElideRight
maximumLineCount: 1
}
Label {
id: e
text: excerpt
font.pointSize: smallfontSize
//textFormat: Text.RichText
color: nightmode ? "#888" : "gray"
wrapMode: Text.WrapAnywhere
renderType: Text.NativeRendering
width: parent.width
elide: Text.ElideRight
maximumLineCount: 1
}
}
}
Rectangle {
anchors.top: parent.bottom
width: parent.width
height: 1
color: nightmode ? "#222" : "lightgray"
}
}
MouseArea {
anchors.fill: parent
onClicked: {
parent.parent.parent.currentIndex = index
}
}
}

View file

@ -1,106 +0,0 @@
/*
* 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/>.
*/
import QtQuick 2.0
import TTRSS 1.0
import QtQuick.Controls 1.3
import QtQuick.Layouts 1.1
import QtQuick.Controls.Styles 1.3
ScrollView {
id: item
property Server server
property Content content
property Post previousPost
property int textFontSize: 14
property bool nightmode
style: ScrollViewStyle {
transientScrollBars: true
}
function next() {
if(listView.count > listView.currentIndex) {
listView.currentIndex++;
}
}
function previous() {
if(listView.currentIndex > 0) {
listView.currentIndex--;
}
}
onWidthChanged: {
// Hide sidebar if smaller than 200px wide
if(width < 200) {
width = 0;
}
}
Rectangle {
width: 1
color: app.nightmode ? "#111" : "lightgray"
anchors.right: parent.right
anchors.top: parent.top
height: parent.height
}
ListView {
id: listView
focus: true
anchors.fill: parent
spacing: 1
model: item.server.posts
delegate: Component {
PostListItem {
textFontSize: item.textFontSize
nightmode: app.nightmode
width: listView.width
}
}
highlightFollowsCurrentItem: false
highlight: Component {
Rectangle {
width: listView.currentItem.width -1
height: listView.currentItem.height
color: nightmode ? "#15539e" : "lightblue"
y: listView.currentItem.y
}
}
onCurrentItemChanged: {
if(previousPost) {
if(!previousPost.dontChangeRead) {
previousPost.read = true;
} else {
previousPost.dontChangeRead = false;
}
}
item.content.post = server.posts[currentIndex]
previousPost = item.content.post
}
}
}

View file

@ -1,144 +0,0 @@
/*
* 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/>.
*/
import QtQuick.Controls 1.2
import QtQuick.Window 2.0
import QtQuick 2.0
import TTRSS 1.0
MenuBar {
id: menuBar
property bool loggedIn: false
property ServerLogin serverLogin
property Server server
property Sidebar sidebar
property Content content
property bool visible: true
property var oldVisibility
Menu {
visible: menuBar.visible
title: qsTr("File")
MenuItem {
text: qsTr("Close &Window")
shortcut: "Ctrl+W"
onTriggered: Qt.quit()
}
MenuItem {
text: qsTr("Exit")
shortcut: "Ctrl+Q"
onTriggered: Qt.quit()
}
}
Menu {
visible: menuBar.visible
title: qsTr("Action")
MenuItem {
text: qsTr("Reload")
shortcut: "R"
enabled: loggedIn
onTriggered: server.reload()
}
MenuItem {
text: qsTr("Set &Unread")
shortcut: "U"
enabled: loggedIn
onTriggered: {
content.post.dontChangeRead = true
content.post.read = false
}
}
MenuItem {
text: qsTr("Next")
shortcut: "J"
enabled: loggedIn
onTriggered: sidebar.next()
}
MenuItem {
text: qsTr("Previous")
shortcut: "K"
enabled: loggedIn
onTriggered: sidebar.previous()
}
MenuItem {
text: qsTr("Open in Browser")
shortcut: "N"
enabled: loggedIn
onTriggered: Qt.openUrlExternally(content.post.link)
}
MenuItem {
text: qsTr("Log Out")
enabled: loggedIn
onTriggered: serverLogin.logout()
}
}
Menu {
visible: menuBar.visible
title: qsTr("View")
MenuItem {
text: qsTr("Night mode")
shortcut: "1"
onTriggered: app.toggleNightmode()
}
MenuItem {
text: qsTr("Zoom In")
shortcut: "Ctrl++"
enabled: loggedIn
onTriggered: app.zoomIn()
}
MenuItem {
text: qsTr("Zoom Out")
shortcut: "Ctrl+-"
enabled: loggedIn
onTriggered: app.zoomOut()
}
MenuItem {
text: qsTr("Reset")
shortcut: "Ctrl+0"
enabled: loggedIn
onTriggered: app.zoomReset()
}
MenuItem {
text: qsTr("Fullscreen")
shortcut: "F11"
enabled: loggedIn
onTriggered: {
if(app.visibility == Window.FullScreen) {
app.visibility = oldVisibility
} else {
oldVisibility = app.visibility
app.showFullScreen()
}
}
}
}
Menu {
visible: menuBar.visible
title: qsTr("Help")
MenuItem {
text: qsTr("About")
onTriggered: Qt.openUrlExternally("http://jeena.net/feedthemonkey/index.html");
}
}
}

View file

@ -1,254 +0,0 @@
/*
* 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/>.
*/
import QtQuick 2.3
import QtQuick.Controls 1.3
import QtQuick.Window 2.0
import QtQuick.Layouts 1.1
import QtQuick.Dialogs 1.1
import Qt.labs.settings 1.0
import TTRSS 1.0
ApplicationWindow {
id: app
title: "FeedTheMonkey"
visible: true
color: nightmode ? "#2d2d2d" : "#eee"
minimumWidth: 480
minimumHeight: 320
width: 800
height: 640
x: 200
y: 200
property Server server: server
property Sidebar sidebar: sidebar
property Content content: content
property variant fontSizes: [7,9,11,13,15,17,19,21,23,25,27,29,31]
property int defaultTextFontSizeIndex: 3
property int textFontSizeIndex: defaultTextFontSizeIndex
property int textFontSize: fontSizes[textFontSizeIndex]
property bool nightmode: false
property bool showMenuBar: false
menuBar: TheMenuBar {
id: menu
serverLogin: serverLogin
server: server
sidebar: sidebar
content: content
visible: app.showMenuBar
__contentItem.visible: visible
}
Settings {
id: settings
category: "window"
property alias x: app.x
property alias y: app.y
property alias width: app.width
property alias height: app.height
property alias sidebarWidth: sidebar.width
property alias textFontSizeIndex: app.textFontSizeIndex
property alias nightmode: app.nightmode
}
function loggedIn() {
if(serverLogin.loggedIn()) {
menu.loggedIn = true;
contentView.visible = true
login.visible = false;
server.initialize(serverLogin.serverUrl, serverLogin.sessionId);
} else {
menu.loggedIn = false
contentView.visible = false
login.visible = true
server.loggedOut()
content.loggedOut()
}
}
function toggleNightmode() {
app.nightmode = !app.nightmode
}
function zoomIn() {
if(textFontSizeIndex + 1 < fontSizes.length) {
textFontSize = fontSizes[++textFontSizeIndex]
}
}
function zoomOut() {
if(textFontSizeIndex - 1 > 0) {
textFontSize = fontSizes[--textFontSizeIndex]
}
}
function zoomReset() {
textFontSizeIndex = defaultTextFontSizeIndex
textFontSize = fontSizes[textFontSizeIndex]
}
function removeHTML(str) {
forEscapingHTML.text = str
return forEscapingHTML.getText(0, forEscapingHTML.length)
}
function showNextPost() {
sidebar.next()
}
function showPreviousPost() {
sidebar.previous()
}
function keyPressed(event) {
switch (event.key) {
case Qt.Key_Right:
case Qt.Key_J:
case Qt.Key_j:
sidebar.next()
break
case Qt.Key_Left:
case Qt.Key_K:
case Qt.Key_k:
sidebar.previous()
break
case Qt.Key_1:
toggleNightmode()
break
case Qt.Key_Home:
content.scrollUp()
break
case Qt.Key_End:
content.scrollDown()
break
case Qt.Key_PageUp:
content.scrollUp(content.pageJump)
break
case Qt.Key_PageDown:
case Qt.Key_Space:
content.scrollDown(content.pageJump)
break
case Qt.Key_Down:
content.scrollDown(content.scrollJump)
break
case Qt.Key_Up:
content.scrollUp(content.scrollJump)
break
case Qt.Key_Enter:
case Qt.Key_Return:
Qt.openUrlExternally(content.post.link)
break
default:
break
}
}
SplitView {
id: contentView
anchors.fill: parent
orientation: Qt.Horizontal
visible: serverLogin.loggedIn()
focus: true
handleDelegate: Rectangle {
width: 1
color: app.nightmode ? "#333" : "#aaa"
}
Sidebar {
id: sidebar
content: content
server: server
implicitWidth: 300
textFontSize: app.textFontSize
nightmode: app.nightmode
}
Content {
id: content
app: app
Layout.minimumWidth: 200
implicitWidth: 624
textFontSize: app.textFontSize
nightmode: app.nightmode
}
Keys.onPressed: keyPressed(event)
Keys.onReleased: {
switch (event.key) {
case Qt.Key_Alt:
app.showMenuBar = !app.showMenuBar
break
default:
break
}
}
}
Login {
id: login
anchors.fill: parent
visible: !serverLogin.loggedIn()
function login() {
serverLogin.login(serverUrl, userName, password)
}
}
MessageDialog {
id: loginErrorAlert
title: "A login error occured"
text: serverLogin.loginError
onAccepted: visible = false
}
ServerLogin {
id: serverLogin
onSessionIdChanged: app.loggedIn()
onLoginErrorChanged: {
console.log("loginError:", loginError)
if(loginError.length > 0) {
loginErrorAlert.visible = true
}
}
}
Server {
id: server
}
TextArea {
id: forEscapingHTML
visible: false
textFormat: TextEdit.RichText
}
Component.onCompleted: {
if(serverLogin.loggedIn()) {
loggedIn();
}
}
}

View file

@ -1,10 +0,0 @@
<RCC>
<qresource prefix="/qml">
<file>main.qml</file>
<file>TheMenuBar.qml</file>
<file>Content.qml</file>
<file>Login.qml</file>
<file>PostListItem.qml</file>
<file>Sidebar.qml</file>
</qresource>
</RCC>

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;", " ")
}

166
src/app.rs Normal file
View file

@ -0,0 +1,166 @@
use gtk4::prelude::*;
use libadwaita::prelude::*;
use crate::window::FeedTheMonkeyWindow;
const APP_ID: &str = "net.jeena.FeedTheMonkey";
glib::wrapper! {
pub struct FeedTheMonkeyApp(ObjectSubclass<imp::FeedTheMonkeyApp>)
@extends libadwaita::Application, gtk4::Application, gio::Application,
@implements gio::ActionGroup, gio::ActionMap;
}
impl FeedTheMonkeyApp {
pub fn new() -> Self {
glib::Object::builder()
.property("application-id", APP_ID)
.property("flags", gio::ApplicationFlags::empty())
.build()
}
pub fn run(&self) -> glib::ExitCode {
ApplicationExtManual::run(self)
}
}
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,49 +0,0 @@
/*
* 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/>.
*/
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <qdebug.h>
#include <QMetaType>
#include <QtQml>
#include <QIcon>
#include <QtWebEngine/qtwebengineglobal.h>
#include "tinytinyrsslogin.h"
#include "tinytinyrss.h"
#include "post.h"
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
app.setOrganizationName("Jeena");
app.setOrganizationDomain("jeena.net");
app.setApplicationName("FeedTheMonkey");
QtWebEngine::initialize();
qmlRegisterType<TinyTinyRSSLogin>("TTRSS", 1, 0, "ServerLogin");
qmlRegisterType<TinyTinyRSS>("TTRSS", 1, 0, "Server");
qmlRegisterType<Post>("TTRSS", 1, 0, "Post");
QQmlApplicationEngine engine;
engine.load(QUrl(QStringLiteral("qrc:/qml/main.qml")));
return app.exec();
}

27
src/main.rs Normal file
View file

@ -0,0 +1,27 @@
mod api;
mod app;
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() -> 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);
}

View file

@ -1,79 +0,0 @@
/*
* 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/>.
*/
#include "post.h"
#include <QDebug>
#include <QJsonDocument>
#include <QTextDocument>
Post::Post(QObject *parent) : QObject(parent)
{
}
Post::Post(QJsonObject post, QObject *parent) : QObject(parent)
{
mTitle = html2text(post.value("title").toString().trimmed());
mFeedTitle = html2text(post.value("feed_title").toString().trimmed());
mId = post.value("id").toInt();
mFeedId = post.value("feed_id").toString().trimmed();
mAuthor = post.value("author").toString().trimmed();
QUrl url(post.value("link").toString().trimmed());
mLink = url;
QDateTime timestamp;
timestamp.setTime_t(post.value("updated").toInt());
mDate = timestamp;
mContent = post.value("content").toString().trimmed();
mExcerpt = html2text(post.value("excerpt").toString().remove(QRegExp("<[^>]*>")).replace("&hellip;", " ...").trimmed().replace("(\\s+)", " ").replace("\n", ""));
mStarred = post.value("marked").toBool();
mRead = !post.value("unread").toBool();
mDontChangeRead = false;
QJsonDocument doc(post);
QString result(doc.toJson(QJsonDocument::Indented));
mJsonString = result;
}
Post::~Post()
{
}
void Post::setRead(bool r)
{
if(mRead == r) return;
mRead = r;
emit readChanged(mRead);
}
void Post::setDontChangeRead(bool r)
{
if(mDontChangeRead == r) return;
mDontChangeRead = r;
emit dontChangeReadChanged(mDontChangeRead);
}
QString Post::html2text(const QString htmlString)
{
QTextDocument doc;
doc.setHtml(htmlString);
return doc.toPlainText();
}

View file

@ -1,89 +0,0 @@
/*
* 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/>.
*/
#ifndef POST_H
#define POST_H
#include <QObject>
#include <QUrl>
#include <QDate>
#include <QJsonObject>
class Post : public QObject
{
Q_OBJECT
Q_PROPERTY(QString title READ title CONSTANT)
Q_PROPERTY(QString feedTitle READ feedTitle CONSTANT)
Q_PROPERTY(int id READ id CONSTANT)
Q_PROPERTY(QString feedId READ feedId CONSTANT)
Q_PROPERTY(QString author READ author CONSTANT)
Q_PROPERTY(QUrl link READ link CONSTANT)
Q_PROPERTY(QDateTime date READ date CONSTANT)
Q_PROPERTY(QString content READ content CONSTANT)
Q_PROPERTY(QString excerpt READ excerpt CONSTANT)
Q_PROPERTY(bool starred READ starred NOTIFY starredChanged)
Q_PROPERTY(bool read READ read WRITE setRead NOTIFY readChanged)
Q_PROPERTY(bool dontChangeRead READ dontChangeRead WRITE setDontChangeRead NOTIFY dontChangeReadChanged)
Q_PROPERTY(QString jsonString READ jsonString CONSTANT)
public:
Post(QObject *parent = 0);
Post(QJsonObject post, QObject *parent = 0);
~Post();
QString title() const { return mTitle; }
QString feedTitle() const { return mFeedTitle; }
int id() const { return mId; }
QString feedId() const { return mFeedId; }
QString author() const { return mAuthor; }
QUrl link() const { return mLink; }
QDateTime date() const { return mDate; }
QString content() const { return mContent; }
QString excerpt() const { return mExcerpt; }
bool starred() const { return mStarred; }
bool read() { return mRead; }
void setRead(bool r);
bool dontChangeRead() const { return mDontChangeRead; }
void setDontChangeRead(bool r);
QString jsonString() const { return mJsonString; }
signals:
void starredChanged(bool);
void readChanged(bool);
void dontChangeReadChanged(bool);
public slots:
private:
QString mTitle;
QString mFeedTitle;
int mId;
QString mFeedId;
QString mAuthor;
QUrl mLink;
QDateTime mDate;
QString mContent;
QString mExcerpt;
bool mStarred;
bool mRead;
bool mDontChangeRead;
QString mJsonString;
QString html2text(const QString htmlString);
};
#endif // POST_H

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,150 +0,0 @@
/*
* 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/>.
*/
#include "tinytinyrss.h"
#include <QJsonDocument>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QJsonArray>
TinyTinyRSS::TinyTinyRSS(QObject *parent) :
QObject(parent)
{
qRegisterMetaType<QList<Post *> >();
mNetworkManager = new QNetworkAccessManager(this);
mPosts = QList<Post *>();
}
TinyTinyRSS::~TinyTinyRSS()
{
mPosts.clear();
delete mNetworkManager;
}
void TinyTinyRSS::initialize(const QString serverUrl, const QString sessionId)
{
mServerUrl = serverUrl;
mSessionId = sessionId;
reload();
}
void TinyTinyRSS::reload()
{
QVariantMap opts;
opts.insert("show_excerpt", false);
opts.insert("view_mode", "unread");
opts.insert("show_content", true);
opts.insert("feed_id", -4);
opts.insert("skip", 0);
doOperation("getHeadlines", opts, [this] (const QJsonObject &json) {
mPosts.clear();
QJsonArray posts = json.value("content").toArray();
for(int i = 0; i < posts.count(); i++)
{
QJsonObject postJson = posts.at(i).toObject();
Post *post = new Post(postJson, this);
connect(post, SIGNAL(readChanged(bool)), this, SLOT(onPostReadChanged(bool)));
mPosts.append(post);
}
emit postsChanged(mPosts);
});
}
void TinyTinyRSS::loggedOut()
{
mServerUrl = nullptr;
mSessionId = nullptr;
mPosts.clear();
emit postsChanged(mPosts);
}
void TinyTinyRSS::doOperation(QString operation, QVariantMap opts, std::function<void (const QJsonObject &json)> callback)
{
QVariantMap options;
options.insert("sid", mSessionId);
options.insert("op", operation);
QMapIterator<QString, QVariant> i(opts);
while (i.hasNext()) {
i.next();
options.insert(i.key(), i.value());
}
QJsonObject jsonobj = QJsonObject::fromVariantMap(options);
QJsonDocument json = QJsonDocument(jsonobj);
QNetworkRequest request(mServerUrl);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = mNetworkManager->post(request, json.toJson());
connect(reply, &QNetworkReply::finished, [callback, reply] () {
if (reply) {
if (reply->error() == QNetworkReply::NoError) {
QString jsonString = QString(reply->readAll());
QJsonDocument json = QJsonDocument::fromJson(jsonString.toUtf8());
callback(json.object());
} else {
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
//do some error management
qWarning() << "HTTP error: " << httpStatus;
}
reply->deleteLater();
}
});
}
void TinyTinyRSS::onPostReadChanged(bool r)
{
Post *post = (Post *)sender();
updateArticle(post->id(), 2, !r, [post] (const QJsonObject &) {
// not doing anything with this yet.
});
}
void TinyTinyRSS::updateArticle(int articleId, int field, bool trueFalse, std::function<void (const QJsonObject &json)> callback)
{
QVariantMap opts;
opts.insert("article_ids", articleId);
opts.insert("field", field);
opts.insert("mode", trueFalse ? 1 : 0);
doOperation("updateArticle", opts, callback);
}
QQmlListProperty<Post> TinyTinyRSS::posts()
{
return QQmlListProperty<Post>(this, mPosts);
}
int TinyTinyRSS::postsCount() const
{
return mPosts.count();
}
Post *TinyTinyRSS::post(int index) const
{
return mPosts.at(index);
}

View file

@ -1,68 +0,0 @@
/*
* 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/>.
*/
#ifndef TINYTINYRSS_H
#define TINYTINYRSS_H
#include <QObject>
#include <QMap>
#include <QNetworkReply>
#include <QList>
#include <QQmlListProperty>
#include <QJsonObject>
#include <functional>
#include "post.h"
class TinyTinyRSS : public QObject
{
Q_OBJECT
Q_PROPERTY(QQmlListProperty<Post> posts READ posts NOTIFY postsChanged)
public:
TinyTinyRSS(QObject *parent = 0);
~TinyTinyRSS();
Q_INVOKABLE void initialize(const QString serverUrl, const QString sessionId);
Q_INVOKABLE void reload();
Q_INVOKABLE void loggedOut();
QQmlListProperty<Post> posts();
int postsCount() const;
Post *post(int) const;
signals:
void postsChanged(QList<Post *>);
private slots:
void onPostReadChanged(bool);
private:
void doOperation(QString operation, QVariantMap opts, std::function<void (const QJsonObject &json)> callback);
void updateArticle(int articleId, int field, bool trueFalse, std::function<void (const QJsonObject &json)> callback);
QString mServerUrl;
QString mSessionId;
QList<Post*> mPosts;
QNetworkAccessManager *mNetworkManager;
};
#endif // TINYTINYRSS_H

View file

@ -1,135 +0,0 @@
/*
* 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/>.
*/
#include "tinytinyrsslogin.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QSettings>
#define APP_URL "net.jeena"
#define APP_NAME "FeedTheMonkey"
TinyTinyRSSLogin::TinyTinyRSSLogin(QObject *parent) :
QObject(parent)
{
mNetworkManager = new QNetworkAccessManager(this);
QSettings settings;
mSessionId = settings.value("sessionId").toString();
mServerUrl = settings.value("serverUrl").toString();
}
TinyTinyRSSLogin::~TinyTinyRSSLogin()
{
delete mNetworkManager;
}
bool TinyTinyRSSLogin::loggedIn()
{
return !mSessionId.isEmpty();
}
void TinyTinyRSSLogin::login(const QString serverUrl, const QString user, const QString password)
{
mServerUrl = QUrl(serverUrl + "/api/");
QVariantMap options;
options.insert("op", "login");
options.insert("user", user);
options.insert("password", password);
QJsonObject jsonobj = QJsonObject::fromVariantMap(options);
QJsonDocument json = QJsonDocument(jsonobj);
QNetworkRequest request(mServerUrl);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = mNetworkManager->post(request, json.toJson());
connect(reply, SIGNAL(finished()), this, SLOT(reply()));
}
void TinyTinyRSSLogin::logout()
{
if(mSessionId.length() > 0 && mServerUrl.toString().length() > 0) {
QVariantMap options;
options.insert("op", "logout");
options.insert("sid", mSessionId);
QJsonObject jsonobj = QJsonObject::fromVariantMap(options);
QJsonDocument json = QJsonDocument(jsonobj);
QNetworkRequest request(mServerUrl);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = mNetworkManager->post(request, json.toJson());
connect(reply, SIGNAL(finished()), this, SLOT(reply()));
}
}
void TinyTinyRSSLogin::reply()
{
QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
if (reply) {
if (reply->error() == QNetworkReply::NoError) {
QString jsonString = QString(reply->readAll());
QJsonDocument json = QJsonDocument::fromJson(jsonString.toUtf8());
if(json.object().value("content").toObject().value("error").toString().length() > 0) {
mLoginError = json.object().value("content").toObject().value("error").toString();
qWarning() << mLoginError;
emit loginErrorChanged(mLoginError);
if(mLoginError == "NOT_LOGGED_IN") {
mSessionId = nullptr;
mServerUrl = nullptr;
QSettings settings;
settings.remove("sessionId");
settings.remove("serverUrl");
settings.sync();
emit sessionIdChanged(mSessionId);
}
} else {
mSessionId = json.object().value("content").toObject().value("session_id").toString();
emit sessionIdChanged(mSessionId);
QSettings settings;
settings.setValue("sessionId", mSessionId);
settings.setValue("serverUrl", mServerUrl);
settings.sync();
}
} else {
mLoginError = "HTTP error: "
+ reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toString()
+ " :: "
+ reply->errorString();
qWarning() << mLoginError;
emit loginErrorChanged(mLoginError);
}
reply->deleteLater();
}
}

View file

@ -1,60 +0,0 @@
/*
* 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/>.
*/
#ifndef TINYTINYRSSLOGIN_H
#define TINYTINYRSSLOGIN_H
#include <QObject>
#include <QMetaType>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
class TinyTinyRSSLogin : public QObject
{
Q_OBJECT
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionIdChanged)
Q_PROPERTY(QUrl serverUrl READ serverUrl)
Q_PROPERTY(QString loginError READ loginError NOTIFY loginErrorChanged)
public:
TinyTinyRSSLogin(QObject *parent = 0);
~TinyTinyRSSLogin();
QString sessionId() const { return mSessionId; }
QUrl serverUrl() const { return mServerUrl; }
QString loginError() const { return mLoginError; }
Q_INVOKABLE bool loggedIn();
Q_INVOKABLE void login(const QString serverUrl, const QString user, const QString password);
Q_INVOKABLE void logout();
signals:
void sessionIdChanged(QString);
void loginErrorChanged(QString);
private slots:
void reply();
private:
QString mSessionId;
QUrl mServerUrl;
QString mLoginError;
QNetworkAccessManager *mNetworkManager;
};
#endif // TINYTINYRSSLOGIN_H

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, |_| {});
}