import 'package:actors/actors.dart'; import 'package:drift/drift.dart' show Value; import 'package:signals/signals.dart'; import '../../database/database.dart'; import '../../mews/document.dart'; import '../actors/fetch_actor.dart'; /// Which sites the reader subscribes to (spec 6). The posts themselves live in /// the database, so an unread post survives a restart; only the set of /// addresses is held here, because that is what the chrome watches. class SubscriptionsController { SubscriptionsController({ required MewsDatabase db, required Actor fetch, }) : _db = db, _fetch = fetch; final MewsDatabase _db; final Actor _fetch; final subscribedUrls = signal({}); /// Whether a check is running, so a page can say so instead of looking idle. final refreshing = signal(false); /// Called after a check changes something a page would show. Set by the /// dependency container to the navigator's rerender; holding the navigator /// directly would make the wiring circular. void Function()? onChanged; Future load() async { final rows = await _db.allSubscriptions(); subscribedUrls.value = rows.map((row) => row.url).toSet(); } bool isSubscribed(String url) => subscribedUrls.value.contains(url); Future toggle(MewsPage page) async { if (isSubscribed(page.url)) { await remove(page.url); return; } await _db.addSubscription(page.url, page.title, page.atomFeedHref); subscribedUrls.value = {...subscribedUrls.value, page.url}; // Whatever the site has already published is its back catalogue, not // news, so it is stored read and only later posts are flagged. A site // that publishes only through a feed is seeded the same way. await _db.addEntries(page.url, page.entries, read: true); final feed = page.atomFeedHref; if (page.entries.isEmpty && feed != null) { await _checkFeed(page.url, feed, read: true); } } Future remove(String siteUrl) async { await _db.removeSubscription(siteUrl); subscribedUrls.value = {...subscribedUrls.value}..remove(siteUrl); } /// Records the posts on a subscribed page the reader just opened, and /// returns the ones that are new. Does nothing for a site not subscribed to. Future> refreshSite(MewsPage page) async { if (!isSubscribed(page.url)) return const []; await _db.describeSubscription(page.url, title: page.title, atomHref: page.atomFeedHref); final fresh = await _db.addEntries(page.url, page.entries); await _db.touchSubscription(page.url, DateTime.now()); return fresh; } /// Checks every subscribed site for posts. Sites are checked one after /// another on purpose: the fetch actor answers concurrent sends, so a /// parallel pass would open one request per site at once against small /// servers, for a wait nobody is sitting through. Future refreshAll() async { if (refreshing.value) return; refreshing.value = true; onChanged?.call(); try { for (final site in await _db.allSubscriptions()) { await _check(site.url); } } finally { refreshing.value = false; onChanged?.call(); } } /// Checks one site, on the reader's request. Future refreshOne(String siteUrl) async { if (refreshing.value || !isSubscribed(siteUrl)) return; refreshing.value = true; onChanged?.call(); try { await _check(siteUrl); } finally { refreshing.value = false; onChanged?.call(); } } Future markRead(String url, {String? siteUrl}) async { await _db.markEntryRead(url, siteUrl: siteUrl); onChanged?.call(); } Future markUnread(String url, {String? siteUrl}) async { await _db.markEntryUnread(url, siteUrl: siteUrl); onChanged?.call(); } /// Fetches one site and stores whatever it publishes. A site that cannot be /// reached is still recorded as checked and keeps the posts already held, so /// one site being down never stops the rest of the pass. Future _check(String siteUrl) async { try { final cached = await _db.cachedPage(siteUrl); final result = await _fetch.send(FetchPage( url: siteUrl, etag: cached?.etag, lastModified: cached?.lastModified, cachedSource: cached?.source, )); if (result case FetchPageOk( :final page, :final source, :final etag, :final lastModified, :final notModified, )) { await _db.describeSubscription(siteUrl, title: page.title, atomHref: page.atomFeedHref); await _db.addEntries(siteUrl, page.entries); // Dated links are the normative way a site publishes (spec 6.1), so // the feed is only read when the page itself listed nothing. A site // where both agree is then fetched once, not twice. final feed = page.atomFeedHref; if (page.entries.isEmpty && feed != null) { await _checkFeed(siteUrl, feed); } if (!notModified) { // Keep the cache current so the site also reads offline, but do not // record a visit: the reader did not open it. await _db.saveCachedPage(CachedPagesCompanion.insert( url: siteUrl, source: source, title: Value(page.title), etag: Value(etag), lastModified: Value(lastModified), )); } } } catch (_) { // Nothing to tell the reader: the site's "last checked" date says it. } await _db.touchSubscription(siteUrl, DateTime.now()); } /// Reads a site's Atom feed (spec 6.2). Entries are keyed by address, so a /// post listed by both the page and the feed is stored once. Future _checkFeed(String siteUrl, String feedUrl, {bool read = false}) async { final result = await _fetch.send(FetchFeed(url: feedUrl)); if (result case FetchFeedOk(:final entries)) { await _db.addEntries(siteUrl, entries, read: read); } } }