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

@ -5,7 +5,9 @@ import 'package:signals/signals.dart';
import '../../database/database.dart';
import '../../mews/document.dart';
import '../actors/fetch_actor.dart';
import '../internal_actions.dart';
import '../internal_pages.dart';
import '../pages/mews_urls.dart';
/// What went wrong while browsing. The presentation layer maps these to
/// user-facing messages; no raw codes cross into the UI.
@ -32,13 +34,16 @@ class BrowserController {
required MewsDatabase db,
required Actor<FetchCommand, FetchResult> fetch,
InternalPages? internalPages,
InternalActions? internalActions,
}) : _db = db,
_fetch = fetch,
_internalPages = internalPages;
_internalPages = internalPages,
_internalActions = internalActions;
final MewsDatabase _db;
final Actor<FetchCommand, FetchResult> _fetch;
final InternalPages? _internalPages;
final InternalActions? _internalActions;
/// The stack of visited pages; the reader is at [_stackIndex].
final List<String> _stack = [];
@ -82,14 +87,18 @@ class BrowserController {
}
Future<void> open(String input) async {
final url = normalizeAddress(input);
final url = MewsUrls.canonical(normalizeAddress(input));
if (url.isEmpty) return;
// Setting links change a value and stay on the current page instead of
// Action links change something and stay on the current page instead of
// joining the walk history.
if (InternalPages.isSettingUrl(url)) {
await _internalPages?.applySetting(url);
await _loadCurrent();
if (MewsUrls.isAction(url)) {
// Only the client's own pages may change the reader's data. A fetched
// page linking to mews:do/... must not act on their behalf (spec 8.1).
if (!_onInternalPage) return;
if (await (_internalActions?.apply(url) ?? Future.value(false))) {
await _loadCurrent();
}
return;
}
@ -145,10 +154,39 @@ class BrowserController {
Future<void> reload() => _loadCurrent();
/// Whether the reader is standing on one of the client's own pages.
bool get _onInternalPage =>
_stackIndex >= 0 && MewsUrls.isInternal(_stack[_stackIndex]);
/// Rebuilds the open page when it is one of ours, so a change made elsewhere
/// shows up without the reader navigating.
Future<void> refreshInternalPage() async {
if (!_onInternalPage) return;
await _loadCurrent();
}
Future<void> submitForm(String action, String method, Map<String, String> fields) async {
// A form on one of our own pages is a navigation or an action, never a
// network request; the fetch client cannot speak the mews scheme.
if (MewsUrls.isInternal(action)) {
final base = Uri.parse(action);
final verb = MewsUrls.verbOf(action);
return open(verb == null
? MewsUrls.page(base, fields)
: MewsUrls.action(verb, {...base.queryParameters, ...fields}));
}
loading.value = true;
error.value = null;
final result = await _fetch.send(SubmitForm(url: action, method: method, fields: fields));
final FetchResult result;
try {
result = await _fetch.send(SubmitForm(url: action, method: method, fields: fields));
} catch (_) {
// A submit that throws out of the actor is still just a failed load.
loading.value = false;
error.value = const LoadFailed();
return;
}
loading.value = false;
switch (result) {
case FetchFormOk(:final page):
@ -162,10 +200,10 @@ class BrowserController {
}
}
/// Reopens the most recent page from the previous session, if any.
/// Reopens the most recent page from the previous session, or the hub when
/// there is nothing to return to.
Future<void> restoreLastSession() async {
final url = await _db.lastVisitedUrl();
if (url == null) return;
final url = await _db.lastVisitedUrl() ?? MewsUrls.hub;
_stack.add(url);
_stackIndex = 0;
_updateNeighbors();
@ -216,7 +254,8 @@ class BrowserController {
if (page != null) {
currentPage.value = page;
error.value = null;
visitedUrls.value = {...visitedUrls.value, url};
// Our own addresses stay out of visitedUrls: a sort or filter link
// would otherwise take the visited color after one use.
} else {
currentPage.value = null;
error.value = const LoadFailed();