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}; // The posts already on the page are the site's back catalogue, not news, // so they are stored read and only later posts are flagged. await _db.addEntries(page.url, page.entries, 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); 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()); } }