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'; import '../internal_pages.dart'; /// What went wrong while browsing. The presentation layer maps these to /// user-facing messages; no raw codes cross into the UI. sealed class BrowseError { const BrowseError(); } class LoadFailed extends BrowseError { const LoadFailed(); } class BrokenPage extends BrowseError { const BrokenPage(this.source); /// The markup that failed to parse; "show page text" recovers its content. final String source; } /// Coordinates navigation: owns the signals the reader UI observes and /// dispatches fetch commands to the fetch actor. Local-first: a cached page is /// revalidated with the server but survives any network failure. class BrowserController { BrowserController({ required MewsDatabase db, required Actor fetch, InternalPages? internalPages, }) : _db = db, _fetch = fetch, _internalPages = internalPages; final MewsDatabase _db; final Actor _fetch; final InternalPages? _internalPages; /// The stack of visited pages; the reader is at [_stackIndex]. final List _stack = []; int _stackIndex = -1; final currentPage = signal(null); final loading = signal(false); final error = signal(null); /// Pages opened this session, so links can show the visited color. final visitedUrls = signal({}); /// The parsed pages next to the current one in the stack, ready to show /// while swiping between them. final backPage = signal(null); final forwardPage = signal(null); bool get canGoBack => _stackIndex > 0; bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex; /// Normalizes what the reader typed into an address: bare domains become /// https URLs; anything already carrying a scheme (mews:, https:, ...) is /// left alone. static String normalizeAddress(String input) { final trimmed = input.trim(); if (trimmed.isEmpty) return ''; if (_schemePattern.hasMatch(trimmed)) return trimmed; return 'https://$trimmed'; } static final _schemePattern = RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*:'); /// The wap. subdomain equivalent of [url], or null when the host already /// starts with it or the address is not a plain web URL. static String? wapUrlOf(String url) { final uri = Uri.tryParse(url); if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null; if (uri.scheme != 'http' && uri.scheme != 'https') return null; if (uri.host.startsWith('wap.')) return null; return uri.replace(host: 'wap.${uri.host}').toString(); } Future open(String input) async { final url = normalizeAddress(input); if (url.isEmpty) return; // Setting links change a value and stay on the current page instead of // joining the walk history. if (InternalPages.isSettingUrl(url)) { await _internalPages?.applySetting(url); await _loadCurrent(); return; } _stack.removeRange(_stackIndex + 1, _stack.length); _stack.add(url); _stackIndex++; _updateNeighbors(); await _loadCurrent(); // Mews sites commonly live on a wap. subdomain; try that before showing // an error when the address as typed does not work out. if (error.value != null && _stack[_stackIndex] == url) { final wap = wapUrlOf(url); if (wap != null) { final firstError = error.value; _stack[_stackIndex] = wap; _updateNeighbors(); await _loadCurrent(); // The address as typed is the closer match; keep its error when the // wap. attempt does not work either. if (error.value != null && firstError != null) { error.value = firstError; } } } } Future back() async { if (!canGoBack) return; _stackIndex--; // The neighbor was parsed for the swipe already; show it right away so // the transition lands on content instead of a blank page while // _loadCurrent revalidates it against the server. _showParsedNeighbor(backPage.value); _updateNeighbors(); await _loadCurrent(); } Future forward() async { if (!canGoForward) return; _stackIndex++; _showParsedNeighbor(forwardPage.value); _updateNeighbors(); await _loadCurrent(); } void _showParsedNeighbor(MewsPage? page) { if (page == null) return; currentPage.value = page; error.value = null; visitedUrls.value = {...visitedUrls.value, page.url}; } Future reload() => _loadCurrent(); Future submitForm(String action, String method, Map fields) async { loading.value = true; error.value = null; final result = await _fetch.send(SubmitForm(url: action, method: method, fields: fields)); loading.value = false; switch (result) { case FetchFormOk(:final page): _stack.removeRange(_stackIndex + 1, _stack.length); _stack.add(page.url); _stackIndex++; _updateNeighbors(); _show(page); case _: error.value = const LoadFailed(); } } /// Reopens the most recent page from the previous session, if any. Future restoreLastSession() async { final url = await _db.lastVisitedUrl(); if (url == null) return; _stack.add(url); _stackIndex = 0; _updateNeighbors(); await _loadCurrent(); } /// Parses the stack's neighboring pages in the fetch actor so swipes can /// show the real content. Results arriving after another navigation are /// dropped, so rapid navigation never shows a stale neighbor. void _updateNeighbors() { _loadNeighbor(_stackIndex - 1, backPage); _loadNeighbor(_stackIndex + 1, forwardPage); } Future _loadNeighbor(int index, Signal target) async { final expectedIndex = _stackIndex; if (index < 0 || index >= _stack.length) { target.value = null; return; } final url = _stack[index]; if (_internalPages?.handles(url) ?? false) { target.value = await _internalPages!.pageFor(url); return; } final cached = await _db.cachedPage(url); if (_stackIndex != expectedIndex) return; if (cached == null) { target.value = null; return; } final result = await _fetch.send(ParseSource(url: url, source: cached.source)); if (_stackIndex != expectedIndex) return; target.value = result is FetchPageOk ? result.page : null; } Future _loadCurrent() async { if (_stackIndex < 0) return; final url = _stack[_stackIndex]; loading.value = true; error.value = null; // Internal pages are built from the local database, never fetched or // cached; they join the stack like any other page. if (_internalPages?.handles(url) ?? false) { final page = await _internalPages!.pageFor(url); loading.value = false; if (page != null) { currentPage.value = page; error.value = null; visitedUrls.value = {...visitedUrls.value, url}; } else { currentPage.value = null; error.value = const LoadFailed(); } return; } final cached = await _db.cachedPage(url); final result = await _fetch.send(FetchPage( url: url, etag: cached?.etag, lastModified: cached?.lastModified, cachedSource: cached?.source, )); loading.value = false; switch (result) { case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified): currentPage.value = page; error.value = null; visitedUrls.value = {...visitedUrls.value, url}; if (!notModified) { await _db.saveCachedPage(CachedPagesCompanion.insert( url: url, source: source, title: Value(page.title), etag: Value(etag), lastModified: Value(lastModified), )); await _db.recordVisit(url, page.title); } case FetchParseError(:final source): currentPage.value = null; error.value = BrokenPage(source); case _: currentPage.value = null; error.value = const LoadFailed(); } } void _show(MewsPage page) { currentPage.value = page; _db.recordVisit(page.url, page.title); } }