Feed Reader
index.jsx 536 lines
Files · 10
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536import "./style.css";
import React, { useState, useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";
import { RecordApp, RecordActions } from "../../shared/records.jsx";
import { displayDate } from "../../shared/format.js";
import { store } from "./store.js";
import { Button, Input, Modal } from "../../shared/ui.jsx";
import { parseFeed, plainText } from "./rss.js";
import {
STALE_MS,
feedList,
loadFeeds,
refreshFeeds,
siteName,
subscribe,
unsubscribe,
} from "./feeds.js";
function safeURL(value) {
if (!value) return null;
try {
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
function sourceName(row) {
if (row.feed_title) return row.feed_title;
if (row.source) return row.source;
try {
return new URL(row.source_url || row.link).hostname.replace(/^www\./, "");
} catch {
return "RSS item";
}
}
function title(row) {
return (
row.title || plainText(row.description).slice(0, 80) || "Untitled item"
);
}
function ImportRSS({ store, onClose, onDone }) {
const [xml, setXML] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event) {
event.preventDefault();
setError("");
setBusy(true);
try {
const result = await store.importItems(parseFeed(xml).items);
onDone(
`Imported ${result.added} new ${result.added === 1 ? "item" : "items"}; updated ${result.updated}.`,
);
onClose();
} catch (err) {
setError(String(err.message || err));
} finally {
setBusy(false);
}
}
return (
<Modal title="Import a feed file" onClose={onClose} busy={busy} wide>
<form onSubmit={submit}>
<p className="ra-help">
Choose an RSS or Atom file, or paste its XML. Matching items will be
updated.
</p>
<div className="ui-field ra-upload">
<label htmlFor="rss-file">Feed file</label>
<input
id="rss-file"
type="file"
accept=".xml,.rss,.atom,application/rss+xml,application/atom+xml,text/xml,application/xml"
disabled={busy}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
setError("");
if (file.size > 4 * 1024 * 1024) {
setError("Choose an RSS file smaller than 4 MB.");
return;
}
try {
setXML(await file.text());
} catch {
setError("The file could not be read.");
}
}}
/>
</div>
<div className="ui-field">
<label htmlFor="rss-xml">XML contents</label>
<textarea
id="rss-xml"
rows="9"
value={xml}
onChange={(event) => setXML(event.target.value)}
required
disabled={busy}
placeholder='<rss version="2.0">…</rss>'
/>
</div>
{error && (
<p className="ra-alert" role="alert">
{error}
</p>
)}
<div className="ui-form-actions">
<Button
type="button"
className="ui-button"
disabled={busy}
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
className="ui-button"
disabled={busy || !xml}
>
{busy ? "Importing…" : "Import items"}
</Button>
</div>
</form>
</Modal>
);
}
function checkedAt(feed) {
if (!feed.checked_at) return "Not checked yet";
return `Checked ${new Date(feed.checked_at).toLocaleString([], {
dateStyle: "medium",
timeStyle: "short",
})}`;
}
function Feeds({ store, onClose, onDone }) {
const feeds = useSyncExternalStore(feedList.subscribe, feedList.getSnapshot);
const [importing, setImporting] = useState(false);
const [address, setAddress] = useState("");
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [status, setStatus] = useState("");
if (importing)
return <ImportRSS store={store} onClose={onClose} onDone={onDone} />;
async function run(kind, operation) {
setBusy(kind);
setError("");
setStatus("");
try {
setStatus(await operation());
} catch (err) {
setError(String(err.message || err));
} finally {
setBusy("");
}
}
const items = (count) =>
count ? `${count} new ${count === 1 ? "item" : "items"}` : "no new items";
return (
<Modal title="Feeds" onClose={onClose} busy={!!busy} wide>
<form
onSubmit={(event) => {
event.preventDefault();
run("subscribe", async () => {
const result = await subscribe(store, address);
setAddress("");
return `Subscribed to ${result.title}: ${items(result.added)}.`;
});
}}
>
<p className="ra-help">
Follow an RSS or Atom feed by its address. Feed addresses are stored as
https; an http address is upgraded when you subscribe. The first time
a feed is on a new site, Lattice asks whether the reader may connect to it.
</p>
<div className="ra-subscribe">
<Input
label="Feed address"
value={address}
onChange={(event) => setAddress(event.target.value)}
placeholder="https://example.com/feed.xml"
required
disabled={!!busy}
/>
<Button type="submit" variant="primary" disabled={!!busy || !address}>
{busy === "subscribe" ? "Subscribing…" : "Subscribe"}
</Button>
</div>
</form>
{error && (
<p className="ra-alert" role="alert">
{error}
</p>
)}
{status && (
<p className="ra-help" role="status">
{status}
</p>
)}
{feeds.length ? (
<ul className="ra-feed-list" aria-label="Followed feeds">
{feeds.map((feed) => (
<li key={feed.url}>
<div>
<strong>{feed.title || siteName(feed.url)}</strong>
<small className="ui-muted">{feed.url}</small>
<small className={feed.error ? "ui-error-text" : "ui-muted"}>
{feed.error || checkedAt(feed)}
</small>
</div>
<Button
variant="ghost"
disabled={!!busy}
onClick={() =>
run("remove", async () => {
await unsubscribe(feed.url);
return `Unsubscribed from ${feed.title || siteName(feed.url)}. Its items stay in the reader.`;
})
}
>
Unsubscribe
</Button>
</li>
))}
</ul>
) : (
<p className="ui-muted">You don’t follow any feeds yet.</p>
)}
<div className="ui-form-actions">
<Button disabled={!!busy} onClick={() => setImporting(true)}>
Import a file…
</Button>
<Button
disabled={!!busy || !feeds.length}
onClick={() =>
run("refresh", async () => {
const result = await refreshFeeds(store);
const text = `${items(result.added)}${result.failed ? `; ${result.failed} ${result.failed === 1 ? "feed" : "feeds"} could not be checked` : ""}.`;
return text[0].toUpperCase() + text.slice(1);
})
}
>
{busy === "refresh" ? "Checking…" : "Check for new items"}
</Button>
<Button variant="primary" disabled={!!busy} onClick={onClose}>
Done
</Button>
</div>
</Modal>
);
}
const config = {
slug: "feeds",
singular: "item",
plural: "items",
navigationLabel: "Your feeds",
subtitle: "Saved articles and RSS feeds.",
listLabel: "Items",
searchPlaceholder: "Search titles or sources…",
emptyTitle: "Add something to read",
emptyDescription:
"Subscribe to a feed, add an item, or try a few sample articles.",
title,
extraLabel: "Feeds",
renderExtra: (props) => <Feeds {...props} />,
search: (row) =>
`${row.title || ""} ${plainText(row.description)} ${row.author || ""} ${row.feed_title || ""} ${row.source || ""} ${row.category || ""}`,
sort: (a, b) =>
(Date.parse(b.pub_date) || 0) - (Date.parse(a.pub_date) || 0) ||
b.id - a.id,
filters: (rows) => [
{ id: "all", label: "All items", count: rows.length },
...[...new Set(rows.map((row) => row.category).filter(Boolean))]
.sort()
.map((category) => ({
id: `category:${category}`,
label: category,
count: rows.filter((row) => row.category === category).length,
})),
],
matches: (row, filter) => row.category === filter.slice(9),
stats: (rows) => [
{ label: "Saved items", value: rows.length },
{
label: "Sources",
value: new Set(
rows
.filter((row) => row.source || row.source_url || row.link)
.map(sourceName),
).size,
},
{
label: "Categories",
value: new Set(rows.map((row) => row.category).filter(Boolean)).size,
},
],
fields: [
{
key: "title",
label: "Title",
wide: true,
help: "Enter a title or synopsis.",
},
{
key: "description",
label: "Synopsis",
multiline: true,
wide: true,
rows: 5,
},
{
key: "link",
label: "Article link",
wide: true,
placeholder: "https://example.com/article",
},
{
key: "pub_date",
label: "Publication date",
placeholder: "Thu, 15 Oct 2026 08:00:00 GMT",
help: "Original RSS date, using RFC 822 format.",
},
{ key: "category", label: "Category" },
{
key: "author",
label: "Author",
placeholder: "[address] (Writer)",
},
{ key: "source", label: "Original source name", help: "RSS source: where this item was first published." },
{
key: "guid",
label: "GUID",
advanced: true,
},
{
key: "guid_is_perma_link",
label: "GUID is a permalink",
options: [
{ value: 1, label: "Yes (RSS default)" },
{ value: 0, label: "No" },
],
default: () => 1,
advanced: true,
},
{ key: "source_url", label: "Original source feed URL", advanced: true, wide: true },
{ key: "comments", label: "Comments URL", advanced: true, wide: true },
{
key: "enclosure_url",
label: "Attachment URL",
advanced: true,
wide: true,
},
{
key: "enclosure_length",
label: "Attachment length (bytes)",
numeric: true,
advanced: true,
},
{
key: "enclosure_type",
label: "Attachment MIME type",
advanced: true,
placeholder: "audio/mpeg",
},
],
prepare: (data) => {
if (data.title === null && data.description === null)
throw new Error("Provide a title or a synopsis.");
return data;
},
renderCard: (row, actions, selected) => (
<article
className={`ui-panel ui-record-card ra-feeds-card ${selected ? "is-selected" : ""}`}
>
<div className="ra-card-top">
<div>
<div className="ra-meta" style={{ marginTop: 0, marginBottom: 7 }}>
<span>{sourceName(row)}</span>
<span className="ra-dot">{displayDate(row.pub_date)}</span>
</div>
<Button
variant="ghost"
className="ra-feed-open"
onClick={() => actions.open(row)}
>
{title(row)}
</Button>
</div>
<RecordActions row={row} actions={actions} title={title(row)} />
</div>
{row.description && (
<p className="ra-feed-preview">{plainText(row.description)}</p>
)}
<div className="ra-feed-bottom">
{row.category && <span className="ui-badge">{row.category}</span>}
{row.enclosure_url && <span className="ui-badge">Attachment</span>}
<Button
variant="ghost"
className="ra-inline-action"
onClick={() => actions.open(row)}
>
Read item →
</Button>
</div>
</article>
),
renderDetail: (row, actions) => (
<aside className="ra-feed-detail" aria-label="Article reader">
<div className="ra-detail-close">
Article reader
<Button
variant="ghost"
className="ra-icon-button"
aria-label="Close article reader"
onClick={actions.close}
>
×
</Button>
</div>
<h2>{title(row)}</h2>
<div className="ra-meta">
<span>{sourceName(row)}</span>
<span className="ra-dot">{displayDate(row.pub_date)}</span>
</div>
{row.author && <p className="ra-meta">{row.author}</p>}
<p className="ra-detail-body">
{plainText(row.description) || "This item has no synopsis."}
</p>
<div className="ra-feed-links">
{[
["Original article", row.link],
["Comments", row.comments],
["Attachment", row.enclosure_url],
]
.filter(([, url]) => safeURL(url))
.map(([label, url]) => (
<a
key={label}
href={safeURL(url)}
target="_blank"
rel="noopener noreferrer"
onClick={() => store.emit("item-opened", row)}
>
{label} ↗
</a>
))}
</div>
<dl className="ra-detail-meta">
{row.feed_title && <><dt>Subscription</dt><dd>{row.feed_title}</dd></>}
{row.pub_date && (
<>
<dt>Original publication date</dt>
<dd>{row.pub_date}</dd>
</>
)}
{row.source_url && (
<>
<dt>Original source feed</dt>
<dd>{row.source_url}</dd>
</>
)}
{row.enclosure_url && (
<>
<dt>Attachment</dt>
<dd>
{row.enclosure_type || "Unknown type"}
{row.enclosure_length !== null
? ` · ${row.enclosure_length.toLocaleString()} bytes`
: ""}
</dd>
</>
)}
</dl>
</aside>
),
samples: [
{
guid: "field-notes-204",
guid_is_perma_link: 0,
title: "A field guide to autumn walks",
link: "https://example.com/autumn-walks",
description:
"Cooler mornings change the rhythm of a familiar route. Look for the small details: seed heads along the path, birds gathering at the river, and the first leaves turning gold.\n\nTake a notebook, walk slowly, and leave room for an unplanned turn.",
category: "Outdoors",
source: "Field Notes",
source_url: "https://example.com/field-notes.xml",
author: "[address] (Field Notes)",
pub_date: "Thu, 15 Oct 2026 08:00:00 GMT",
},
{
guid: "workshop-118",
guid_is_perma_link: 0,
title: "Repairing a favorite chair",
description:
"<p>A loose joint is often easier to fix than it looks. Start by checking the grain, cleaning the old glue, and testing the fit before clamping.</p>",
category: "Making",
source: "The Workshop",
source_url: "https://example.com/workshop.xml",
pub_date: "Wed, 14 Oct 2026 16:30:00 GMT",
},
{
guid: "reading-room-87",
guid_is_perma_link: 0,
title: "What makes a good reading corner?",
description:
"A lamp at the right height, a place to rest your cup, and a chair you can sit in for an hour. Three readers share the spaces they return to.",
category: "Culture",
source: "Reading Room",
source_url: "https://example.com/reading.xml",
pub_date: "Tue, 13 Oct 2026 10:00:00 GMT",
},
{
guid: "field-notes-203",
guid_is_perma_link: 0,
title: "The sound of the city before dawn",
description:
"A short audio walk through streets before the morning traffic. Delivery bicycles, a bakery opening its shutters, and a lone bus at the junction.",
category: "Outdoors",
source: "Field Notes",
source_url: "https://example.com/field-notes.xml",
pub_date: "Mon, 12 Oct 2026 06:00:00 GMT",
enclosure_url: "https://example.com/dawn.mp3",
enclosure_length: 4821000,
enclosure_type: "audio/mpeg",
},
],
};
createRoot(document.getElementById("root")).render(
<RecordApp store={store} config={config} />,
);
loadFeeds(store)
.then(() => refreshFeeds(store, { olderThan: STALE_MS }))
.catch((error) => console.error("[Feed Reader] Feed check failed:", error));