feat: check every subscription in the background and add the posts page
This commit is contained in:
parent
953ae798eb
commit
1b426ccd8b
15 changed files with 688 additions and 58 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'))]),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
104
app/lib/application/pages/posts_page.dart
Normal file
104
app/lib/application/pages/posts_page.dart
Normal 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')),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
''';
|
||||
|
|
|
|||
|
|
@ -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
45
app/test/fake_fetch.dart
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue