feat: check every subscription in the background and add the posts page

This commit is contained in:
randogoth 2026-10-11 13:20:00 +03:00
parent 953ae798eb
commit 1b426ccd8b
15 changed files with 688 additions and 58 deletions

View file

@ -65,6 +65,8 @@ The v1 client presents one reader screen:
- An address field pinned at the top; it hides when the reader scrolls down and returns when they scroll up or reach the top. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed.
- A swipe right goes back and a swipe left goes forward through the history stack. The whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text. Touch devices navigate this way; pointer platforms get the address bar's buttons as well.
- The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:bookmarks`, `mews:subscriptions`, `mews:history` and `mews:settings` each carry a strip linking to the others. Bare `mews:` is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription.
- The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:posts`, `mews:bookmarks`, `mews:subscriptions`, `mews:history` and `mews:settings` each carry a strip linking to the others. Bare `mews:` is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription.
- `mews:posts` is every post from every subscribed site in one list. The reader can order it by date, by site, or unread first, and limit it to unread posts; those choices ride in the address as a query string, so going back undoes one and a swipe preview shows the page the reader will land on. Unread posts are shown bold, and opening a post marks it read.
- Subscribed sites are checked once in the background at launch, and whenever the reader asks. Sites are checked one after another rather than at once, and a site that cannot be reached keeps the posts already held and is still recorded as checked, so one site being down never stops the rest.
- A change is a link too: following `mews:do/<verb>` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network.
- Fonts are bundled, and v1 ships Atkinson Hyperlegible. Adding a font is one TTF in `app/assets/fonts/`, one `fonts` entry in `app/pubspec.yaml`, one option on the settings page, and one label in `app/locales/en-US/main.ftl`.

View file

@ -277,6 +277,9 @@ class BrowserController {
currentPage.value = page;
error.value = null;
visitedUrls.value = {...visitedUrls.value, url};
// Opening a post is reading it. Nothing happens when the address is
// not one, so this needs no knowledge of the reader's subscriptions.
await _db.markEntryRead(url);
if (!notModified) {
await _db.saveCachedPage(CachedPagesCompanion.insert(
url: url,

View file

@ -1,18 +1,34 @@
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(this._db);
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();
@ -47,4 +63,85 @@ class SubscriptionsController {
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);
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());
}
}

View file

@ -1,5 +1,6 @@
import 'controllers/reader_settings_controller.dart'
show ReaderSettingsController;
import 'controllers/subscriptions_controller.dart';
import 'pages/mews_urls.dart';
/// Applies the change an action address encodes, e.g. mews:do/set-theme.
@ -10,10 +11,14 @@ import 'pages/mews_urls.dart';
/// only links carrying these addresses are the ones the client builds, so a
/// miss is a bug rather than something to explain to the reader.
class InternalActions {
InternalActions({required ReaderSettingsController settings})
: _settings = settings;
InternalActions({
required ReaderSettingsController settings,
required SubscriptionsController subscriptions,
}) : _settings = settings,
_subscriptions = subscriptions;
final ReaderSettingsController _settings;
final SubscriptionsController _subscriptions;
Future<bool> apply(String url) async {
final verb = MewsUrls.verbOf(url);
@ -24,6 +29,10 @@ class InternalActions {
'set-size' => _setSize(args['step']),
'set-theme' => _settings.setThemeMode(args['value'] ?? ''),
'set-colors' => _settings.setVariant(args['value'] ?? ''),
'mark-read' => _markRead(args, read: true),
'mark-unread' => _markRead(args, read: false),
'refresh-subscriptions' => _refreshAll(),
'refresh-subscription' => _refreshOne(args['url']),
_ => false,
};
}
@ -33,4 +42,28 @@ class InternalActions {
'down' => _settings.decreaseSize(),
_ => Future.value(false),
};
Future<bool> _markRead(Map<String, String> args, {required bool read}) async {
final url = args['url'];
if (url == null || url.isEmpty) return false;
final site = args['site'];
if (read) {
await _subscriptions.markRead(url, siteUrl: site);
} else {
await _subscriptions.markUnread(url, siteUrl: site);
}
return true;
}
/// The check redraws the page itself as it runs, so the caller need not.
Future<bool> _refreshAll() async {
await _subscriptions.refreshAll();
return false;
}
Future<bool> _refreshOne(String? siteUrl) async {
if (siteUrl == null || siteUrl.isEmpty) return false;
await _subscriptions.refreshOne(siteUrl);
return false;
}
}

View file

@ -3,10 +3,12 @@ import '../l10n/strings.dart';
import '../mews/document.dart';
import 'controllers/reader_settings_controller.dart'
show ReaderSettingsController;
import 'controllers/subscriptions_controller.dart';
import 'pages/bookmarks_page.dart';
import 'pages/history_page.dart';
import 'pages/hub_page.dart';
import 'pages/mews_urls.dart';
import 'pages/posts_page.dart';
import 'pages/settings_page.dart';
import 'pages/subscriptions_page.dart';
@ -17,13 +19,16 @@ class InternalPages {
InternalPages({
required MewsDatabase db,
required ReaderSettingsController settings,
required SubscriptionsController subscriptions,
required Strings strings,
}) : _db = db,
_settings = settings,
_subscriptions = subscriptions,
_strings = strings;
final MewsDatabase _db;
final ReaderSettingsController _settings;
final SubscriptionsController _subscriptions;
final Strings _strings;
/// Whether [url] is one of the internal pages.
@ -34,7 +39,12 @@ class InternalPages {
// Internal addresses are opaque, so the page name is the path, not a host.
final uri = Uri.parse(MewsUrls.canonical(url));
return switch (uri.path) {
'hub' => buildHubPage(uri, db: _db, strings: _strings),
'hub' => buildHubPage(uri,
db: _db, subscriptions: _subscriptions, strings: _strings),
'posts' => buildPostsPage(uri,
db: _db,
strings: _strings,
refreshing: _subscriptions.refreshing.value),
'bookmarks' => buildBookmarksPage(uri, db: _db, strings: _strings),
'subscriptions' => buildSubscriptionsPage(uri, db: _db, strings: _strings),
'history' => buildHistoryPage(uri, db: _db, strings: _strings),

View file

@ -1,6 +1,7 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import '../controllers/subscriptions_controller.dart';
import 'mews_urls.dart';
import 'page_blocks.dart';
@ -10,11 +11,13 @@ import 'page_blocks.dart';
Future<MewsPage> buildHubPage(
Uri url, {
required MewsDatabase db,
required SubscriptionsController subscriptions,
required Strings strings,
}) async {
final bookmarks = await db.countBookmarks();
final sites = await db.countSubscriptions();
final visits = await db.countHistory();
final unread = await db.unreadCount();
return internalPage(
url: url,
@ -22,6 +25,14 @@ Future<MewsPage> buildHubPage(
body: [
HeadingBlock(1, [MewsText(strings.t('hub-title'))]),
itemList([
itemRow(
href: MewsUrls.posts,
label: strings.t('nav-posts'),
note: countText(strings, unread,
none: 'count-unread-none',
one: 'count-unread-one',
many: 'count-unread-many'),
),
itemRow(
href: MewsUrls.bookmarks,
label: strings.t('nav-bookmarks'),
@ -48,6 +59,14 @@ Future<MewsPage> buildHubPage(
),
itemRow(href: MewsUrls.settings, label: strings.t('nav-settings')),
]),
ParagraphBlock([
if (subscriptions.refreshing.value)
MewsEmphasis(EmphasisStyle.emphasis,
[MewsText(strings.t('posts-checking'))])
else
MewsLink(MewsUrls.action('refresh-subscriptions'),
[MewsText(strings.t('action-refresh'))]),
]),
],
);
}

View file

@ -13,6 +13,7 @@ abstract final class MewsUrls {
static const scheme = 'mews';
static const hub = 'mews:hub';
static const posts = 'mews:posts';
static const bookmarks = 'mews:bookmarks';
static const subscriptions = 'mews:subscriptions';
static const history = 'mews:history';

View file

@ -11,9 +11,14 @@ import 'mews_urls.dart';
/// constant here rather than a message.
const actionSeparator = ' ';
/// One choice in a group of mutually exclusive values.
/// A link with its label: a choice in a group, or an action on a row. For an
/// action [current] is always false.
typedef Choice = ({String href, String label, bool current});
/// An action link on a row.
Choice action(String href, String label) =>
(href: href, label: label, current: false);
/// One entry in the strip of internal pages.
typedef NavItem = ({String url, String labelId});
@ -21,6 +26,7 @@ typedef NavItem = ({String url, String labelId});
/// never shifts under the reader.
const navItems = <NavItem>[
(url: MewsUrls.hub, labelId: 'nav-hub'),
(url: MewsUrls.posts, labelId: 'nav-posts'),
(url: MewsUrls.bookmarks, labelId: 'nav-bookmarks'),
(url: MewsUrls.subscriptions, labelId: 'nav-subscriptions'),
(url: MewsUrls.history, labelId: 'nav-history'),
@ -61,18 +67,59 @@ MewsListItem itemRow({
required String label,
String? date,
String? note,
bool strong = false,
List<Choice> actions = const [],
}) {
final text = date == null ? label : '$date $label';
return MewsListItem([
ParagraphBlock([
MewsLink(href, [MewsText(date == null ? label : '$date $label')]),
MewsLink(href, [
if (strong)
MewsEmphasis(EmphasisStyle.strong, [MewsText(text)])
else
MewsText(text),
]),
if (note != null) ...[
const MewsLineBreak(),
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]),
],
if (actions.isNotEmpty) ...[
const MewsLineBreak(),
..._separated(
[for (final action in actions) MewsLink(action.href, [MewsText(action.label)])]),
],
]),
]);
}
/// Joins runs with the separator between them, never around them.
List<MewsInline> _separated(List<MewsInline> runs) {
final out = <MewsInline>[];
for (final run in runs) {
if (out.isNotEmpty) out.add(const MewsText(actionSeparator));
out.add(run);
}
return out;
}
/// A group of mutually exclusive values on one line, for a short group that
/// would waste a list. The chosen value is plain text, the rest are links.
List<MewsBlock> choiceLine({required String label, required List<Choice> options}) {
return [
ParagraphBlock([
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(label)]),
const MewsText(actionSeparator),
..._separated([
for (final option in options)
if (option.current)
MewsEmphasis(EmphasisStyle.strong, [MewsText(option.label)])
else
MewsLink(option.href, [MewsText(option.label)]),
]),
]),
];
}
/// A group of mutually exclusive values: the current one plain, the rest links.
ListBlock choiceRow(List<Choice> options) {
return ListBlock(

View file

@ -0,0 +1,104 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'mews_urls.dart';
import 'page_blocks.dart';
/// Every post from every subscribed site in one list (spec 6.1), newest first
/// unless the reader orders it otherwise. Unread posts stand out, and opening
/// one marks it read.
Future<MewsPage> buildPostsPage(
Uri url, {
required MewsDatabase db,
required Strings strings,
required bool refreshing,
}) async {
final sort = EntrySort.parse(url.queryParameters['sort']);
final unreadOnly = url.queryParameters['show'] == 'unread';
final entries =
await db.riverEntries(sort: sort, unreadOnly: unreadOnly);
final unread = await db.unreadCount();
final title = strings.t('posts-title');
return internalPage(
url: url,
title: title,
body: [
...chrome(title: title, current: url, strings: strings),
ParagraphBlock([
MewsEmphasis(EmphasisStyle.emphasis, [
MewsText(countText(strings, unread,
none: 'count-unread-none',
one: 'count-unread-one',
many: 'count-unread-many')),
]),
const MewsText(actionSeparator),
if (refreshing)
MewsEmphasis(EmphasisStyle.emphasis,
[MewsText(strings.t('posts-checking'))])
else
MewsLink(MewsUrls.action('refresh-subscriptions'),
[MewsText(strings.t('action-refresh'))]),
]),
...choiceLine(
label: strings.t('posts-sort-label'),
options: [
for (final option in const [
(EntrySort.date, 'posts-sort-date'),
(EntrySort.source, 'posts-sort-source'),
(EntrySort.unread, 'posts-sort-unread'),
])
(
href: MewsUrls.page(url, {'sort': option.$1.name}),
label: strings.t(option.$2),
current: option.$1 == sort,
),
],
),
...choiceLine(
label: strings.t('posts-show-label'),
options: [
(
href: MewsUrls.page(url, {'show': null}),
label: strings.t('posts-show-all'),
current: !unreadOnly,
),
(
href: MewsUrls.page(url, {'show': 'unread'}),
label: strings.t('posts-show-unread'),
current: unreadOnly,
),
],
),
const RuleBlock(),
if (entries.isEmpty)
ParagraphBlock([
MewsText(strings
.t(unreadOnly ? 'posts-none-unread' : 'posts-empty')),
])
else
itemList([
for (final entry in entries)
itemRow(
href: entry.url,
label: entry.title,
date: dayText(entry.date),
note: entry.siteName,
strong: entry.unread,
actions: [
if (entry.unread)
action(
MewsUrls.action('mark-read',
{'site': entry.siteUrl, 'url': entry.url}),
strings.t('posts-mark-read'))
else
action(
MewsUrls.action('mark-unread',
{'site': entry.siteUrl, 'url': entry.url}),
strings.t('posts-mark-unread')),
],
),
]),
],
);
}

View file

@ -286,6 +286,51 @@ class MewsDatabase extends _$MewsDatabase {
return statement.write(const FeedEntriesCompanion(readAt: Value(null)));
}
/// Every stored post with the site it came from, for the posts page.
Future<List<RiverEntry>> riverEntries({
EntrySort sort = EntrySort.date,
bool unreadOnly = false,
int limit = 200,
}) async {
final site = alias(subscriptions, 'site');
final query = select(feedEntries).join([
leftOuterJoin(site, site.url.equalsExp(feedEntries.siteUrl)),
]);
if (unreadOnly) query.where(feedEntries.readAt.isNull());
// A site without a title sorts under its address, so the order never
// depends on a null.
final sourceName = coalesce([site.title, feedEntries.siteUrl]);
query.orderBy(switch (sort) {
EntrySort.date => [
OrderingTerm.desc(feedEntries.date),
OrderingTerm.asc(feedEntries.url),
],
EntrySort.source => [
OrderingTerm.asc(sourceName),
OrderingTerm.desc(feedEntries.date),
],
EntrySort.unread => [
// Unread first, whichever way the database orders nulls.
OrderingTerm.desc(feedEntries.readAt.isNull()),
OrderingTerm.desc(feedEntries.date),
],
});
query.limit(limit);
return (await query.get()).map((row) {
final entry = row.readTable(feedEntries);
return RiverEntry(
siteUrl: entry.siteUrl,
siteTitle: row.readTableOrNull(site)?.title,
url: entry.url,
title: entry.title,
date: entry.date,
readAt: entry.readAt,
);
}).toList();
}
/// How many posts are unread, for one site or across all of them.
Future<int> unreadCount({String? siteUrl}) async {
final total = feedEntries.url.count();
@ -371,3 +416,43 @@ class MewsDatabase extends _$MewsDatabase {
Future<void> saveCachedPage(CachedPagesCompanion entry) =>
into(cachedPages).insert(entry, mode: InsertMode.insertOrReplace);
}
/// How the posts page is ordered.
enum EntrySort {
date,
source,
unread;
/// Reads the order from an address, falling back to the default rather than
/// refusing a value the reader may have typed.
static EntrySort parse(String? name) => switch (name) {
'source' => EntrySort.source,
'unread' => EntrySort.unread,
_ => EntrySort.date,
};
}
/// One post together with the site that published it.
class RiverEntry {
const RiverEntry({
required this.siteUrl,
required this.siteTitle,
required this.url,
required this.title,
required this.date,
required this.readAt,
});
final String siteUrl;
final String? siteTitle;
final String url;
final String title;
final DateTime date;
final DateTime? readAt;
bool get unread => readAt == null;
/// The site as the reader knows it: its title, or its address until one is
/// recorded.
String get siteName => siteTitle ?? siteUrl;
}

View file

@ -1,3 +1,5 @@
import 'dart:async' show unawaited;
import 'package:actors/actors.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
@ -27,16 +29,23 @@ class AppDependencies {
strings = strings ?? Strings() {
settings = ReaderSettingsController(db);
bookmarks = BookmarksController(db);
subscriptions = SubscriptionsController(db);
internalPages =
InternalPages(db: db, settings: settings, strings: this.strings);
internalActions = InternalActions(settings: settings);
subscriptions = SubscriptionsController(db: db, fetch: this.fetch);
internalPages = InternalPages(
db: db,
settings: settings,
subscriptions: subscriptions,
strings: this.strings);
internalActions =
InternalActions(settings: settings, subscriptions: subscriptions);
browser = BrowserController(
db: db,
fetch: this.fetch,
internalPages: internalPages,
internalActions: internalActions,
);
// A check running in the background redraws whichever of our pages is
// open, so a new post shows up without the reader navigating.
subscriptions.onChanged = browser.refreshInternalPage;
}
/// Localized strings, also used to title the internal pages.
@ -56,6 +65,9 @@ class AppDependencies {
await bookmarks.load();
await subscriptions.load();
await browser.restoreLastSession();
// Subscriptions are checked in the background: the reader never waits on
// the network to see their last page.
unawaited(subscriptions.refreshAll());
}
}

View file

@ -16,6 +16,7 @@ reader-unsubscribe = Stop subscribing
# The strip of the app's own pages, carried by each of them.
nav-hub = Mews
nav-posts = Posts
nav-bookmarks = Bookmarks
nav-subscriptions = Subscriptions
nav-history = History
@ -41,6 +42,27 @@ count-unread-none = No unread posts
count-unread-one = 1 unread post
count-unread-many = { $count } unread posts
## Posts
posts-title = Posts
posts-empty = Posts from the sites you subscribe to appear here.
posts-none-unread = You've read everything.
posts-checking = Checking for new posts.
posts-sort-label = Sort by
posts-sort-date = Date
posts-sort-source = Site
posts-sort-unread = Unread first
posts-show-label = Show
posts-show-all = All posts
posts-show-unread = Unread only
posts-mark-read = Mark as read
posts-mark-unread = Mark as unread
## Actions
# Link labels used on more than one page.
action-refresh = Check for new posts
## Library pages
# Internal pages rendered like ordinary Mews pages.

View file

@ -1,13 +1,11 @@
import 'dart:io' show File;
import 'package:actors/actors.dart';
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderParagraph;
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/actors/fetch_actor.dart';
import 'package:mews/application/controllers/browser_controller.dart'
show BrokenPage, LoadFailed;
import 'package:mews/database/database.dart';
@ -19,6 +17,8 @@ import 'package:mews/presentation/app.dart';
import 'package:mews/presentation/reader/address_bar.dart';
import 'package:mews/presentation/reader/swipe_navigator.dart';
import 'fake_fetch.dart';
const pageSource = '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
@ -42,42 +42,6 @@ const pageSource = '''
</html>
''';
/// Serves pages from an in-memory map without network or isolates, so
/// navigation tests are deterministic. Cached pages parse back from their
/// cached source, like the real actor does for revalidation.
class _FakeFetch implements Actor<FetchCommand, FetchResult> {
_FakeFetch(this.sources);
final Map<String, String> sources;
@override
Future<FetchResult> send(FetchCommand message) async {
return switch (message) {
ParseSource(:final url, :final source) => _parse(url, source),
FetchPage(:final url, :final cachedSource) => cachedSource != null
? _parse(url, cachedSource)
: sources[url] != null
? _parse(url, sources[url]!)
: const FetchNetworkError(),
_ => const FetchNetworkError(),
};
}
FetchResult _parse(String url, String source) {
try {
return FetchPageOk(page: parseMewsPage(source: source, url: url), source: source);
} on MewsParseException catch (e) {
return FetchParseError(e.message, source);
}
}
@override
Future<void> close() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
String _page(String title, String body) => '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
@ -232,7 +196,7 @@ void main() {
testWidgets('a fetched page cannot run an action link', (tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
fetch: FakeFetch({
'https://example.com/evil.html': _page('Evil',
'<h1>Evil</h1><p><a href="mews:do/set-theme?value=dark">Go</a></p>'),
}),
@ -365,7 +329,7 @@ void main() {
};
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch(sources),
fetch: FakeFetch(sources),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
@ -402,7 +366,7 @@ void main() {
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
fetch: FakeFetch({
'https://wap.example.com/':
_page('Wap site', '<h1>Wap site</h1><p>Content lives here.</p>'),
}),
@ -427,7 +391,7 @@ void main() {
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
fetch: FakeFetch({
'https://example.com/a.html':
_page('Page A', '<h1>Page A</h1><p>The first page.</p>'),
'https://example.com/b.html':
@ -485,7 +449,7 @@ void main() {
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
fetch: FakeFetch({
'https://example.com/a.html':
_page('Page A', '<h1>Page A</h1><p>The first pages content.</p>'),
'https://example.com/b.html':
@ -520,6 +484,85 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
testWidgets('the posts page lists, orders and filters posts',
(tester) async {
const siteA = 'https://a.example/';
const siteB = 'https://b.example/';
final sources = {
siteA: _entriesPage('Alpha', const []),
siteB: _entriesPage('Beta', const []),
};
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: FakeFetch(sources),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
await stubDeps.subscriptions.load();
await stubDeps.subscriptions.toggle(parseSource(sources[siteA]!, siteA));
await stubDeps.subscriptions.toggle(parseSource(sources[siteB]!, siteB));
// Both sites publish after the reader subscribed, so both posts are new.
sources[siteA] = _entriesPage('Alpha', const [
('2026-10-10', 'newer.html', 'A post from Alpha'),
]);
sources[siteB] = _entriesPage('Beta', const [
('2026-10-11', 'newest.html', 'A post from Beta'),
]);
await stubDeps.subscriptions.refreshAll();
await stubDeps.browser.open(MewsUrls.posts);
await tester.pumpAndSettle();
expect(find.textContaining('2 unread posts'), findsOneWidget);
expect(find.textContaining('A post from Alpha'), findsOneWidget);
expect(find.textContaining('A post from Beta'), findsOneWidget);
// Newest first by default: Beta's post is dated a day later.
Future<List<String>> postTitles() async {
final titles = <String>[];
for (final element in find.byType(RichText).evaluate()) {
final text =
(element.renderObject as RenderParagraph).text.toPlainText();
if (text.contains('A post from')) {
titles.add(text.contains('Beta') ? 'Beta' : 'Alpha');
}
}
return titles;
}
expect(await postTitles(), ['Beta', 'Alpha']);
// Ordering by site puts Alpha first, and the address carries the choice.
await tapRun(tester, 'Site');
expect(stubDeps.browser.currentPage.value?.url, 'mews:posts?sort=source');
expect(await postTitles(), ['Alpha', 'Beta']);
// Back undoes the ordering, because it is part of the address.
await stubDeps.browser.back();
await tester.pumpAndSettle();
expect(stubDeps.browser.currentPage.value?.url, MewsUrls.posts);
expect(await postTitles(), ['Beta', 'Alpha']);
// Opening a post reads it, and the unread filter then hides it.
sources['${siteB}newest.html'] =
_page('A post from Beta', '<h1>A post from Beta</h1>');
await tapRun(tester, 'A post from Beta');
expect(stubDeps.browser.currentPage.value?.url, '${siteB}newest.html');
await stubDeps.browser.open('mews:posts?show=unread');
await tester.pumpAndSettle();
expect(find.textContaining('1 unread post'), findsOneWidget);
expect(await postTitles(), ['Alpha']);
// Marking it unread again brings it back.
await stubDeps.browser.open('mews:posts');
await tester.pumpAndSettle();
await tapRun(tester, 'Mark as unread');
expect(find.textContaining('2 unread posts'), findsOneWidget);
});
testWidgets('new posts are counted on the subscriptions page',
(tester) async {
await pumpApp(tester);
@ -569,3 +612,18 @@ Future<void> _pumpUntil(WidgetTester tester, bool Function() condition,
}
await tester.pumpAndSettle();
}
/// A Mews page whose list holds dated links (spec 6.1), one per entry given as
/// (date, file name, title).
String _entriesPage(String title, List<(String, String, String)> entries) => '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>$title</title><meta name="mews-profile" content="0.1" /></head>
<body>
<h1>$title</h1>
<ul>
${entries.map((e) => ' <li><a href="${e.$2}">${e.$1} ${e.$3}</a></li>').join('\n')}
</ul>
</body>
</html>
''';

View file

@ -8,6 +8,8 @@ import 'package:mews/application/controllers/subscriptions_controller.dart';
import 'package:mews/database/database.dart';
import 'package:mews/mews/document.dart';
import 'fake_fetch.dart';
void main() {
late MewsDatabase db;
@ -61,7 +63,7 @@ void main() {
});
test('subscribing takes on the back catalogue without flagging it', () async {
final subscriptions = SubscriptionsController(db);
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
await subscriptions.load();
final firstPost = MewsEntry(
@ -97,7 +99,7 @@ void main() {
});
test('unread posts survive a restart', () async {
final subscriptions = SubscriptionsController(db);
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
await subscriptions.load();
await subscriptions.toggle(_page('https://example.com/', const []));
await subscriptions.refreshSite(_page('https://example.com/', [
@ -109,14 +111,14 @@ void main() {
// A fresh controller over the same database sees the same unread post,
// which the old in-memory count could not do.
final restarted = SubscriptionsController(db);
final restarted = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
await restarted.load();
expect(restarted.isSubscribed('https://example.com/'), isTrue);
expect(await db.unreadCount(), 1);
});
test('leaving a site takes its posts with it', () async {
final subscriptions = SubscriptionsController(db);
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
await subscriptions.load();
await subscriptions.toggle(_page('https://example.com/', const []));
await subscriptions.refreshSite(_page('https://example.com/', [
@ -132,6 +134,79 @@ void main() {
expect(await db.unreadCount(), 0);
});
test('a check finds new posts on every subscribed site', () async {
const siteA = 'https://a.example/';
const siteB = 'https://b.example/';
final sources = {
siteA: _entriesPage('Site A', const []),
siteB: _entriesPage('Site B', const []),
};
final subscriptions =
SubscriptionsController(db: db, fetch: FakeFetch(sources));
await subscriptions.load();
await subscriptions.toggle(_page(siteA, const []));
await subscriptions.toggle(_page(siteB, const []));
// Both sites publish something after the reader subscribed.
sources[siteA] = _entriesPage('Site A', const [
('2026-10-09', 'a-one.html', 'A newer post'),
]);
sources[siteB] = _entriesPage('Site B', const [
('2026-10-10', 'b-one.html', 'Another post'),
('2026-10-09', 'b-two.html', 'And one more'),
]);
await subscriptions.refreshAll();
expect(await db.unreadCount(), 3);
expect(await db.unreadCount(siteUrl: siteA), 1);
expect(subscriptions.refreshing.value, isFalse);
// Every site was recorded as checked.
for (final site in await db.allSubscriptions()) {
expect(site.lastCheckedAt, isNotNull);
}
});
test('one unreachable site does not stop the rest of the check', () async {
const reachable = 'https://a.example/';
const unreachable = 'https://down.example/';
final subscriptions = SubscriptionsController(
db: db,
// Only the first site answers; the other has no source at all.
fetch: FakeFetch({
reachable: _entriesPage('Site A', const [
('2026-10-09', 'a-one.html', 'A newer post'),
]),
}),
);
await subscriptions.load();
await subscriptions.toggle(_page(unreachable, const []));
await subscriptions.toggle(_page(reachable, const []));
await subscriptions.refreshAll();
expect(await db.unreadCount(siteUrl: reachable), 1);
expect(subscriptions.refreshing.value, isFalse);
// The site that could not be reached is still marked as checked.
final down = (await db.allSubscriptions())
.firstWhere((s) => s.url == unreachable);
expect(down.lastCheckedAt, isNotNull);
});
test('a check already running is not started again', () async {
final subscriptions = SubscriptionsController(
db: db,
fetch: FakeFetch({'https://a.example/': _entriesPage('Site A', const [])}),
);
await subscriptions.load();
await subscriptions.toggle(_page('https://a.example/', const []));
var redraws = 0;
subscriptions.onChanged = () => redraws++;
await Future.wait([subscriptions.refreshAll(), subscriptions.refreshAll()]);
// One pass: it starts and finishes once, so two redraws, not four.
expect(redraws, 2);
});
test('caches pages for offline reopening', () async {
await db.saveCachedPage(CachedPagesCompanion.insert(
url: 'https://example.com/page.html',
@ -161,3 +236,20 @@ MewsPage _page(String url, List<MewsEntry> entries) => MewsPage(
entries: entries,
specVersion: '0.1',
);
/// A Mews page whose list holds dated links (spec 6.1), one per entry given as
/// (date, file name, title).
String _entriesPage(
String title, List<(String, String, String)> entries) =>
'''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>$title</title><meta name="mews-profile" content="0.1" /></head>
<body>
<h1>$title</h1>
<ul>
${entries.map((e) => ' <li><a href="${e.$2}">${e.$1} ${e.$3}</a></li>').join('\n')}
</ul>
</body>
</html>
''';

45
app/test/fake_fetch.dart Normal file
View file

@ -0,0 +1,45 @@
import 'package:actors/actors.dart';
import 'package:mews/application/actors/fetch_actor.dart';
import 'package:mews/mews/parser.dart';
/// Serves pages from an in-memory map without network or isolates, so
/// navigation tests are deterministic. [sources] wins over the cached source
/// a caller offers, the way a server answering 200 does, so a test can change
/// what a site publishes between two fetches. An address with no source is
/// unreachable, and the cached copy stands in for it if there is one.
class FakeFetch implements Actor<FetchCommand, FetchResult> {
FakeFetch(this.sources);
final Map<String, String> sources;
@override
Future<FetchResult> send(FetchCommand message) async {
return switch (message) {
ParseSource(:final url, :final source) => _parse(url, source),
FetchPage(:final url, :final cachedSource) => switch ((
sources[url],
cachedSource,
)) {
(final String fresh, _) => _parse(url, fresh),
(null, final String cached) => _parse(url, cached),
_ => const FetchNetworkError(),
},
_ => const FetchNetworkError(),
};
}
FetchResult _parse(String url, String source) {
try {
return FetchPageOk(page: parseMewsPage(source: source, url: url), source: source);
} on MewsParseException catch (e) {
return FetchParseError(e.message, source);
}
}
@override
Future<void> close() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}