From 33c3790fe3b7814febfe1a88a8ae359e779f0042 Mon Sep 17 00:00:00 2001 From: Ray Slakinski Date: Sun, 20 Sep 2026 09:38:26 -0400 Subject: [PATCH] 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 --- README.md | 2 +- ios/Sources/LibraryStore.swift | 6 ++++ ios/Sources/LibraryView.swift | 28 ++++++++++++--- ios/Sources/RootViewController.swift | 5 +-- ios/Sources/WebViewController.swift | 53 ++++++++++++++++++++++++++++ ios/UITests/PlaybackTests.swift | 25 +++++++++++++ 6 files changed, 111 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 896a42e..79a3544 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Set your team under Signing & Capabilities, pick your phone, and run. The projec ## What is native and what is the page The lists, the player and the car are native. Settings, the admin page, Directory, Popular and -OPML are ipx's own page, opened from the toolbar — they are form-heavy, rarely touched, and they +OPML are ipx's own page, each named in the toolbar's menu rather than hidden behind one button — they are form-heavy, rarely touched, and they already work. An item's show notes will stay HTML too, since they are feed-supplied and sanitized server-side and there is no good native renderer for them. diff --git a/ios/Sources/LibraryStore.swift b/ios/Sources/LibraryStore.swift index 6423d99..b8c9404 100644 --- a/ios/Sources/LibraryStore.swift +++ b/ios/Sources/LibraryStore.swift @@ -26,6 +26,10 @@ final class LibraryStore: ObservableObject { /// follows the person rather than the browser; the app should follow the same choice rather /// than the phone's, or it disagrees with the page it sits in front of. @Published private(set) var appearance: ColorScheme? + /// Whether the admin page is worth offering. The server sends it to admins only, so a link + /// for anyone else leads to a refusal. + @Published private(set) var isAdmin = false + @Published private(set) var signOutURL: String? @Published var place: Place = .all { didSet { if place != oldValue { reload() } } } @Published var filter: IPX.Filter = .all { didSet { if filter != oldValue { reload() } } } @@ -61,6 +65,8 @@ final class LibraryStore: ObservableObject { func refreshFeeds() async { appearance = .dark if let me = try? await api.me() { + isAdmin = me.admin + signOutURL = me.signOut switch me.mode { case "light": appearance = .light case "auto": appearance = nil // follow the system, as the page does for Auto diff --git a/ios/Sources/LibraryView.swift b/ios/Sources/LibraryView.swift index 72ed8d5..1a80dff 100644 --- a/ios/Sources/LibraryView.swift +++ b/ios/Sources/LibraryView.swift @@ -10,7 +10,28 @@ struct LibraryView: View { @ObservedObject var playback: Playback /// The file is the one asked for, or the item's first playable one. var play: (IPX.Entry, IPX.Enclosure?) -> Void - var openPage: () -> Void + var openPage: (WebViewController.Screen) -> Void + + /// Everything that lives in the page, named rather than hidden behind one button: it should + /// be as easy to reach Settings here as it is in a browser. + private var pageMenu: some View { + Menu { + Button { openPage(.settings) } label: { Label("Settings", systemImage: "gearshape") } + Button { openPage(.directory) } label: { Label("Directory", systemImage: "square.grid.2x2") } + Button { openPage(.popular) } label: { Label("Popular", systemImage: "star") } + Button { openPage(.opml) } label: { Label("Import or export OPML", systemImage: "square.and.arrow.up") } + if store.isAdmin { + Divider() + Button { openPage(.admin) } label: { Label("Admin", systemImage: "wrench.and.screwdriver") } + } + Divider() + Button { openPage(.page) } label: { Label("Open the full page", systemImage: "safari") } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("More") + .help("Settings, the directory, OPML") + } var body: some View { NavigationSplitView { @@ -19,10 +40,7 @@ struct LibraryView: View { } detail: { ItemListView(store: store, playback: playback, play: play) .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(action: openPage) { Image(systemName: "safari") } - .help("Open the full page: settings, the directory, OPML") - } + ToolbarItem(placement: .primaryAction) { pageMenu } } } .tint(Glass.accent) diff --git a/ios/Sources/RootViewController.swift b/ios/Sources/RootViewController.swift index d27ee35..71ea950 100644 --- a/ios/Sources/RootViewController.swift +++ b/ios/Sources/RootViewController.swift @@ -34,7 +34,7 @@ final class RootViewController: UIViewController { store: store, playback: playback, play: { [weak self] entry, file in self?.play(entry, file) }, - openPage: { [weak self] in self?.showPage() }) + openPage: { [weak self] screen in self?.showPage(screen) }) .environment(\.api, api) host = UIHostingController(rootView: AnyView(root)) @@ -82,7 +82,8 @@ final class RootViewController: UIViewController { playback.play() } - private func showPage() { + private func showPage(_ screen: WebViewController.Screen) { + page.open(screen) guard page.presentingViewController == nil else { return } let nav = UINavigationController(rootViewController: page) page.navigationItem.leftBarButtonItem = UIBarButtonItem( diff --git a/ios/Sources/WebViewController.swift b/ios/Sources/WebViewController.swift index cdad7aa..fdaec66 100644 --- a/ios/Sources/WebViewController.swift +++ b/ios/Sources/WebViewController.swift @@ -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 diff --git a/ios/UITests/PlaybackTests.swift b/ios/UITests/PlaybackTests.swift index 24ad6cc..c47be64 100644 --- a/ios/UITests/PlaybackTests.swift +++ b/ios/UITests/PlaybackTests.swift @@ -106,3 +106,28 @@ final class ItemPaneTests: XCTestCase { "no read control in the pane") } } + +/// The screens that stay in the page have to be reachable from the native interface, or they +/// are simply gone: settings, the directory, OPML and the admin page were never rewritten. +final class PageScreenTests: XCTestCase { + func testSettingsOpensThePagesOwnDialog() throws { + let server = ProcessInfo.processInfo.environment["IPX_SERVER"] ?? "" + try XCTSkipIf(server.isEmpty, "set TEST_RUNNER_IPX_SERVER to a fixture daemon") + + let app = XCUIApplication() + app.launchArguments = ["-ipx.server", server] + app.launch() + + XCTAssertTrue(app.staticTexts["Second Episode"].firstMatch.waitForExistence(timeout: 30), + "the list never appeared") + + app.buttons["More"].firstMatch.tap() + app.buttons["Settings"].firstMatch.tap() + + // The page's own Settings dialog, not a native one: this is the thing being checked. + XCTAssertTrue(app.staticTexts["Theme"].waitForExistence(timeout: 20), + "the page opened but its Settings dialog never did") + XCTAssertTrue(app.staticTexts["Subscriptions"].firstMatch.exists, + "the dialog is there but not the one expected") + } +}