Compare commits

..

3 commits

Author SHA1 Message Date
54d57c0daa Add Settings and a main window 2020-08-25 00:01:13 +02:00
01be7e88c3 Create a GTK hello world application
This commit sets everything up to be able to initialize a GTK
application with a window and a button.
2020-08-12 22:26:38 +02:00
88a97bb555 Clean up and initialized everything for v3 written in Rust and GTK
This commit just removes all the Qt and QML code and sets up
everything as preperation for the rewrite in Rust and GTK.

There is no new code yet, just a hello world, but the structure
is there.
2020-08-12 22:10:56 +02:00
43 changed files with 10649 additions and 5709 deletions

6
.gitignore vendored
View file

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

View file

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

45
.vscode/launch.json vendored Normal file
View file

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

2304
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,25 @@
[package]
name = "feedthemonkey"
version = "3.0.0"
edition = "2021"
authors = ["Jeena <hello@jeena.net>"]
edition = "2018"
readme = "README.md"
repository = "https://github.com/jeena/feedthemonkey"
description = "Desktop client for the TinyTinyRSS feed reader"
license = "GPL-3.0-or-later"
[[bin]]
name = "feedthemonkey"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[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"] }
url = { version = "2.1.1", features = ["serde"] }
url_serde = "0.2.0"
confy = "0.3.1"
serde = { version = "1.0", features = ["derive"] }
[build-dependencies]
[dependencies.gtk]
version = "0.9.0"
features = ["v3_16"]
[dependencies.gio]
version = ""
features = ["v2_44"]

133
README.md
View file

@ -1,101 +1,58 @@
# FeedTheMonkey
<img align="right" src="data/icons/net.jeena.FeedTheMonkey.png" width="256" alt="Icon">
<img align=right src="http://jeena.net/feedthemonkey/feedthemonkey-icon.png" width='256' alt='Icon'>
FeedTheMonkey is a desktop client for any server that implements the
[Greader API](https://github.com/theoldreader/api).
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.
It doesn't work as a standalone feed reader — it connects to a server to fetch articles and sync read state.
It is written in Rust and GTK. You need to have an account on a TinyTinyRSS server.
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.
## Installation
## Features
- **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
When logging in, enter:
- **FreshRSS**: `https://example.com/api/greader.php`
- **Miniflux**: `https://example.com`
- Other compatible servers: consult your server's documentation for the Greader API endpoint
## Dependencies
### Runtime
- GTK 4 (`gtk4`)
- libadwaita (`libadwaita`)
- WebKitGTK 6 (`webkitgtk-6.0`)
- GLib / GIO (`glib-2.0`, `gio-2.0`)
On Arch Linux: `sudo pacman -S gtk4 libadwaita webkitgtk-6.0`
### Build
- Rust toolchain (`rustup` / `cargo`)
- `blueprint-compiler` — compiles `.blp` UI files to `.ui`
- `glib-compile-schemas` — compiles GSettings schemas (part of `glib2`)
- `glib-compile-resources` — compiles GResource bundles (part of `glib2`)
On Arch Linux: `sudo pacman -S blueprint-compiler glib2`
## Building
```sh
cargo build --release
```
The binary is at `target/release/feedthemonkey`.
## Installing
```sh
sudo ./install.sh
```
This installs the binary, icon, desktop entry, and GSettings schema to `/usr/local`.
Set `PREFIX` to install elsewhere:
```sh
sudo PREFIX=/usr ./install.sh
```
## Trivia
This is version 3 of FeedTheMonkey, 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
![FeedTheMonkey screenshot](data/screenshot.png)
TBD
## 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 |
The keyboard shortcuts are inspired by other feed readers which are inspired by the text editor vi.
`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
## Trivia
This is version 3 of FeedTheMonkey, you can find version 1 which was written
in PyQt in the v1 branch of this repo and version 2 which was written in C++
and Qt/QML in the v2 branch.
## Screenshot
![Feed the Monkey screenshot](http://jeena.net/feedthemonkey/feedthemonkey-dark.png)
## License
Copyright 20152026 Jeena
This file is part of FeedTheMonkey.
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.
Copyright 2020 Jeena
FeedTheMonkey is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FeedTheMonkey is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FeedTheMonkey. If not, see <http://www.gnu.org/licenses/>.

View file

@ -1,66 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 50 KiB

View file

@ -1,11 +0,0 @@
[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

@ -1,33 +0,0 @@
<?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

@ -1,12 +0,0 @@
<?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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

View file

@ -1,45 +0,0 @@
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"]
}
}

View file

@ -1,67 +0,0 @@
<?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>

View file

@ -1,43 +0,0 @@
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"]
}
}
}
}
}

View file

@ -1,60 +0,0 @@
<?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

@ -1,38 +0,0 @@
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

@ -1,55 +0,0 @@
<?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>

View file

@ -1,112 +0,0 @@
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";
}
}
}
}

View file

@ -1,140 +0,0 @@
<?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>

View file

@ -1,221 +0,0 @@
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";
}
}
}

View file

@ -1,276 +0,0 @@
<?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,89 +1,98 @@
/* 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; }
/*
* 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/>.
*/
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: var(--font-size);
}
body {
background: #eee;
font-family: sans-serif;
word-wrap: break-word;
}
.nightmode {
background: #353535;
color: #ddd;
}
.nightmode::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.nightmode::-webkit-scrollbar-track-piece {
background-color: #111;
}
.nightmode::-webkit-scrollbar-thumb:vertical {
height: 30px;
background-color: #444;
border-radius: 5px;
}
h1 {
font-size: 1.4em;
margin: 0;
padding: 0;
}
.starred:after {
content: "*";
}
header {
padding: 2em;
border-bottom: 1px solid #aaa;
}
.nightmode header {
border-bottom-color: #222;
}
header p {
color: #666;
margin: 0;
padding: 0;
}
.nightmode header p {
color: #888;
}
a {
color: var(--link);
color: inherit;
text-decoration: none;
}
article {
line-height: 1.6;
margin: 2em;
}
article a {
text-decoration: underline;
}
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;
blockquote {
font-style: italic;
}
img {
@ -91,12 +100,8 @@ 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;
@ -104,28 +109,4 @@ 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,61 +1,107 @@
<!DOCTYPE html>
<html>
<head>
<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>
<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);
}
<script>
function setArticle(article) {
window.scrollTo(0, 0);
document.getElementById('date').textContent = '';
document.getElementById('title').textContent = '';
document.getElementById('title').href = '';
document.getElementById('feed_title').textContent = '';
document.getElementById('author').textContent = '';
document.getElementById('article').innerHTML = '';
if (!article) return;
document.getElementById('date').textContent =
new Date(parseInt(article.updated, 10) * 1000).toLocaleDateString();
document.getElementById('title').textContent = article.title || '';
document.getElementById('title').href = article.link || '';
document.getElementById('feed_title').textContent = article.feed_title || '';
if (article.author && article.author.length > 0)
document.getElementById('author').textContent = '\u2013 ' + article.author;
document.getElementById('article').innerHTML = article.content || '';
$("date").innerHTML = "";
$("title").innerHTML = "";
$("title").href = "";
$("title").title = "";
$("feed_title").innerHTML = "";
$("author").innerHTML = "";
$("article").innerHTML = "";
if(article === "empty") {
$("article").innerHTML = "No unread articles to display.";
} else if(article === "loading") {
$("article").innerHTML = "Loading <blink>&hellip;</blink>";
} else if (article === "logout") {
} else if(article) {
$("date").innerHTML = (new Date(parseInt(article.updated, 10) * 1000));
$("title").innerHTML = article.title;
$("title").href = article.link;
$("title").title = article.link;
$("feed_title").innerHTML = article.feed_title;
$("title").className = article.marked ? "starred" : "";
$("author").innerHTML = "";
if(article.author && article.author.length > 0)
$("author").innerHTML = "&ndash; " + article.author
$("article").innerHTML = article.content;
var as = $("article").getElementsByTagName("a");
for(var i = 0; i < as.length; i++) {
as[i].target = "";
}
}
}
function setDark(isDark) {
document.documentElement.setAttribute('data-dark', isDark ? '1' : '0');
function setFont(font, size) {
document.body.style.fontFamily = font;
document.body.style.fontSize = size + "pt";
}
function setFont(family, sizePx) {
document.documentElement.style.setProperty('--font', family);
document.documentElement.style.setProperty('--font-size', sizePx + 'px');
function setNightmode(nightmode) {
if(nightmode) document.body.className = "nightmode";
else document.body.className = "";
}
function checkKey(e) {
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';
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";
}
}
document.addEventListener('keydown', checkKey);
window.addEventListener("keydown", checkKey);
</script>
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
</head>
<body class=''>
<header>
<p><span id="feed_title"></span> <span id="author"></span></p>
<h1><a id="title" href=""></a></h1>
<p><timedate id="date"></timedate></p>
</header>
<article id="article"></article>
</body>
</html>

View file

@ -1,31 +0,0 @@
#!/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"

BIN
misc/Icon.icns Normal file

Binary file not shown.

View file

@ -0,0 +1,11 @@
[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;

9793
misc/feedthemonkey.xpm Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,268 +0,0 @@
use reqwest::Client;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct Api {
client: Client,
pub server_url: String,
pub auth_token: String,
}
#[derive(Debug, Deserialize)]
struct StreamContents {
items: Vec<Item>,
}
#[derive(Debug, Deserialize)]
struct Item {
id: String,
title: Option<String>,
origin: Option<Origin>,
canonical: Option<Vec<Link>>,
published: Option<i64>,
summary: Option<Summary>,
author: Option<String>,
categories: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct Origin {
title: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Link {
href: String,
}
#[derive(Debug, Deserialize)]
struct Summary {
content: Option<String>,
}
use crate::model::Article;
/// Return the candidate base URLs to try in order.
/// Miniflux serves the Greader API at the server root;
/// FreshRSS serves it at /api/greader.php.
fn candidate_base_urls(server_url: &str) -> Vec<String> {
let base = server_url.trim_end_matches('/');
if base.ends_with("/api/greader.php") {
vec![base.to_string()]
} else {
vec![
base.to_string(),
format!("{base}/api/greader.php"),
]
}
}
impl Api {
pub async fn login(
server_url: &str,
username: &str,
password: &str,
) -> Result<Self, String> {
let client = Client::new();
let candidates = candidate_base_urls(server_url);
let mut last_err = String::new();
for base in candidates {
let url = format!("{base}/accounts/ClientLogin");
let resp = match client
.post(&url)
.form(&[("Email", username), ("Passwd", password)])
.send()
.await
{
Ok(r) => r,
Err(e) => { last_err = e.to_string(); continue; }
};
let status = resp.status();
let body = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
last_err = format!("Login failed ({}): {}", status.as_u16(), human_error(&body, status.as_u16()));
continue;
}
let auth_token = match body.lines().find_map(|l| l.strip_prefix("Auth=")) {
Some(t) => t.to_string(),
None => {
last_err = if looks_like_html(&body) {
format!(
"The server at {base} does not appear to be a \
Greader API endpoint. Check your server URL."
)
} else {
format!("Unexpected response from server: {}", body.trim())
};
continue;
}
};
return Ok(Self { client, server_url: base, auth_token });
}
Err(last_err)
}
pub async fn fetch_write_token(&self) -> Result<String, String> {
let url = format!("{}/reader/api/0/token", self.server_url);
let resp = self
.client
.get(&url)
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch write token: {}", resp.status()));
}
resp.text().await.map_err(|e| e.to_string()).map(|s| s.trim().to_string())
}
pub async fn fetch_unread(&self) -> Result<Vec<Article>, String> {
let url = format!(
"{}/reader/api/0/stream/contents/reading-list\
?xt=user/-/state/com.google/read&n=200&output=json",
self.server_url
);
let resp = self
.client
.get(&url)
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch articles: {}", resp.status()));
}
let stream: StreamContents = resp.json().await.map_err(|e| e.to_string())?;
Ok(stream.items.into_iter().map(|item| {
let unread = !item.categories.as_deref().unwrap_or_default()
.iter()
.any(|c| c == "user/-/state/com.google/read");
let content = item.summary
.as_ref()
.and_then(|s| s.content.clone())
.unwrap_or_default();
let excerpt = plain_text_excerpt(&content, 150);
Article {
id: item.id,
title: item.title.unwrap_or_default(),
feed_title: item.origin.as_ref().and_then(|o| o.title.clone()).unwrap_or_default(),
author: item.author.unwrap_or_default(),
link: item.canonical.as_ref()
.and_then(|v| v.first())
.map(|l| l.href.clone())
.unwrap_or_default(),
published: item.published.unwrap_or(0),
content,
excerpt,
unread,
}
}).collect())
}
pub async fn mark_read(
&self,
write_token: &str,
item_id: &str,
) -> Result<(), String> {
self.edit_tag(write_token, item_id, "a", "user/-/state/com.google/read").await
}
pub async fn mark_unread(
&self,
write_token: &str,
item_id: &str,
) -> Result<(), String> {
self.edit_tag(write_token, item_id, "r", "user/-/state/com.google/read").await
}
async fn edit_tag(
&self,
write_token: &str,
item_id: &str,
action_key: &str,
state: &str,
) -> Result<(), String> {
let url = format!("{}/reader/api/0/edit-tag", self.server_url);
let resp = self
.client
.post(&url)
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
.form(&[("i", item_id), (action_key, state), ("T", write_token)])
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("UNAUTHORIZED".to_string());
}
if !resp.status().is_success() {
return Err(format!("edit-tag failed: {}", resp.status()));
}
Ok(())
}
}
fn looks_like_html(body: &str) -> bool {
let trimmed = body.trim_start();
trimmed.starts_with("<!") || trimmed.to_ascii_lowercase().starts_with("<html")
}
fn human_error(body: &str, status: u16) -> String {
if looks_like_html(body) {
match status {
401 | 403 => "Wrong username or password.".to_string(),
404 => "API endpoint not found. Check your server URL.".to_string(),
_ => format!("Server returned HTTP {status}. Check your server URL."),
}
} else {
let trimmed = body.trim();
if trimmed.is_empty() {
format!("Server returned HTTP {status} with no message.")
} else {
trimmed.to_string()
}
}
}
fn plain_text_excerpt(html: &str, max_chars: usize) -> String {
// Very simple HTML stripper — remove tags, collapse whitespace
let mut out = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
c if !in_tag => out.push(c),
_ => {}
}
}
let collapsed: String = out.split_whitespace().collect::<Vec<_>>().join(" ");
let decoded = decode_html_entities(&collapsed);
if decoded.chars().count() <= max_chars {
decoded
} else {
decoded.chars().take(max_chars).collect::<String>() + ""
}
}
fn decode_html_entities(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
}

View file

@ -1,166 +1,63 @@
use gtk4::prelude::*;
use libadwaita::prelude::*;
extern crate gio;
extern crate gtk;
extern crate confy;
use crate::window::FeedTheMonkeyWindow;
use gio::prelude::*;
use gtk::prelude::*;
use std::string::String;
use crate::settings::Settings;
const APP_ID: &str = "net.jeena.FeedTheMonkey";
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;
#[derive(Debug)]
pub struct App {
uiapp: gtk::Application,
settings: Option<Settings>,
}
impl FeedTheMonkeyApp {
impl App {
pub fn new() -> Self {
glib::Object::builder()
.property("application-id", APP_ID)
.property("flags", gio::ApplicationFlags::empty())
.build()
}
let app = gtk::Application::new(
Some(&APP_ID),
gio::ApplicationFlags::FLAGS_NONE,
)
.expect("Application::new failed");
pub fn run(&self) -> glib::ExitCode {
ApplicationExtManual::run(self)
}
}
app.connect_activate(|app| {
// We create the main window.
let win = gtk::ApplicationWindow::new(app);
mod imp {
use super::*;
use libadwaita::subclass::prelude::*;
// Then we set its size and a title.
win.set_default_size(320, 200);
win.set_title("FeedTheMonkey");
#[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>()));
}
// Don't forget to make all widgets visible.
win.show_all();
});
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);
let conf: Result<Settings, std::io::Error> = confy::load(&APP_ID);
window.present();
match conf {
Ok(s) => Self { uiapp: app, settings: Some(s) },
Err(_) => Self { uiapp: app, settings: None },
}
}
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);
pub fn run(&self, argv: &[String]) {
/*
match self.settings {
Some(_) => self.show_content(),
None => match confy::store(&APP_ID, Settings::default()) { // TODO: Replace default data with login form data
Ok(_) => println!("New settings stored"),
Err(e) => eprint!("Error while storing settings: {}", e)
}
}
*/
self.uiapp.run(&argv);
}
pub fn show_content(&self) {
println!("Show content")
}
}

View file

@ -1,193 +0,0 @@
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")
}
}

View file

@ -1,29 +0,0 @@
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 })
}

View file

@ -1,65 +0,0 @@
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}");
}
}

View file

@ -1,68 +0,0 @@
/// 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
}

View file

@ -1,211 +0,0 @@
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();
}
}
}

View file

@ -1,124 +0,0 @@
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,27 +1,7 @@
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;
mod settings;
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()
fn main() {
let app = app::App::new();
app.run(&std::env::args().collect::<Vec<_>>());
}

View file

@ -1,76 +0,0 @@
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!(),
}
}
}
}

View file

@ -1,65 +0,0 @@
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,80 +0,0 @@
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 {}
}

View file

@ -1,50 +0,0 @@
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);
}

20
src/settings.rs Normal file
View file

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

View file

@ -1,974 +0,0 @@
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, |_| {});
}