diff --git a/SPEC.md b/SPEC.md index 50f355e..c7ab56c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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/` 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`. diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index d7a0dce..3cec7f8 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -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, diff --git a/app/lib/application/controllers/subscriptions_controller.dart b/app/lib/application/controllers/subscriptions_controller.dart index 2af50d8..d1c43d9 100644 --- a/app/lib/application/controllers/subscriptions_controller.dart +++ b/app/lib/application/controllers/subscriptions_controller.dart @@ -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 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(); @@ -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 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()); + } } diff --git a/app/lib/application/internal_actions.dart b/app/lib/application/internal_actions.dart index 0127e71..7a453f0 100644 --- a/app/lib/application/internal_actions.dart +++ b/app/lib/application/internal_actions.dart @@ -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 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 _markRead(Map 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 _refreshAll() async { + await _subscriptions.refreshAll(); + return false; + } + + Future _refreshOne(String? siteUrl) async { + if (siteUrl == null || siteUrl.isEmpty) return false; + await _subscriptions.refreshOne(siteUrl); + return false; + } } diff --git a/app/lib/application/internal_pages.dart b/app/lib/application/internal_pages.dart index db07d10..3381b35 100644 --- a/app/lib/application/internal_pages.dart +++ b/app/lib/application/internal_pages.dart @@ -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), diff --git a/app/lib/application/pages/hub_page.dart b/app/lib/application/pages/hub_page.dart index fcc0092..a9509e0 100644 --- a/app/lib/application/pages/hub_page.dart +++ b/app/lib/application/pages/hub_page.dart @@ -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 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 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 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'))]), + ]), ], ); } diff --git a/app/lib/application/pages/mews_urls.dart b/app/lib/application/pages/mews_urls.dart index 2493773..357b779 100644 --- a/app/lib/application/pages/mews_urls.dart +++ b/app/lib/application/pages/mews_urls.dart @@ -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'; diff --git a/app/lib/application/pages/page_blocks.dart b/app/lib/application/pages/page_blocks.dart index 6b2e0af..a29dda6 100644 --- a/app/lib/application/pages/page_blocks.dart +++ b/app/lib/application/pages/page_blocks.dart @@ -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 = [ (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 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 _separated(List runs) { + final out = []; + 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 choiceLine({required String label, required List 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 options) { return ListBlock( diff --git a/app/lib/application/pages/posts_page.dart b/app/lib/application/pages/posts_page.dart new file mode 100644 index 0000000..32b5beb --- /dev/null +++ b/app/lib/application/pages/posts_page.dart @@ -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 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')), + ], + ), + ]), + ], + ); +} diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index 8f17683..54543e1 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -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> 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 unreadCount({String? siteUrl}) async { final total = feedEntries.url.count(); @@ -371,3 +416,43 @@ class MewsDatabase extends _$MewsDatabase { Future 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; +} diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index 6ab50a9..4a7a7ae 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -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()); } } diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index cbe93f7..696be98 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -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. diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 131a02e..92c944a 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -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 = ''' @@ -42,42 +42,6 @@ const pageSource = ''' '''; -/// 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 { - _FakeFetch(this.sources); - - final Map sources; - - @override - Future 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 close() async {} - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - String _page(String title, String body) => ''' @@ -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', '

Evil

Go

'), }), @@ -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', '

Wap site

Content lives here.

'), }), @@ -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', '

Page A

The first page.

'), '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', '

Page A

The first pages content.

'), '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> postTitles() async { + final titles = []; + 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', '

A post from Beta

'); + 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 _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) => ''' + + + $title + +

$title

+ + + +'''; diff --git a/app/test/controllers_test.dart b/app/test/controllers_test.dart index dd07ca2..7e6da65 100644 --- a/app/test/controllers_test.dart +++ b/app/test/controllers_test.dart @@ -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 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) => + ''' + + + $title + +

$title

+ + + +'''; diff --git a/app/test/fake_fetch.dart b/app/test/fake_fetch.dart new file mode 100644 index 0000000..ba7c1c6 --- /dev/null +++ b/app/test/fake_fetch.dart @@ -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 { + FakeFetch(this.sources); + + final Map sources; + + @override + Future 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 close() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}