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") + } +}