feat: news headlines for alerts pane
Also handle news api errors Also config num pages
This commit is contained in:
parent
a6f26b3d37
commit
5b221fb72d
7 changed files with 275 additions and 9 deletions
|
|
@ -752,7 +752,7 @@
|
||||||
min-height: lib.s(200);
|
min-height: lib.s(200);
|
||||||
|
|
||||||
.expanded {
|
.expanded {
|
||||||
min-height: lib.s(400);
|
min-height: lib.s(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|
@ -792,6 +792,74 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headlines {
|
||||||
|
min-height: lib.s(200);
|
||||||
|
|
||||||
|
.expanded {
|
||||||
|
min-height: lib.s(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin-top: lib.s(40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
@include lib.spacing(10, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
@include lib.rounded(20);
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
|
background-color: color.change(scheme.$surface0, $alpha: 0.5);
|
||||||
|
padding: lib.s(10) lib.s(15);
|
||||||
|
|
||||||
|
@include lib.spacing(5);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: lib.s(28);
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: lib.s(12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sublabel {
|
||||||
|
font-size: lib.s(14);
|
||||||
|
color: scheme.$subtext0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin-top: lib.s(10);
|
||||||
|
font-size: lib.s(14);
|
||||||
|
|
||||||
|
@include lib.spacing(8, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article {
|
||||||
|
@include lib.rounded(20);
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
|
background-color: color.change(scheme.$surface1, $alpha: 0.3);
|
||||||
|
padding: lib.s(10) lib.s(15);
|
||||||
|
|
||||||
|
.article-body {
|
||||||
|
@include font.reading;
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
|
font-size: lib.s(15);
|
||||||
|
margin-top: lib.s(10);
|
||||||
|
color: scheme.$subtext1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover .article-body,
|
||||||
|
button:focus .article-body {
|
||||||
|
color: color.mix(scheme.$subtext0, scheme.$blue, 60%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.time-date {
|
.time-date {
|
||||||
padding: lib.s(12) lib.s(8);
|
padding: lib.s(12) lib.s(8);
|
||||||
font-size: lib.s(48);
|
font-size: lib.s(48);
|
||||||
|
|
|
||||||
|
|
@ -171,5 +171,6 @@ export default {
|
||||||
languages: ["en"], // A list of languages codes to filter by
|
languages: ["en"], // A list of languages codes to filter by
|
||||||
domains: [] as string[], // A list of news domains to pull from
|
domains: [] as string[], // A list of news domains to pull from
|
||||||
timezone: "", // A timezone to filter by, e.g. "America/New_York"
|
timezone: "", // A timezone to filter by, e.g. "America/New_York"
|
||||||
|
pages: 3, // Number of pages to pull (each page is 10 articles)
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -100,4 +100,5 @@ export default {
|
||||||
"news.languages": ARR(STR),
|
"news.languages": ARR(STR),
|
||||||
"news.domains": ARR(STR),
|
"news.domains": ARR(STR),
|
||||||
"news.timezone": STR,
|
"news.timezone": STR,
|
||||||
|
"news.pages": NUM,
|
||||||
} as { [k: string]: string | string[] | number[] };
|
} as { [k: string]: string | string[] | number[] };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import Headlines from "./modules/headlines";
|
||||||
import Notifications from "./modules/notifications";
|
import Notifications from "./modules/notifications";
|
||||||
|
|
||||||
export default () => (
|
export default () => (
|
||||||
<box vertical className="pane alerts" name="alerts">
|
<box vertical className="pane alerts" name="alerts">
|
||||||
<Notifications />
|
<Notifications />
|
||||||
|
<box className="separator" />
|
||||||
|
<Headlines />
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
172
src/modules/sidebar/modules/headlines.tsx
Normal file
172
src/modules/sidebar/modules/headlines.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -69,7 +69,7 @@ const List = () => (
|
||||||
const NoNews = () => (
|
const NoNews = () => (
|
||||||
<box homogeneous name="empty">
|
<box homogeneous name="empty">
|
||||||
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="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!" />
|
<label label="No Arch news!" />
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@ import { notify } from "@/utils/system";
|
||||||
import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal";
|
import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal";
|
||||||
import { news as config } from "config";
|
import { news as config } from "config";
|
||||||
|
|
||||||
export interface Article {
|
export interface IArticle {
|
||||||
title: string;
|
title: string;
|
||||||
link: string;
|
link: string;
|
||||||
keywords: string;
|
keywords: string[] | null;
|
||||||
creator: string;
|
creator: string[] | null;
|
||||||
description: string;
|
description: string | null;
|
||||||
pubDate: string;
|
pubDate: string;
|
||||||
|
source_name: string;
|
||||||
|
category: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@register({ GTypeName: "News" })
|
@register({ GTypeName: "News" })
|
||||||
|
|
@ -24,7 +26,8 @@ export default class News extends GObject.Object {
|
||||||
#notified = false;
|
#notified = false;
|
||||||
|
|
||||||
#loading: boolean = false;
|
#loading: boolean = false;
|
||||||
#articles: Article[] = [];
|
#articles: IArticle[] = [];
|
||||||
|
#categories: { [category: string]: IArticle[] } = {};
|
||||||
|
|
||||||
@property(Boolean)
|
@property(Boolean)
|
||||||
get loading() {
|
get loading() {
|
||||||
|
|
@ -36,6 +39,11 @@ export default class News extends GObject.Object {
|
||||||
return this.#articles;
|
return this.#articles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property(Object)
|
||||||
|
get categories() {
|
||||||
|
return this.#categories;
|
||||||
|
}
|
||||||
|
|
||||||
async getNews() {
|
async getNews() {
|
||||||
if (!config.apiKey.get()) {
|
if (!config.apiKey.get()) {
|
||||||
if (!this.#notified) {
|
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}`;
|
const url = `https://newsdata.io/api/1/latest?apikey=${config.apiKey.get()}&${args}`;
|
||||||
try {
|
try {
|
||||||
const res = JSON.parse(await execAsync(["curl", url]));
|
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;
|
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}`]));
|
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);
|
this.#articles.push(...res.results);
|
||||||
page = res.nextPage;
|
page = res.nextPage;
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +106,15 @@ export default class News extends GObject.Object {
|
||||||
}
|
}
|
||||||
this.notify("articles");
|
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.#loading = false;
|
||||||
this.notify("loading");
|
this.notify("loading");
|
||||||
}
|
}
|
||||||
|
|
@ -109,5 +129,6 @@ export default class News extends GObject.Object {
|
||||||
config.languages.subscribe(() => this.getNews().catch(console.error));
|
config.languages.subscribe(() => this.getNews().catch(console.error));
|
||||||
config.domains.subscribe(() => this.getNews().catch(console.error));
|
config.domains.subscribe(() => this.getNews().catch(console.error));
|
||||||
config.timezone.subscribe(() => this.getNews().catch(console.error));
|
config.timezone.subscribe(() => this.getNews().catch(console.error));
|
||||||
|
config.pages.subscribe(() => this.getNews().catch(console.error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue