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} />,
);
// Check feeds that are due when the reader opens. Failures are shown on
// each feed in the Feeds dialog.
loadFeeds(store)
  .then(() => refreshFeeds(store, { olderThan: STALE_MS }))
  .catch((error) => console.error("[Feed Reader] Feed check failed:", error));