Settings, admin, the directory and OPML open in the page (#5)

Each is named in the toolbar's menu rather than hidden behind one button
that says "page": reaching Settings here should be no harder than in a
browser. Admin appears only for an admin, because the server sends that
page to admins alone and a link for anyone else leads to a refusal.

Settings, the directory, Popular and OPML are dialogs the page opens by
name rather than routes of their own, so they are reached by calling them
once it has loaded -- prefsModal(), opmlModal(), selectFeed(':directory').
Asking a moment after the load avoids racing the page wiring them up. The
admin page is a real route and just loads. Asking for the screen already
showing runs the script without reloading, or the page would reload to sit
exactly where it already was.

This is a decision as much as a change, and worth writing down: the goal
was never a native app with no web view in it. These screens are
form-heavy, rarely opened, admin-gated in places, and they work. Rewriting
them would have been the largest part of the job for the smallest return.

The test opens the menu, chooses Settings, and looks for the page's own
dialog, so a menu that opens the page but not the thing asked for fails
rather than passing. It also found that the menu had no accessibility
label, which it should have had anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 09:38:26 -04:00
parent 292941524e
commit 33c3790fe3
6 changed files with 111 additions and 8 deletions

View File

@@ -203,9 +203,62 @@ final class WebViewController: UIViewController, WKNavigationDelegate {
}
}
// MARK: - the screens that stay in the page
/// The parts of ipx that were never going to be rewritten. They are form-heavy, rarely
/// opened, admin-gated in places, and they already work; rewriting them would be the largest
/// part of the job for the smallest return. Settings, the directory and OPML are dialogs the
/// page opens by name rather than routes, so they are reached by calling them.
enum Screen {
case settings, directory, popular, opml, admin, page
var path: String { self == .admin ? "/admin" : "/" }
/// Run once the page has loaded. Nil for a screen that is a route of its own.
var script: String? {
switch self {
case .settings: return "prefsModal()"
case .opml: return "opmlModal()"
case .directory: return "selectFeed(':directory')"
case .popular: return "selectFeed(':popular')"
case .admin, .page: return nil
}
}
}
private var pending: Screen?
func open(_ screen: Screen) {
pending = screen
guard let url = ServerSettings.url(screen.path) else { return showSetup(animated: true) }
// Already on the right page: just run the script, or the whole thing reloads to sit
// exactly where it already was.
if webView.url?.path == url.path, webView.url != nil {
runPending()
} else {
cookies.sync { [weak self] in
DispatchQueue.main.async { self?.webView.load(URLRequest(url: url)) }
}
}
}
private func runPending() {
guard let screen = pending else { return }
pending = nil
guard let script = screen.script else { return }
// The page wires its dialogs at load; asking a moment later avoids racing that.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.webView.evaluateJavaScript(script) { _, error in
if let error { NSLog("ipx: %@ -> %@", script, error.localizedDescription) }
}
}
}
// MARK: - navigation
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
runPending()
// The bridge announces itself on load. A server whose page predates it never will, and the
// app would otherwise look identical while playing nothing in the background.
checkedBridge = false