the-mews/app/lib/application/controllers/subscriptions_controller.dart

170 lines
6 KiB
Dart
Raw Normal View History

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<FetchCommand, FetchResult> fetch,
}) : _db = db,
_fetch = fetch;
final MewsDatabase _db;
final Actor<FetchCommand, FetchResult> _fetch;
final subscribedUrls = signal(<String>{});
/// 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<void> load() async {
final rows = await _db.allSubscriptions();
subscribedUrls.value = rows.map((row) => row.url).toSet();
}
bool isSubscribed(String url) => subscribedUrls.value.contains(url);
Future<void> 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<void> 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<List<MewsEntry>> 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<void> 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<void> 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<void> markRead(String url, {String? siteUrl}) async {
await _db.markEntryRead(url, siteUrl: siteUrl);
onChanged?.call();
}
Future<void> 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<void> _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<void> _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);
}
}
}