2026-10-11 13:20:00 +03:00
|
|
|
import 'package:actors/actors.dart';
|
|
|
|
|
import 'package:drift/drift.dart' show Value;
|
2026-10-11 13:12:48 +03:00
|
|
|
import 'package:signals/signals.dart';
|
|
|
|
|
|
|
|
|
|
import '../../database/database.dart';
|
|
|
|
|
import '../../mews/document.dart';
|
2026-10-11 13:20:00 +03:00
|
|
|
import '../actors/fetch_actor.dart';
|
2026-10-11 13:12:48 +03:00
|
|
|
|
|
|
|
|
/// 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 {
|
2026-10-11 13:20:00 +03:00
|
|
|
SubscriptionsController({
|
|
|
|
|
required MewsDatabase db,
|
|
|
|
|
required Actor<FetchCommand, FetchResult> fetch,
|
|
|
|
|
}) : _db = db,
|
|
|
|
|
_fetch = fetch;
|
2026-10-11 13:12:48 +03:00
|
|
|
|
|
|
|
|
final MewsDatabase _db;
|
2026-10-11 13:20:00 +03:00
|
|
|
final Actor<FetchCommand, FetchResult> _fetch;
|
2026-10-11 13:12:48 +03:00
|
|
|
|
|
|
|
|
final subscribedUrls = signal(<String>{});
|
|
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
/// 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;
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
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};
|
|
|
|
|
// 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<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;
|
|
|
|
|
}
|
2026-10-11 13:20:00 +03:00
|
|
|
|
|
|
|
|
/// 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);
|
|
|
|
|
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());
|
|
|
|
|
}
|
2026-10-11 13:12:48 +03:00
|
|
|
}
|