diff options
| author | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-04-09 14:21:50 +1000 |
|---|---|---|
| committer | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-04-09 14:21:50 +1000 |
| commit | 5b221fb72d6b915a56df4c17ecc747bb6f15dee8 (patch) | |
| tree | f1199057ffa9cdffca2a69f9ef2246d185894b06 /src | |
| parent | weather: store api key directly in config (diff) | |
| download | caelestia-shell-5b221fb72d6b915a56df4c17ecc747bb6f15dee8.tar.gz caelestia-shell-5b221fb72d6b915a56df4c17ecc747bb6f15dee8.tar.bz2 caelestia-shell-5b221fb72d6b915a56df4c17ecc747bb6f15dee8.zip | |
feat: news headlines for alerts pane
Also handle news api errors
Also config num pages
Diffstat (limited to 'src')
| -rw-r--r-- | src/config/defaults.ts | 1 | ||||
| -rw-r--r-- | src/config/types.ts | 1 | ||||
| -rw-r--r-- | src/modules/sidebar/alerts.tsx | 3 | ||||
| -rw-r--r-- | src/modules/sidebar/modules/headlines.tsx | 172 | ||||
| -rw-r--r-- | src/modules/sidebar/modules/news.tsx | 2 | ||||
| -rw-r--r-- | src/services/news.ts | 35 |
6 files changed, 206 insertions, 8 deletions
diff --git a/src/config/defaults.ts b/src/config/defaults.ts index d5db290..70a4700 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -171,5 +171,6 @@ export default { languages: ["en"], // A list of languages codes to filter by domains: [] as string[], // A list of news domains to pull from timezone: "", // A timezone to filter by, e.g. "America/New_York" + pages: 3, // Number of pages to pull (each page is 10 articles) }, }; diff --git a/src/config/types.ts b/src/config/types.ts index 2862612..0e9138d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -100,4 +100,5 @@ export default { "news.languages": ARR(STR), "news.domains": ARR(STR), "news.timezone": STR, + "news.pages": NUM, } as { [k: string]: string | string[] | number[] }; diff --git a/src/modules/sidebar/alerts.tsx b/src/modules/sidebar/alerts.tsx index 3dd4b5a..b669514 100644 --- a/src/modules/sidebar/alerts.tsx +++ b/src/modules/sidebar/alerts.tsx @@ -1,7 +1,10 @@ +import Headlines from "./modules/headlines"; import Notifications from "./modules/notifications"; export default () => ( <box vertical className="pane alerts" name="alerts"> <Notifications /> + <box className="separator" /> + <Headlines /> </box> ); diff --git a/src/modules/sidebar/modules/headlines.tsx b/src/modules/sidebar/modules/headlines.tsx new file mode 100644 index 0000000..924e5b8 --- /dev/null +++ b/src/modules/sidebar/modules/headlines.tsx @@ -0,0 +1,172 @@ +import News, { type IArticle } from "@/services/news"; +import Palette, { type IPalette } from "@/services/palette"; +import { capitalize } from "@/utils/strings"; +import { setupCustomTooltip } from "@/utils/widgets"; +import { bind, execAsync, Variable } from "astal"; +import { Gtk } from "astal/gtk3"; + +const fixGoogleNews = (colours: IPalette, title: string, desc: string) => { + // Add separator, bold and split at domain (domain is at the end of each headline) + const domain = title.split(" - ").at(-1); + if (domain) desc = desc.replaceAll(domain, `— <span foreground="${colours.subtext0}">${domain}</span>\n\n`); + // Add spaces between sentences + desc = desc.replace(/\.([A-Z])/g, ". $1"); + // Split headlines + desc = desc.replace(/(( |\.)[^A-Z][a-z]+)([A-Z])/g, "$1\n\n$3"); + desc = desc.replace(/( [A-Z]+)([A-Z](?![s])[a-z])/g, "$1\n\n$2"); + // Add separator and bold domains + desc = desc.replace(/ ([a-zA-Z.]+)\n\n/g, ` — <span foreground="${colours.subtext0}">$1</span>\n\n`); + desc = desc.replace(/ ([a-zA-Z.]+)$/, ` — <span foreground="${colours.subtext0}">$1</span>`); // Last domain + return desc.trim(); +}; + +const getCategoryIcon = (category: string) => { + if (category === "business") return "monitoring"; + if (category === "crime") return "speed_camera"; + if (category === "domestic") return "home"; + if (category === "education") return "school"; + if (category === "entertainment") return "tv"; + if (category === "environment") return "eco"; + if (category === "food") return "restaurant"; + if (category === "health") return "health_and_safety"; + if (category === "lifestyle") return "digital_wellbeing"; + if (category === "politics") return "account_balance"; + if (category === "science") return "science"; + if (category === "sports") return "sports_basketball"; + if (category === "technology") return "account_tree"; + if (category === "top") return "breaking_news"; + if (category === "tourism") return "travel"; + if (category === "world") return "public"; + return "newsmode"; +}; + +const Article = ({ title, description, creator, pubDate, source_name, link }: IArticle) => { + const expanded = Variable(false); + + return ( + <box vertical className="article"> + <button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}> + <box hexpand className="header"> + <box vertical> + <label truncate xalign={0} label={title} setup={self => setupCustomTooltip(self, title)} /> + <label + truncate + xalign={0} + className="sublabel" + label={source_name + (creator ? ` (${creator.join(", ")})` : "")} + /> + </box> + </box> + </button> + <revealer + revealChild={bind(expanded)} + transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} + transitionDuration={200} + > + <button onClicked={() => execAsync(`app2unit -O -- ${link}`)}> + <box vertical className="article-body"> + <label wrap xalign={0} label={`Published on ${new Date(pubDate).toLocaleString()}`} /> + <label + wrap + xalign={0} + className="sublabel" + label={`By ${ + creator?.join(", ") ?? + (source_name === "Google News" ? title.split(" - ").at(-1) : source_name) + }`} + /> + {description && ( + <label + wrap + useMarkup + xalign={0} + label={bind(Palette.get_default(), "colours").as(c => + `\n${ + source_name === "Google News" + ? fixGoogleNews(c, title, description) + : description + }`.replaceAll("&", "&") + )} + /> + )} + </box> + </button> + </revealer> + </box> + ); +}; + +const Category = ({ title, articles }: { title: string; articles: IArticle[] }) => { + const expanded = Variable(false); + + return ( + <box vertical className="category"> + <button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}> + <box className="header"> + <label className="icon" label={getCategoryIcon(title)} /> + <label label={`${capitalize(title)} (${articles.length})`} /> + <box hexpand /> + <label className="icon" label={bind(expanded).as(e => (e ? "expand_less" : "expand_more"))} /> + </box> + </button> + <revealer + revealChild={bind(expanded)} + transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} + transitionDuration={200} + > + <box vertical className="body"> + {articles.map(a => ( + <Article {...a} /> + ))} + </box> + </revealer> + </box> + ); +}; + +const List = () => ( + <box vertical valign={Gtk.Align.START} className="list"> + {bind(News.get_default(), "categories").as(c => + Object.entries(c).map(([k, v]) => <Category title={k} articles={v} />) + )} + </box> +); + +const NoNews = () => ( + <box homogeneous name="empty"> + <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> + <label className="icon" label="full_coverage" /> + <label label="No news headlines!" /> + </box> + </box> +); + +export default () => ( + <box vertical className="headlines"> + <box className="header-bar"> + <label label="Top news headlines" /> + <box hexpand /> + <button + className={bind(News.get_default(), "loading").as(l => (l ? "enabled" : ""))} + sensitive={bind(News.get_default(), "loading").as(l => !l)} + cursor="pointer" + onClicked={() => News.get_default().getNews()} + label={bind(News.get_default(), "loading").as(l => (l ? " Loading" : " Reload"))} + /> + </box> + <stack + transitionType={Gtk.StackTransitionType.CROSSFADE} + transitionDuration={200} + shown={bind(News.get_default(), "articles").as(a => (a.length > 0 ? "list" : "empty"))} + > + <NoNews /> + <scrollable + className={bind(News.get_default(), "articles").as(a => (a.length > 0 ? "expanded" : ""))} + hscroll={Gtk.PolicyType.NEVER} + name="list" + > + <List /> + </scrollable> + </stack> + </box> +); diff --git a/src/modules/sidebar/modules/news.tsx b/src/modules/sidebar/modules/news.tsx index aba37c7..1ab2383 100644 --- a/src/modules/sidebar/modules/news.tsx +++ b/src/modules/sidebar/modules/news.tsx @@ -69,7 +69,7 @@ const List = () => ( const NoNews = () => ( <box homogeneous name="empty"> <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="breaking_news_alt_1" /> + <label className="icon" label="breaking_news" /> <label label="No Arch news!" /> </box> </box> diff --git a/src/services/news.ts b/src/services/news.ts index 5845aff..3d56186 100644 --- a/src/services/news.ts +++ b/src/services/news.ts @@ -2,13 +2,15 @@ import { notify } from "@/utils/system"; import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal"; import { news as config } from "config"; -export interface Article { +export interface IArticle { title: string; link: string; - keywords: string; - creator: string; - description: string; + keywords: string[] | null; + creator: string[] | null; + description: string | null; pubDate: string; + source_name: string; + category: string[]; } @register({ GTypeName: "News" }) @@ -24,7 +26,8 @@ export default class News extends GObject.Object { #notified = false; #loading: boolean = false; - #articles: Article[] = []; + #articles: IArticle[] = []; + #categories: { [category: string]: IArticle[] } = {}; @property(Boolean) get loading() { @@ -36,6 +39,11 @@ export default class News extends GObject.Object { return this.#articles; } + @property(Object) + get categories() { + return this.#categories; + } + async getNews() { if (!config.apiKey.get()) { if (!this.#notified) { @@ -77,11 +85,14 @@ export default class News extends GObject.Object { const url = `https://newsdata.io/api/1/latest?apikey=${config.apiKey.get()}&${args}`; try { const res = JSON.parse(await execAsync(["curl", url])); - this.#articles = res.results; + if (res.status !== "success") throw new Error(`Failed to get news: ${res.results.message}`); + + this.#articles = [...res.results]; let page = res.nextPage; - for (let i = 0; i < 3; i++) { + for (let i = 1; i < config.pages.get(); i++) { const res = JSON.parse(await execAsync(["curl", `${url}&page=${page}`])); + if (res.status !== "success") throw new Error(`Failed to get news: ${res.results.message}`); this.#articles.push(...res.results); page = res.nextPage; } @@ -95,6 +106,15 @@ export default class News extends GObject.Object { } this.notify("articles"); + this.#categories = {}; + for (const article of this.#articles) { + for (const category of article.category) { + if (!this.#categories.hasOwnProperty(category)) this.#categories[category] = []; + this.#categories[category].push(article); + } + } + this.notify("categories"); + this.#loading = false; this.notify("loading"); } @@ -109,5 +129,6 @@ export default class News extends GObject.Object { config.languages.subscribe(() => this.getNews().catch(console.error)); config.domains.subscribe(() => this.getNews().catch(console.error)); config.timezone.subscribe(() => this.getNews().catch(console.error)); + config.pages.subscribe(() => this.getNews().catch(console.error)); } } |