Compare commits

...

10 commits

Author SHA1 Message Date
baf1037306 Merge pull request 'Upgrade SQLite' (#12) from chore/update-sqlite into main
All checks were successful
CI / check (push) Successful in 7m47s
CI / test (push) Successful in 5m31s
CI / audit (push) Successful in 19s
Reviewed-on: #12
2026-09-16 09:59:02 +00:00
Austin Schaefer
803ade78eb Upgrade SQLite
All checks were successful
CI / check (pull_request) Successful in 6m41s
CI / test (pull_request) Successful in 5m19s
CI / audit (pull_request) Successful in 18s
2026-09-16 10:46:05 +02:00
bc07a8808f Merge pull request 'Extract vendored dioxus-primitives components into their own crate' (#10) from worktree-extract-dx-components into main
All checks were successful
CI / check (push) Successful in 4m59s
CI / test (push) Successful in 3m23s
CI / audit (push) Successful in 13s
Reviewed-on: #10
Reviewed-by: Austin Schaefer <austin.schaefer@mailo.eu>
2026-09-15 10:23:28 +00:00
Austin Schaefer
3dcf404b26 Extract vendored dioxus-primitives components into their own crate
All checks were successful
CI / check (pull_request) Successful in 15m54s
CI / test (pull_request) Successful in 10m5s
CI / audit (pull_request) Successful in 27s
crates/web/src/components/ was a byte-for-byte copy of the
DioxusLabs/components "preview" registry (a shadcn-style copy-into-
your-project source, not something published as a dependency). Move it
wholesale into a new feedsignal-dioxus-components crate so it reads as
vendored code we depend on rather than application source, and future
app-specific styling should layer on via the class/attributes props
each component already accepts instead of editing the vendored files.

No component logic changed — this is a pure move plus import-path
updates in the four app files that consumed it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 11:31:27 +02:00
b50b7e61cb Merge pull request 'Extract pure logic out of RSX components into unit-testable handlers modules' (#9) from worktree-extract-rsx-logic into main
All checks were successful
CI / check (push) Successful in 12m3s
CI / test (push) Successful in 10m43s
CI / audit (push) Successful in 48s
Reviewed-on: #9
2026-09-15 09:11:50 +00:00
Austin Schaefer
49ce3eaad8 Bump rustls to 0.23.45 to clear RUSTSEC-2026-0285
All checks were successful
CI / check (pull_request) Successful in 10m18s
CI / test (pull_request) Successful in 6m44s
CI / audit (pull_request) Successful in 25s
cargo audit was failing on main independent of this branch's changes: a
new advisory (published 2026-09-14) flags rustls 0.23.43, pulled in
transitively via dioxus-server/reqwest/rig-core, for accepting TLS 1.3
handshake messages across encryption level boundaries. The fix is
upstream in 0.23.45; no code changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 10:11:00 +02:00
Austin Schaefer
bd086b0288 Extract subscribe_form's submit-result mapping into a testable outcome
Some checks failed
CI / check (pull_request) Successful in 1m47s
CI / test (pull_request) Successful in 3m30s
CI / audit (pull_request) Failing after 14s
The submit closure mixed pure decision logic (what should the form show
after subscribe_feed resolves) with Dioxus signal orchestration. Splits
out resolve_submit_outcome(Result<usize, String>) -> SubmitOutcome, so
the success/failure mapping is unit-tested without a Dioxus runtime; the
component now just applies the resulting outcome to its signals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:50:06 +02:00
Austin Schaefer
2ae3293c9d Fix cargo fmt formatting in subscribe_form handlers tests
Some checks failed
CI / check (pull_request) Successful in 1m52s
CI / test (pull_request) Successful in 3m29s
CI / audit (pull_request) Failing after 13s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:42:08 +02:00
Austin Schaefer
de6256a812 Extract pure logic out of RSX components into unit-testable handlers modules
Some checks failed
CI / check (pull_request) Failing after 21s
CI / test (pull_request) Has been skipped
CI / audit (pull_request) Has been skipped
Splits article_row.rs and subscribe_form.rs into mod.rs + component.rs +
handlers.rs (mirroring the components/*/ convention), and adds a sibling
app/handlers.rs for App's heading computation. RSX now calls into plain
functions (score_percent, normalize_feed_url, subscribed_message,
feed_heading) that are covered by unit tests, instead of computing the
same logic inline where it can't be tested without a Dioxus runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:26:00 +02:00
de6b4578d4 Merge pull request 'Add feed sidebar navigation' (#8) from worktree-feed-sidebar-nav into main
Some checks failed
CI / check (push) Successful in 17m42s
CI / test (push) Successful in 3m45s
CI / audit (push) Failing after 18s
Reviewed-on: #8
2026-09-15 07:20:40 +00:00
42 changed files with 222 additions and 36 deletions

19
Cargo.lock generated
View file

@ -1603,6 +1603,15 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "feedsignal-dioxus-components"
version = "0.1.0"
dependencies = [
"dioxus",
"dioxus-icons",
"dioxus-primitives",
]
[[package]] [[package]]
name = "feedsignal-feeds" name = "feedsignal-feeds"
version = "0.1.0" version = "0.1.0"
@ -1633,10 +1642,10 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"dioxus", "dioxus",
"dioxus-icons",
"dioxus-primitives", "dioxus-primitives",
"feedsignal-core", "feedsignal-core",
"feedsignal-db", "feedsignal-db",
"feedsignal-dioxus-components",
"feedsignal-feeds", "feedsignal-feeds",
"feedsignal-llm", "feedsignal-llm",
"serde", "serde",
@ -2424,9 +2433,9 @@ dependencies = [
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.30.1" version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
@ -3400,9 +3409,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.43" version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"once_cell", "once_cell",

View file

@ -3,6 +3,7 @@ resolver = "2"
members = [ members = [
"crates/core", "crates/core",
"crates/db", "crates/db",
"crates/dioxus-components",
"crates/feeds", "crates/feeds",
"crates/llm", "crates/llm",
"crates/web", "crates/web",
@ -27,9 +28,10 @@ diesel = { version = "2.3", features = ["sqlite", "chrono"] }
diesel-async = { version = "0.9", features = ["sqlite", "bb8"] } diesel-async = { version = "0.9", features = ["sqlite", "bb8"] }
diesel_migrations = { version = "2.3", features = ["sqlite"] } diesel_migrations = { version = "2.3", features = ["sqlite"] }
bb8 = "0.9" bb8 = "0.9"
libsqlite3-sys = { version = "0.30", features = ["bundled"] } libsqlite3-sys = { version = "0.38.2", features = ["bundled"] }
feedsignal-core = { path = "crates/core" } feedsignal-core = { path = "crates/core" }
feedsignal-db = { path = "crates/db" } feedsignal-db = { path = "crates/db" }
feedsignal-dioxus-components = { path = "crates/dioxus-components" }
feedsignal-feeds = { path = "crates/feeds" } feedsignal-feeds = { path = "crates/feeds" }
feedsignal-llm = { path = "crates/llm" } feedsignal-llm = { path = "crates/llm" }

View file

@ -0,0 +1,10 @@
[package]
name = "feedsignal-dioxus-components"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
dioxus = "0.7"
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
dioxus-icons = { version = "0.1.0", default-features = false }

View file

@ -0,0 +1,9 @@
// Vendored copies of the dioxus-primitives component registry
// (https://github.com/DioxusLabs/components, preview/src/components).
// That registry is a "copy this source into your project" style
// collection, not a published crate — so these files are pulled in
// verbatim rather than depended on directly. Don't edit component
// internals for app-specific needs; layer overrides on via the `class`/
// `attributes` props each component already accepts.
mod components;
pub use components::*;

View file

@ -24,7 +24,7 @@ chrono = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true } serde_json = { workspace = true, optional = true }
tokio-cron-scheduler = { version = "0.13", optional = true } tokio-cron-scheduler = { version = "0.13", optional = true }
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
dioxus-icons = { version = "0.1.0", default-features = false } feedsignal-dioxus-components.workspace = true
[features] [features]
default = ["web"] default = ["web"]

View file

@ -1,12 +1,13 @@
use super::handlers::score_percent;
use crate::api; use crate::api;
use crate::api::ArticleView; use crate::api::ArticleView;
use crate::components::badge::{Badge, BadgeVariant};
use crate::components::button::{Button, ButtonSize, ButtonVariant};
use dioxus::prelude::*; use dioxus::prelude::*;
use feedsignal_dioxus_components::badge::{Badge, BadgeVariant};
use feedsignal_dioxus_components::button::{Button, ButtonSize, ButtonVariant};
#[component] #[component]
pub fn ArticleRow(article: ArticleView) -> Element { pub fn ArticleRow(article: ArticleView) -> Element {
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32); let score_pct = score_percent(article.final_score);
rsx! { rsx! {
li { class: "article-row", li { class: "article-row",
div { class: "article-row-header", div { class: "article-row-header",

View file

@ -0,0 +1,29 @@
/// Converts a fractional relevance score into a whole-number percentage for
/// the row's badge, rounding rather than truncating so e.g. 0.995 shows 100%
/// instead of 99%.
pub fn score_percent(final_score: Option<f32>) -> Option<i32> {
final_score.map(|s| (s * 100.0).round() as i32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_score_yields_no_percent() {
assert_eq!(score_percent(None), None);
}
#[test]
fn score_is_rounded_not_truncated() {
assert_eq!(score_percent(Some(0.995)), Some(100));
assert_eq!(score_percent(Some(0.554)), Some(55));
}
#[test]
fn score_is_scaled_to_a_percentage() {
assert_eq!(score_percent(Some(0.5)), Some(50));
assert_eq!(score_percent(Some(0.0)), Some(0));
assert_eq!(score_percent(Some(1.0)), Some(100));
}
}

View file

@ -0,0 +1,4 @@
mod component;
mod handlers;
pub use component::*;

View file

@ -0,0 +1,48 @@
use crate::api::FeedView;
/// Heading text for the article list: the selected feed's title, "Feed" as a
/// placeholder while that feed's title hasn't loaded yet, or "All articles"
/// when no feed is selected.
pub fn feed_heading(selected_feed_id: Option<&str>, feeds: Option<&[FeedView]>) -> String {
match selected_feed_id {
None => "All articles".to_string(),
Some(id) => feeds
.and_then(|feeds| feeds.iter().find(|f| f.id == id))
.map(|f| f.title.clone())
.unwrap_or_else(|| "Feed".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feed(id: &str, title: &str) -> FeedView {
FeedView {
id: id.to_string(),
title: title.to_string(),
url: format!("https://example.com/{id}.xml"),
}
}
#[test]
fn no_selection_shows_all_articles() {
assert_eq!(feed_heading(None, None), "All articles");
}
#[test]
fn selected_feed_shows_its_title() {
let feeds = vec![feed("1", "Rust Blog"), feed("2", "Hacker News")];
assert_eq!(feed_heading(Some("2"), Some(&feeds)), "Hacker News");
}
/// The feed list may still be loading (or the id may be stale) when a
/// selection is made, so fall back to a placeholder rather than panic
/// or show a blank heading.
#[test]
fn unresolved_selection_falls_back_to_placeholder() {
assert_eq!(feed_heading(Some("missing"), None), "Feed");
let feeds = vec![feed("1", "Rust Blog")];
assert_eq!(feed_heading(Some("missing"), Some(&feeds)), "Feed");
}
}

View file

@ -1,14 +1,15 @@
mod article_row; mod article_row;
mod handlers;
mod subscribe_form; mod subscribe_form;
use crate::api; use crate::api;
use crate::components::scroll_area::ScrollArea; use article_row::ArticleRow;
use crate::components::sidebar::{ use dioxus::prelude::*;
use feedsignal_dioxus_components::scroll_area::ScrollArea;
use feedsignal_dioxus_components::sidebar::{
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader,
SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger,
}; };
use article_row::ArticleRow;
use dioxus::prelude::*;
use subscribe_form::SubscribeForm; use subscribe_form::SubscribeForm;
#[component] #[component]
@ -89,16 +90,10 @@ pub fn App() -> Element {
SidebarTrigger {} SidebarTrigger {}
h2 { h2 {
{ {
let heading = match selected_feed.read().as_ref() { let heading = handlers::feed_heading(
None => "All articles".to_string(), selected_feed.read().as_deref(),
Some(id) => feeds feeds.read().as_ref().and_then(|r| r.as_ref().ok()).map(|v| v.as_slice()),
.read() );
.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|feeds| feeds.iter().find(|f| &f.id == id))
.map(|f| f.title.clone())
.unwrap_or_else(|| "Feed".to_string()),
};
rsx! { "{heading}" } rsx! { "{heading}" }
} }
} }

View file

@ -1,7 +1,8 @@
use super::handlers::{normalize_feed_url, resolve_submit_outcome, SubmitOutcome};
use crate::api; use crate::api;
use crate::components::button::{Button, ButtonVariant};
use crate::components::input::Input;
use dioxus::prelude::*; use dioxus::prelude::*;
use feedsignal_dioxus_components::button::{Button, ButtonVariant};
use feedsignal_dioxus_components::input::Input;
/// Form for manually subscribing to a feed by URL. Fetches the feed /// Form for manually subscribing to a feed by URL. Fetches the feed
/// immediately on submit (rather than waiting for the next scheduled poll) /// immediately on submit (rather than waiting for the next scheduled poll)
@ -13,22 +14,20 @@ pub fn SubscribeForm(on_subscribed: EventHandler<()>) -> Element {
let mut submitting = use_signal(|| false); let mut submitting = use_signal(|| false);
let submit = move |_| { let submit = move |_| {
let feed_url = url.read().clone(); let Some(feed_url) = normalize_feed_url(&url.read()) else {
if feed_url.trim().is_empty() {
return; return;
} };
spawn(async move { spawn(async move {
submitting.set(true); submitting.set(true);
status.set(None); status.set(None);
match api::feeds::subscribe_feed(feed_url).await { let result = api::feeds::subscribe_feed(feed_url).await;
Ok(count) => { match resolve_submit_outcome(result.map_err(|err| err.to_string())) {
status.set(Some(Ok(format!( SubmitOutcome::Success { message } => {
"Subscribed — pulled in {count} article(s)." status.set(Some(Ok(message)));
))));
url.set(String::new()); url.set(String::new());
on_subscribed.call(()); on_subscribed.call(());
} }
Err(err) => status.set(Some(Err(err.to_string()))), SubmitOutcome::Failure { message } => status.set(Some(Err(message))),
} }
submitting.set(false); submitting.set(false);
}); });

View file

@ -0,0 +1,77 @@
/// Trims a feed URL entered by the user, returning `None` when it's blank
/// so the submit handler can bail out before making a request.
pub fn normalize_feed_url(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
/// Success message shown after a feed subscription pulls in new articles.
fn subscribed_message(article_count: usize) -> String {
format!("Subscribed — pulled in {article_count} article(s).")
}
/// What the form should show, and whether the URL input should be cleared,
/// after a subscription attempt resolves.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubmitOutcome {
/// The subscription succeeded: show `message` and clear the input.
Success { message: String },
/// The subscription failed: show `message` and leave the input as-is,
/// so the reader can fix it up and retry.
Failure { message: String },
}
/// Maps a `subscribe_feed` result to what the form should do next.
pub fn resolve_submit_outcome(result: Result<usize, String>) -> SubmitOutcome {
match result {
Ok(count) => SubmitOutcome::Success {
message: subscribed_message(count),
},
Err(message) => SubmitOutcome::Failure { message },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blank_input_normalizes_to_none() {
assert_eq!(normalize_feed_url(""), None);
assert_eq!(normalize_feed_url(" "), None);
}
#[test]
fn surrounding_whitespace_is_trimmed() {
assert_eq!(
normalize_feed_url(" https://example.com/feed.xml "),
Some("https://example.com/feed.xml".to_string())
);
}
#[test]
fn success_reports_the_article_count_and_clears_the_input() {
assert_eq!(
resolve_submit_outcome(Ok(3)),
SubmitOutcome::Success {
message: "Subscribed — pulled in 3 article(s).".to_string()
}
);
}
/// A failed attempt should surface the error but leave the input alone
/// so the reader doesn't have to retype the URL to fix it.
#[test]
fn failure_surfaces_the_error_message() {
assert_eq!(
resolve_submit_outcome(Err("feed unreachable".to_string())),
SubmitOutcome::Failure {
message: "feed unreachable".to_string()
}
);
}
}

View file

@ -0,0 +1,4 @@
mod component;
mod handlers;
pub use component::*;

View file

@ -1,6 +1,5 @@
mod api; mod api;
mod app; mod app;
mod components;
#[cfg(feature = "server")] #[cfg(feature = "server")]
mod server; mod server;