diff --git a/public/fonts/Inter-VariableFont_opsz,wght.woff2 b/public/fonts/Inter-VariableFont_opsz,wght.woff2 new file mode 100644 index 0000000..b61bb0d Binary files /dev/null and b/public/fonts/Inter-VariableFont_opsz,wght.woff2 differ diff --git a/public/tauri.svg b/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/vite.svg b/public/vite.svg index e7b8dfb..443a1c2 100644 --- a/public/vite.svg +++ b/public/vite.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..83f454c 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..12c8557 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..9381588 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0ca4f27..3e32178 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index b81f820..3f7d131 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 624c7bf..5298277 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c021d2b..4f2ca1c 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 6219700..2f40ec3 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f9bc048..49a567f 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d5fbfb2..84fcedf 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 63440d7..2515913 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index f3f705a..f8db8ca 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 4556388..12f88c3 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns deleted file mode 100644 index 12a5bce..0000000 Binary files a/src-tauri/icons/icon.icns and /dev/null differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 06c23c8..adeb6a7 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..16e8940 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/client.rs b/src-tauri/src/client.rs index ffed60e..5df27f2 100644 --- a/src-tauri/src/client.rs +++ b/src-tauri/src/client.rs @@ -2,7 +2,9 @@ use once_cell::sync::Lazy; pub static CLIENT: Lazy = Lazy::new(|| { reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(20)) + .connect_timeout(std::time::Duration::from_secs(10)) + .user_agent("RuFeed/0.3") .build() .expect("Failed to build client") }); diff --git a/src-tauri/src/commands/feed.rs b/src-tauri/src/commands/feed.rs index b10e635..6abe4d0 100644 --- a/src-tauri/src/commands/feed.rs +++ b/src-tauri/src/commands/feed.rs @@ -1,15 +1,40 @@ - use cached::proc_macro::cached; +use reqwest::header::{ACCEPT, ACCEPT_ENCODING}; use crate::client::CLIENT; use crate::config::feed_config::Feed; use crate::parser::{extract_feed_info, FeedEntry, FeedItem}; use crate::Error; +async fn fetch_text(url: &str, accept: &str) -> Result { + let response = CLIENT + .get(url) + .header(ACCEPT, accept) + .header(ACCEPT_ENCODING, "identity") + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + return Err(Error::HttpStatus { + url: url.to_string(), + status: status.as_u16(), + }); + } + + response.text().await.map_err(|error| { + if error.is_decode() { + Error::InvalidResponseBody + } else { + Error::HttpRequest(error) + } + }) +} + // ======================= Feed Config Command ======================= #[tauri::command] pub async fn add_feed(url: &str) -> Result { - let html = CLIENT.get(url).send().await?.text().await?; + let html = fetch_text(url, "text/html,*/*;q=0.8").await?; let feeds = extract_feed_info(&html, url); @@ -40,11 +65,14 @@ pub async fn remove_feed(website_url: &str) -> Result { // ======================================================= #[tauri::command] -#[cached(size=100, time=300, result=true)] +#[cached(size = 100, time = 300, result = true)] pub async fn get_feed_item(website_url: String) -> Result, Error> { - if let Ok(feed) = Feed::get(&website_url) { - let xml = CLIENT.get(&feed.feed_url).send().await?.text().await?; + let xml = fetch_text( + &feed.feed_url, + "application/rss+xml,application/atom+xml,application/xml,text/xml,*/*;q=0.8", + ) + .await?; FeedItem::from_feed(&xml) } else { return Err(Error::MissingField("no feed found in the settings.".into())); @@ -52,10 +80,15 @@ pub async fn get_feed_item(website_url: String) -> Result, Error> } #[tauri::command] -#[cached(size=100, time=300, result=true)] +#[cached(size = 100, time = 300, result = true)] pub async fn get_entry(website_url: String, target_url: String) -> Result { if let Ok(feed) = Feed::get(&website_url) { - let xml = CLIENT.get(&feed.feed_url).send().await?.text().await?; + let xml = fetch_text( + &feed.feed_url, + "application/rss+xml,application/atom+xml,application/xml,text/xml,*/*;q=0.8", + ) + .await?; + FeedEntry::from_feed(&xml, &target_url) } else { return Err(Error::MissingField("no feed found in the settings.".into())); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 001ea2e..55ba267 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,6 +1,21 @@ -use crate::Error; -use serde::{Deserialize, Serialize}; use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tauri::Manager; +use crate::Error; + + +fn get_config_path(app: &tauri::AppHandle) -> Result { + let config_dir = app.path().app_config_dir() + .map_err(|e| e.to_string())?; + + // Create dir if not exist + fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; + + Ok(config_dir.join("config.json")) +} + pub mod feed_config { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06e7762..1d9cf76 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ pub mod commands; pub mod config; pub mod parser; -use commands::feed::{add_feed, get_entry, get_feeds, get_feed_item, remove_feed}; +use commands::feed::{add_feed, get_entry, get_feed_item, get_feeds, remove_feed}; pub async fn test_thing() -> Result<(), Error> { // match get_entry("https://blog.rust-lang.org/", "https://blog.rust-lang.org/2026/03/20/rust-challenges/").await { @@ -44,6 +44,12 @@ pub enum Error { #[error("HTTP request failed: {0}")] HttpRequest(#[from] reqwest::Error), + #[error("The website returned unreadable response data")] + InvalidResponseBody, + + #[error("The website returned status {status} for {url}")] + HttpStatus { url: String, status: u16 }, + #[error("Failed to parse JSON: {0}")] Json(#[from] serde_json::Error), } diff --git a/src-tauri/src/parser.rs b/src-tauri/src/parser.rs index 652da21..9723f7b 100644 --- a/src-tauri/src/parser.rs +++ b/src-tauri/src/parser.rs @@ -1,3 +1,4 @@ +use feed_rs::model::Person; use feed_rs::parser; use scraper::{Html, Selector}; @@ -10,11 +11,6 @@ pub struct FeedItem { pub url: String, } -#[derive(Debug, Clone, serde::Serialize)] -pub struct Person { - pub name: String, -} - impl FeedItem { pub fn from_feed(xml: &str) -> Result, Error> { let feed = parser::parse(xml.as_bytes())?; @@ -71,6 +67,13 @@ impl FeedEntry { .unwrap_or_default(); if url == target_url { + let summary = entry.summary.map(|t| t.content); + let content = entry + .content + .and_then(|c| c.body) + .or_else(|| summary.clone()) + .ok_or(Error::MissingField("content".into()))?; + return Ok(Self { id: entry.id, title: entry @@ -87,15 +90,16 @@ impl FeedEntry { .updated .map(|d| d.to_rfc3339()) .ok_or(Error::MissingField("updated".into()))?, - summary: entry.summary.map(|t| t.content), - content: entry - .content - .and_then(|c| c.body) - .ok_or(Error::MissingField("content".into()))?, + summary, + content, authors: entry .authors .into_iter() - .map(|author| Person { name: author.name }) + .map(|author| Person { + name: author.name, + uri: author.uri, + email: author.email, + }) .collect(), }); } diff --git a/src/App.css b/src/App.css index f572c81..57e295b 100644 --- a/src/App.css +++ b/src/App.css @@ -3,6 +3,14 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@font-face { + font-family: "Inter"; + src: url("/fonts/Inter-VariableFont_opsz,wght.woff2") format("woff2"); + font-weight: 100 900; + font-style: normal; + font-display: optional; +} + @custom-variant dark (&:is(.dark *)); :root { @@ -171,6 +179,16 @@ margin-top: 1.2rem; } + .reader-content, + .reader-content * { + -webkit-user-select: text; + user-select: text; + } + + .reader-content ::selection { + @apply bg-primary/25 text-foreground; + } + .reader-content p, .reader-content li { @apply text-base leading-[1.8] text-foreground; diff --git a/src/App.tsx b/src/App.tsx index b545cb8..b68040e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from "react"; -import { commands, Feed, FeedEntry, FeedItem } from "./bindings"; +import { commands, Feed, FeedEntry, FeedItem, Person } from "./bindings"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; -import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent } from "@/components/ui/sheet"; @@ -71,6 +70,89 @@ const formatPublishedDate = (value?: string, long = false): string => { : { year: "numeric", month: "short", day: "numeric" }); }; +const getRenderableHtml = (content: string): string => { + const value = content.trim(); + if (!value) return ""; + + const hasHtmlTags = /<[a-z][\s\S]*>/i.test(value); + const hasEscapedTags = /<\/?[a-z][\s\S]*>/i.test(value); + if (hasHtmlTags || !hasEscapedTags) return value; + + const textarea = document.createElement("textarea"); + textarea.innerHTML = value; + const decoded = textarea.value.trim(); + + return /<[a-z][\s\S]*>/i.test(decoded) ? decoded : value; +}; + +const isSummarySameAsContent = (summary: string | null, content: string): boolean => { + if (!summary) return false; + + const normalizedSummary = summary.replace(/\s+/g, " ").trim(); + if (!normalizedSummary) return false; + + const htmlContent = getRenderableHtml(content); + const normalizedHtml = htmlContent.replace(/\s+/g, " ").trim(); + if (normalizedSummary === normalizedHtml) return true; + + const textContent = htmlContent + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + + return normalizedSummary === textContent; +}; + +const normalizeAuthorUri = (value: string): string => { + const cleanedValue = value.trim(); + if (!cleanedValue) return ""; + return /^https?:\/\//i.test(cleanedValue) ? cleanedValue : `https://${cleanedValue}`; +}; + +const getAuthorUriLabel = (value: string): string => { + const normalized = normalizeAuthorUri(value); + if (!normalized) return "Website"; + + try { + const url = new URL(normalized); + return url.hostname.replace(/^www\./i, "") || "Website"; + } catch { + return "Website"; + } +}; + +const renderAuthor = (author: Person, index: number) => { + const normalizedUri = author.uri ? normalizeAuthorUri(author.uri) : ""; + + return ( +
+ {author.name} + {author.email && ( + + {author.email} + + )} + {normalizedUri && ( + + {getAuthorUriLabel(normalizedUri)} + + + )} +
+ ); +}; + export default function App() { const { resolvedTheme, setTheme } = useTheme(); const [feeds, setFeeds] = useState([]); @@ -349,7 +431,7 @@ export default function App() { {/* Main content */}
-
+
-
+

{view.entry.title}

-
+
{view.entry.authors.length > 0 && ( - - {view.entry.authors.map((a) => a.name).join(", ")} - +
+ {view.entry.authors.map((author, index) => renderAuthor(author, index))} +
)} {view.entry.published && ( {formatPublishedDate(view.entry.published, true)} )}
- {view.entry.summary && ( + {view.entry.summary && !isSummarySameAsContent(view.entry.summary, view.entry.content) && (

{view.entry.summary}

)}
diff --git a/src/bindings.ts b/src/bindings.ts index 277e715..04978cc 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -16,6 +16,8 @@ export type FeedItem = { export type Person = { name: string; + uri?: string; + email?: string; }; export type FeedEntry = {