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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user