jroshell/widget/Overlay/RSSList.tsx
2026-06-06 13:53:38 +02:00

186 lines
6 KiB
TypeScript

import { Gtk } from "ags/gtk4";
import { createState } from "ags";
import GLib from "gi://GLib";
import Gio from "gi://Gio";
import { watchFile } from "../../lib/fileMonitor";
type FeedItem = {
title: string;
link: string;
date: string;
summary: string;
author: string;
feed_title: string;
tag: string;
};
const CACHE_FILE = GLib.build_filenamev([
GLib.get_user_cache_dir(),
"ags",
"rss-feeds.json",
]);
export default function RSSList() {
const [items, setItems] = createState<FeedItem[]>([]);
let listContainer: Gtk.Box | null = null;
function readItemsFromCache(): FeedItem[] {
try {
const file = Gio.File.new_for_path(CACHE_FILE);
const [ok, contents] = file.load_contents(null);
if (!ok) return [];
const data = JSON.parse(new TextDecoder("utf-8").decode(contents));
if (!Array.isArray(data)) return [];
return data.map((item: any) => ({
title: item.title || "(untitled)",
link: item.link || "",
date: item.date ? formatDate(item.date) : "",
summary: item.summary || "",
author: item.author || "",
feed_title: item.feed_title || "",
tag: item.tag || "",
}));
} catch (err) {
console.error("Failed to read RSS cache", err);
return [];
}
}
function formatDate(iso: string): string {
try {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours < 1) return "just now";
if (hours < 24) return `${hours}h ago`;
if (hours < 48) return "yesterday";
return d.toLocaleDateString();
} catch {
return iso;
}
}
function rebuildList() {
if (!listContainer) return;
let child = listContainer.get_first_child();
while (child) {
const next = child.get_next_sibling();
listContainer.remove(child);
child = next;
}
const currentItems = items();
if (currentItems.length === 0) {
const emptyBox = new Gtk.Box({
halign: Gtk.Align.CENTER,
cssClasses: ["status-box"],
});
emptyBox.append(new Gtk.Label({ label: "📰", cssClasses: ["empty-icon"] }));
emptyBox.append(new Gtk.Label({ label: "No feed items", cssClasses: ["status-text"] }));
listContainer.append(emptyBox);
} else {
for (const item of currentItems) {
const entry = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
cssClasses: ["rss-item"],
});
// Title
const titleLabel = new Gtk.Label({
label: item.title,
halign: Gtk.Align.START,
hexpand: false,
cssClasses: ["rss-title"],
wrap: true,
wrapMode: 2,
});
entry.append(titleLabel);
// Meta: tag badge, feed title, date
const meta = new Gtk.Box({ spacing: 8, cssClasses: ["rss-meta"] });
if (item.tag) {
const tagBadge = new Gtk.Label({
label: item.tag,
cssClasses: ["rss-tag", `rss-tag-${item.tag}`],
});
meta.append(tagBadge);
}
meta.append(new Gtk.Label({
label: item.feed_title,
cssClasses: ["rss-feed-title"],
}));
meta.append(new Gtk.Label({
label: "•",
cssClasses: ["rss-separator"],
}));
meta.append(new Gtk.Label({
label: item.date,
cssClasses: ["rss-date"],
}));
entry.append(meta);
// Summary (truncated)
if (item.summary) {
const maxLen = 160;
const text = item.summary.length <= maxLen
? item.summary
: item.summary.slice(0, maxLen).trimEnd() + "…";
const summaryLabel = new Gtk.Label({
label: text,
halign: Gtk.Align.START,
cssClasses: ["rss-summary"],
wrap: true,
wrapMode: 2,
});
entry.append(summaryLabel);
}
// Make clickable if link exists
if (item.link) {
entry.add_css_class("rss-item-link");
const gesture = new Gtk.GestureClick();
gesture.connect("pressed", () => {
GLib.spawn_command_line_async(`xdg-open '${item.link.replace(/'/g, "'\''")}'`);
});
entry.add_controller(gesture);
}
listContainer.append(entry);
}
}
}
function updateDisplay() {
const parsed = readItemsFromCache();
setItems(parsed);
rebuildList();
}
const root = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
cssClasses: ["rss-list-container"],
widthRequest: 300,
});
const scrolled = new Gtk.ScrolledWindow({
vexpand: true,
cssClasses: ["rss-scroll", "grid-card"],
});
listContainer = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
spacing: 12,
hexpand: true,
cssClasses: ["rss-list"],
});
scrolled.set_child(listContainer);
root.append(scrolled);
updateDisplay();
watchFile(CACHE_FILE, updateDisplay, root);
return root;
}