feat: add a hub for the client's own pages and gate mews:do action links

This commit is contained in:
randogoth 2026-10-11 13:05:07 +03:00
parent df705d831f
commit b311b9f7dd
21 changed files with 966 additions and 404 deletions

View file

@ -0,0 +1,32 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'page_blocks.dart';
Future<MewsPage> buildBookmarksPage(
Uri url, {
required MewsDatabase db,
required Strings strings,
}) async {
final bookmarks = await db.bookmarksByDate();
final title = strings.t('bookmarks-title');
return internalPage(
url: url,
title: title,
body: [
...chrome(title: title, current: url, strings: strings),
if (bookmarks.isEmpty)
ParagraphBlock([MewsText(strings.t('bookmarks-empty'))])
else
itemList([
for (final bookmark in bookmarks)
itemRow(
href: bookmark.url,
label: bookmark.title ?? bookmark.url,
date: dayText(bookmark.createdAt),
),
]),
],
);
}

View file

@ -0,0 +1,32 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'page_blocks.dart';
Future<MewsPage> buildHistoryPage(
Uri url, {
required MewsDatabase db,
required Strings strings,
}) async {
final visits = await db.recentVisits();
final title = strings.t('history-title');
return internalPage(
url: url,
title: title,
body: [
...chrome(title: title, current: url, strings: strings),
if (visits.isEmpty)
ParagraphBlock([MewsText(strings.t('history-empty'))])
else
itemList([
for (final visit in visits)
itemRow(
href: visit.url,
label: visit.title ?? visit.url,
date: dayTimeText(visit.visitedAt),
),
]),
],
);
}

View file

@ -0,0 +1,53 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'mews_urls.dart';
import 'page_blocks.dart';
/// The hub names every page the client provides and says what each one holds,
/// so the reader has somewhere to start. Its menu is the navigation, so it
/// carries no strip of its own.
Future<MewsPage> buildHubPage(
Uri url, {
required MewsDatabase db,
required Strings strings,
}) async {
final bookmarks = await db.countBookmarks();
final sites = await db.countSubscriptions();
final visits = await db.countHistory();
return internalPage(
url: url,
title: strings.t('hub-title'),
body: [
HeadingBlock(1, [MewsText(strings.t('hub-title'))]),
itemList([
itemRow(
href: MewsUrls.bookmarks,
label: strings.t('nav-bookmarks'),
note: countText(strings, bookmarks,
none: 'count-bookmarks-none',
one: 'count-bookmarks-one',
many: 'count-bookmarks-many'),
),
itemRow(
href: MewsUrls.subscriptions,
label: strings.t('nav-subscriptions'),
note: countText(strings, sites,
none: 'count-sites-none',
one: 'count-sites-one',
many: 'count-sites-many'),
),
itemRow(
href: MewsUrls.history,
label: strings.t('nav-history'),
note: countText(strings, visits,
none: 'count-pages-none',
one: 'count-pages-one',
many: 'count-pages-many'),
),
itemRow(href: MewsUrls.settings, label: strings.t('nav-settings')),
]),
],
);
}

View file

@ -0,0 +1,74 @@
/// The addresses of the client's own pages, under the `mews:` scheme.
/// One spelling of the scheme, shared by the navigator and the page builders.
library;
/// Internal addresses come in two shapes. A page address is a navigation and
/// joins the walk history; an action address under `do/` changes something and
/// leaves the reader where they are.
///
/// View state (sort, search, tag filter) rides in the query string rather than
/// in a signal, because swipe previews are built from the address alone and
/// going back should undo a sort.
abstract final class MewsUrls {
static const scheme = 'mews';
static const hub = 'mews:hub';
static const bookmarks = 'mews:bookmarks';
static const subscriptions = 'mews:subscriptions';
static const history = 'mews:history';
static const settings = 'mews:settings';
/// Whether [url] belongs to the internal scheme.
static bool isInternal(String url) => Uri.tryParse(url)?.scheme == scheme;
/// Whether [url] changes something instead of going somewhere, e.g.
/// mews:do/set-theme?value=dark.
static bool isAction(String url) {
final uri = Uri.tryParse(url);
if (uri == null || uri.scheme != scheme) return false;
final segments = uri.pathSegments;
return segments.length == 2 && segments.first == 'do';
}
/// The verb of an action address, or null when [url] is not one.
static String? verbOf(String url) =>
isAction(url) ? Uri.parse(url).pathSegments[1] : null;
/// A single spelling for every internal address, so the walk history entry
/// and the built page's url always compare equal. Bare `mews:` is the hub.
static String canonical(String url) {
if (!isInternal(url)) return url;
final uri = Uri.parse(url);
if (uri.path.isEmpty) return hub;
return uri.removeFragment().toString();
}
/// An action address carrying [args] as its query string.
static String action(String verb, [Map<String, String> args = const {}]) =>
Uri(
scheme: scheme,
path: 'do/$verb',
queryParameters: args.isEmpty ? null : args,
).toString();
/// [base] with [changes] merged over its query string. An empty or null
/// value drops the parameter, so clearing a search box returns the whole
/// list. Merging rather than replacing keeps the other parameters, which
/// `Uri.replace(queryParameters:)` would discard.
static String page(Uri base, [Map<String, String?> changes = const {}]) {
final params = {...base.queryParameters};
for (final change in changes.entries) {
final value = change.value;
if (value == null || value.isEmpty) {
params.remove(change.key);
} else {
params[change.key] = value;
}
}
return Uri(
scheme: scheme,
path: base.path,
queryParameters: params.isEmpty ? null : params,
).toString();
}
}

View file

@ -0,0 +1,144 @@
/// The shared vocabulary every internal page is built from. Internal pages are
/// ordinary Mews documents, so a page has only headings, lists, paragraphs and
/// links to work with; these builders keep the result consistent.
library;
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'mews_urls.dart';
/// Separates independent labels on one line. A separator is layout, so it is a
/// constant here rather than a message.
const actionSeparator = ' ';
/// One choice in a group of mutually exclusive values.
typedef Choice = ({String href, String label, bool current});
/// One entry in the strip of internal pages.
typedef NavItem = ({String url, String labelId});
/// The strip carried by every internal page, in a fixed order so its position
/// never shifts under the reader.
const navItems = <NavItem>[
(url: MewsUrls.hub, labelId: 'nav-hub'),
(url: MewsUrls.bookmarks, labelId: 'nav-bookmarks'),
(url: MewsUrls.subscriptions, labelId: 'nav-subscriptions'),
(url: MewsUrls.history, labelId: 'nav-history'),
(url: MewsUrls.settings, labelId: 'nav-settings'),
];
/// The title and the navigation strip that open every internal page. The page
/// the reader is on is plain text, matching how a current value is marked.
List<MewsBlock> chrome({
required String title,
required Uri current,
required Strings strings,
}) {
final inline = <MewsInline>[];
for (final item in navItems) {
if (inline.isNotEmpty) inline.add(const MewsText(actionSeparator));
final label = strings.t(item.labelId);
// Compare paths so a filtered or sorted page still marks its own entry.
if (Uri.parse(item.url).path == current.path) {
inline.add(MewsEmphasis(EmphasisStyle.strong, [MewsText(label)]));
} else {
inline.add(MewsLink(item.url, [MewsText(label)]));
}
}
return [
HeadingBlock(1, [MewsText(title)]),
ParagraphBlock(inline),
const RuleBlock(),
];
}
/// One row in a list: the item, then an optional note on its own line. A date
/// goes inside the link, the way a Mews page writes its own dated links (spec
/// 6.1), so the whole row is one target. One paragraph per row keeps rows
/// close together, since each paragraph carries its own bottom padding.
MewsListItem itemRow({
required String href,
required String label,
String? date,
String? note,
}) {
return MewsListItem([
ParagraphBlock([
MewsLink(href, [MewsText(date == null ? label : '$date $label')]),
if (note != null) ...[
const MewsLineBreak(),
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]),
],
]),
]);
}
/// A group of mutually exclusive values: the current one plain, the rest links.
ListBlock choiceRow(List<Choice> options) {
return ListBlock(
ordered: false,
start: 1,
items: [
for (final option in options)
MewsListItem([
ParagraphBlock([
if (option.current)
MewsEmphasis(EmphasisStyle.strong, [MewsText(option.label)])
else
MewsLink(option.href, [MewsText(option.label)]),
]),
]),
],
);
}
/// A plain list of links, used for menus and library lists alike.
ListBlock itemList(List<MewsListItem> items) =>
ListBlock(ordered: false, start: 1, items: items);
/// Wraps blocks as an internal document. The marker makes the renderer treat
/// it as a Mews page rather than prepending the "not a Mews page" notice, and
/// no entries are offered so an internal page is never subscribable.
MewsPage internalPage({
required Uri url,
required String title,
required List<MewsBlock> body,
}) {
return MewsPage(
url: url.toString(),
specVersion: 'internal',
title: title,
body: body,
lang: 'en',
direction: TextDirection.ltr,
variant: MewsVariant.plain,
entries: const [],
);
}
/// Picks the message for [count]. Plurals are separate messages, so the
/// choice happens here and Fluent formats the number.
String countText(
Strings strings,
int count, {
required String none,
required String one,
required String many,
}) {
return switch (count) {
0 => strings.t(none),
1 => strings.t(one),
_ => strings.t(many, {'count': count}),
};
}
/// YYYY-MM-DD, the shape a Mews entry link uses (spec 6.1).
String dayText(DateTime date) =>
'${date.year.toString().padLeft(4, '0')}-${_two(date.month)}-${_two(date.day)}';
String clockText(DateTime date) => '${_two(date.hour)}:${_two(date.minute)}';
/// The day and the time of day, for history rows.
String dayTimeText(DateTime date) => '${dayText(date)} ${clockText(date)}';
String _two(int value) => value.toString().padLeft(2, '0');

View file

@ -0,0 +1,100 @@
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import '../controllers/reader_settings_controller.dart'
show ReaderSettingsController;
import 'mews_urls.dart';
import 'page_blocks.dart';
/// The bundled typefaces the reader can pick. Adding a font is one TTF in
/// assets/fonts, one entry in pubspec.yaml, one entry here and one label in
/// the locale file.
const _fonts = <({String family, String labelId})>[
(family: 'Atkinson Hyperlegible', labelId: 'settings-font-atkinson'),
];
/// Message ids for the values the controller accepts. Kept literal so every
/// id in the locale file can be found by searching for it.
const _themeLabels = {
'light': 'settings-theme-light',
'dark': 'settings-theme-dark',
'system': 'settings-theme-system',
};
const _variantLabels = {
'site': 'settings-variant-site',
'warm': 'settings-variant-warm',
'cool': 'settings-variant-cool',
'green': 'settings-variant-green',
'mono': 'settings-variant-mono',
};
Future<MewsPage> buildSettingsPage(
Uri url, {
required ReaderSettingsController settings,
required Strings strings,
}) async {
final title = strings.t('settings-page-title');
final scale = settings.fontScale.value;
return internalPage(
url: url,
title: title,
body: [
...chrome(title: title, current: url, strings: strings),
HeadingBlock(2, [MewsText(strings.t('settings-font-heading'))]),
choiceRow([
for (final font in _fonts)
(
href: MewsUrls.action('set-font', {'value': font.family}),
label: strings.t(font.labelId),
current: font.family == settings.font.value,
),
]),
// Text size steps between the controller's bounds, with the current
// value between the two steps.
HeadingBlock(2, [MewsText(strings.t('settings-size-heading'))]),
itemList([
if (scale > ReaderSettingsController.minScale)
MewsListItem([
ParagraphBlock([
MewsLink(MewsUrls.action('set-size', {'step': 'down'}),
[MewsText(strings.t('settings-size-decrease'))]),
]),
]),
MewsListItem([
ParagraphBlock([
MewsEmphasis(EmphasisStyle.strong, [
MewsText(strings.t('settings-size-value',
{'percent': (scale * 100).round()})),
]),
]),
]),
if (scale < ReaderSettingsController.maxScale)
MewsListItem([
ParagraphBlock([
MewsLink(MewsUrls.action('set-size', {'step': 'up'}),
[MewsText(strings.t('settings-size-increase'))]),
]),
]),
]),
HeadingBlock(2, [MewsText(strings.t('settings-theme-heading'))]),
choiceRow([
for (final mode in ReaderSettingsController.themeModes)
(
href: MewsUrls.action('set-theme', {'value': mode}),
label: strings.t(_themeLabels[mode]!),
current: mode == settings.themeMode.value,
),
]),
HeadingBlock(2, [MewsText(strings.t('settings-variant-heading'))]),
choiceRow([
for (final variant in ReaderSettingsController.variants)
(
href: MewsUrls.action('set-colors', {'value': variant}),
label: strings.t(_variantLabels[variant]!),
current: variant == settings.variant.value,
),
]),
],
);
}

View file

@ -0,0 +1,46 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import '../controllers/reader_settings_controller.dart' show LibraryController;
import 'page_blocks.dart';
Future<MewsPage> buildSubscriptionsPage(
Uri url, {
required MewsDatabase db,
required LibraryController library,
required Strings strings,
}) async {
final sites = await db.allSubscriptions();
final title = strings.t('subscriptions-title');
return internalPage(
url: url,
title: title,
body: [
...chrome(title: title, current: url, strings: strings),
if (sites.isEmpty)
ParagraphBlock([MewsText(strings.t('subscriptions-empty'))])
else
itemList([
for (final site in sites)
itemRow(
href: site.url,
label: site.title ?? site.url,
date: dayText(site.createdAt),
note: _unreadNote(library, strings, site.url),
),
]),
],
);
}
/// The unread count for a site, or null when it has nothing new, so the row
/// carries no empty line.
String? _unreadNote(LibraryController library, Strings strings, String siteUrl) {
final count = library.newEntries.value[siteUrl]?.length ?? 0;
if (count == 0) return null;
return countText(strings, count,
none: 'count-unread-none',
one: 'count-unread-one',
many: 'count-unread-many');
}