From 292941524e3ca20ff1d9a2dfd563929fe95b5a83 Mon Sep 17 00:00:00 2001 From: Ray Slakinski Date: Sun, 20 Sep 2026 09:34:34 -0400 Subject: [PATCH] A native item pane, with the show notes still HTML (#4) The reading pane: the item, its files with play, download and delete, read and pin in the toolbar, a link to the original, and the notes. The notes are a web view, deliberately. They are feed-supplied HTML that ipx has already run through ammonia, and there is no good native renderer for that -- NSAttributedString(html:) is slow, single-threaded and ugly, and writing a real one is a project. So this carries only the notes, with just enough stylesheet to belong to the app: the system font at body size, the label colours, links in the accent. Nothing else is imposed, because the markup is the publisher's. A link opens in Safari rather than inside the view, where it would replace the notes with somebody's website and leave no way back. Deleting asks first, and says when the file is shared: one file serves everyone reading the feed, so removing it is not a private act. Two goes at the navigation. A row with a selection binding on the list highlighted and went nowhere -- the binding takes the tap before the link sees it. Without the binding, NavigationLink(value:) with a matching navigationDestination still pushed nothing and gave no reason, so the link carries its own destination now, which has nothing to get wrong. Fourteen tests pass. The new one opens an item and looks for its notes, its delete button and its read control, so a pane that renders empty fails rather than passing quietly. Co-Authored-By: Claude Opus 5 --- README.md | 2 + ios/Sources/ItemDetailView.swift | 165 +++++++++++++++++++++++++++ ios/Sources/ItemListView.swift | 19 ++- ios/Sources/LibraryView.swift | 3 +- ios/Sources/RootViewController.swift | 7 +- ios/Sources/ShowNotesView.swift | 90 +++++++++++++++ ios/UITests/PlaybackTests.swift | 27 +++++ 7 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 ios/Sources/ItemDetailView.swift create mode 100644 ios/Sources/ShowNotesView.swift diff --git a/README.md b/README.md index 82cacd0..896a42e 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ For automation, `-ipx.server ` as a launch argument overrides the stored va | `ios/Sources/LibraryStore.swift` | what the lists show, and the optimistic writes | | `ios/Sources/FeedListView.swift` | the sidebar: places, feeds, OPML folders | | `ios/Sources/ItemListView.swift` | the item table: filter, sort, search, paging | +| `ios/Sources/ItemDetailView.swift` | one item: its files, its controls, its notes | +| `ios/Sources/ShowNotesView.swift` | the notes, which stay HTML on purpose | | `ios/Sources/LibraryView.swift` | the two beside each other, bar underneath | | `ios/Sources/RootViewController.swift` | the native interface, with the page a button away | | `ios/Sources/CookieBridge.swift` | `WKHTTPCookieStore` into `HTTPCookieStorage.shared` | diff --git a/ios/Sources/ItemDetailView.swift b/ios/Sources/ItemDetailView.swift new file mode 100644 index 0000000..d750e8f --- /dev/null +++ b/ios/Sources/ItemDetailView.swift @@ -0,0 +1,165 @@ +import SwiftUI + +/// One item: what it is, what to do with it, its files, and its notes. +struct ItemDetailView: View { + @ObservedObject var store: LibraryStore + @ObservedObject var playback: Playback + let entry: IPX.Entry + var play: (IPX.Entry, IPX.Enclosure?) -> Void + + @Environment(\.api) private var api + @State private var notesHeight: CGFloat = 1 + @State private var confirmingDelete: IPX.Enclosure? + + private var current: IPX.Entry { store.entries.first { $0.id == entry.id } ?? entry } + private var feed: IPX.Feed? { store.feed(entry.feedId) } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + heading + meta + files + notes + } + .padding(16) + } + .background(Glass.Wash()) + .navigationTitle(feed?.name ?? entry.feedId) + #if !targetEnvironment(macCatalyst) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { toolbar } + .confirmationDialog("Delete this file?", + isPresented: Binding(get: { confirmingDelete != nil }, + set: { if !$0 { confirmingDelete = nil } }), + titleVisibility: .visible) { + Button("Delete", role: .destructive) { + if let file = confirmingDelete { delete(file) } + confirmingDelete = nil + } + } message: { + // One file serves everyone reading the feed, so deleting is not a private act. + Text((feed?.subscribers ?? 1) > 1 + ? "Other people reading this feed share this file." + : "The file is removed from the server.") + } + } + + private var heading: some View { + HStack(alignment: .top, spacing: 12) { + FeedArt(feed: feed, size: 56) + Text(current.title ?? "(untitled)") + .font(.title3.weight(.semibold)) + .foregroundStyle(Glass.text) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var meta: some View { + let number = [current.season.map { "S\($0)" }, current.episode.map { "E\($0)" }] + .compactMap { $0 }.joined() + let date = current.published.map { + Date(timeIntervalSince1970: TimeInterval($0)).formatted(date: .long, time: .omitted) + } + let length = current.duration.map { clock($0) } + return Text([feed?.name, number.isEmpty ? nil : number, date, length] + .compactMap { $0 }.joined(separator: " ยท ")) + .font(.caption).foregroundStyle(Glass.faint) + } + + @ViewBuilder private var files: some View { + if !current.enclosures.isEmpty { + VStack(spacing: 8) { + ForEach(current.enclosures) { file in row(file) } + } + } + } + + private func row(_ file: IPX.Enclosure) -> some View { + HStack(spacing: 10) { + Image(systemName: symbol(file)) + .foregroundStyle(file.isDownloaded ? Glass.good + : file.state == "error" ? Glass.bad : Glass.faint) + Text(size(file)).font(.caption).foregroundStyle(Glass.dim) + if let error = file.lastError, file.state == "error" { + Text(error).font(.caption2).foregroundStyle(Glass.bad).lineLimit(1) + } + Spacer() + if file.isPlayable { + Button { play(current, file) } label: { Image(systemName: "play.fill") } + .buttonStyle(.borderless).accessibilityLabel("Play") + } else if !file.isDownloaded { + Button { download(file) } label: { Image(systemName: "arrow.down.circle") } + .buttonStyle(.borderless).accessibilityLabel("Download to the server") + } + if file.isDownloaded { + Button { confirmingDelete = file } label: { Image(systemName: "trash") } + .buttonStyle(.borderless).foregroundStyle(Glass.bad) + .accessibilityLabel("Delete file") + } + } + .padding(.horizontal, 12).padding(.vertical, 9) + .glassPane() + .clipShape(RoundedRectangle(cornerRadius: Glass.Radius.control, style: .continuous)) + } + + private func symbol(_ file: IPX.Enclosure) -> String { + let mime = (file.mime ?? "").lowercased() + if mime.hasPrefix("audio/") { return "headphones" } + if mime.hasPrefix("video/") { return "film" } + if mime.hasPrefix("image/") { return "photo" } + if mime.contains("pdf") { return "doc.richtext" } + if mime.contains("torrent") { return "link" } + return "doc" + } + + private func size(_ file: IPX.Enclosure) -> String { + guard let bytes = file.length, bytes > 0 else { return file.state } + return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } + + @ViewBuilder private var notes: some View { + let html = (current.description ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if html.isEmpty { + Text("No show notes.").font(.callout).italic().foregroundStyle(Glass.faint) + } else { + ShowNotesView(html: html, baseURL: ServerSettings.base, height: $notesHeight) + .frame(height: notesHeight) + } + } + + @ToolbarContentBuilder private var toolbar: some ToolbarContent { + ToolbarItemGroup { + Button { store.setRead(current, !current.read) } label: { + Image(systemName: current.read ? "envelope.badge" : "checkmark.circle") + } + .help(current.read ? "Mark unread" : "Mark read") + .accessibilityLabel(current.read ? "Mark unread" : "Mark read") + + Button { store.setPinned(current, !current.flagged) } label: { + Image(systemName: current.flagged ? "pin.fill" : "pin") + } + .help(current.flagged ? "Unpin" : "Pin, so it is never deleted") + .accessibilityLabel(current.flagged ? "Unpin" : "Pin") + + if let link = current.link, let url = URL(string: link) { + Link(destination: url) { Image(systemName: "safari") } + .help("Open the original") + } + } + } + + private func download(_ file: IPX.Enclosure) { + Task { try? await api.download(enclosure: file.id) } + } + + private func delete(_ file: IPX.Enclosure) { + Task { try? await api.deleteFile(enclosure: file.id, force: true) } + } + + private func clock(_ seconds: Int) -> String { + let (h, m, s) = (seconds / 3600, (seconds % 3600) / 60, seconds % 60) + return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) + } +} diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift index f321d3e..f1fe491 100644 --- a/ios/Sources/ItemListView.swift +++ b/ios/Sources/ItemListView.swift @@ -4,7 +4,8 @@ import SwiftUI struct ItemListView: View { @ObservedObject var store: LibraryStore @ObservedObject var playback: Playback - var play: (IPX.Entry) -> Void + /// The file asked for, or nil for the item's first playable one. + var play: (IPX.Entry, IPX.Enclosure?) -> Void var body: some View { VStack(spacing: 0) { @@ -52,10 +53,18 @@ struct ItemListView: View { // MARK: - rows private var list: some View { - List(selection: $store.selected) { + // No selection binding: it swallowed the tap before the NavigationLink could act on + // it, so a row highlighted and went nowhere. Navigation is the link's job. + List { ForEach(store.entries) { entry in - row(entry) - .tag(entry.id) + // A destination built here rather than a value matched to one elsewhere: + // the value form pushed nothing and gave no reason, and there is nothing to + // get wrong about a link that carries its own destination. + NavigationLink { + ItemDetailView(store: store, playback: playback, entry: entry, play: play) + } label: { + row(entry) + } .onAppear { // Paging by the last row rather than a button: fifty at a time is the // server's page size, and a long list should not need a tap to continue. @@ -117,7 +126,7 @@ struct ItemListView: View { Image(systemName: "pin.fill").font(.caption2).foregroundStyle(Glass.accent) } if entry.playable != nil { - Button { play(entry) } label: { Image(systemName: "play.circle") } + Button { play(entry, nil) } label: { Image(systemName: "play.circle") } .buttonStyle(.plain).foregroundStyle(Glass.accent) .accessibilityLabel("Play") } diff --git a/ios/Sources/LibraryView.swift b/ios/Sources/LibraryView.swift index 3380049..72ed8d5 100644 --- a/ios/Sources/LibraryView.swift +++ b/ios/Sources/LibraryView.swift @@ -8,7 +8,8 @@ import SwiftUI struct LibraryView: View { @ObservedObject var store: LibraryStore @ObservedObject var playback: Playback - var play: (IPX.Entry) -> Void + /// 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 body: some View { diff --git a/ios/Sources/RootViewController.swift b/ios/Sources/RootViewController.swift index 7a794c9..d27ee35 100644 --- a/ios/Sources/RootViewController.swift +++ b/ios/Sources/RootViewController.swift @@ -33,7 +33,7 @@ final class RootViewController: UIViewController { let root = LibraryView( store: store, playback: playback, - play: { [weak self] entry in self?.play(entry) }, + play: { [weak self] entry, file in self?.play(entry, file) }, openPage: { [weak self] in self?.showPage() }) .environment(\.api, api) @@ -64,8 +64,9 @@ final class RootViewController: UIViewController { } /// Plays a row through the same Playback the lock screen and the car drive. - private func play(_ entry: IPX.Entry) { - guard let file = entry.playable, let url = ServerSettings.url("/media/\(file.id)") else { return } + private func play(_ entry: IPX.Entry, _ asked: IPX.Enclosure?) { + guard let file = asked ?? entry.playable, + let url = ServerSettings.url("/media/\(file.id)") else { return } let feed = store.feed(entry.feedId) playback.load(.init( url: url, diff --git a/ios/Sources/ShowNotesView.swift b/ios/Sources/ShowNotesView.swift new file mode 100644 index 0000000..aecfa62 --- /dev/null +++ b/ios/Sources/ShowNotesView.swift @@ -0,0 +1,90 @@ +import SwiftUI +import WebKit + +/// An item's show notes. +/// +/// They are feed-supplied HTML, sanitized server-side with ammonia before they are sent. There +/// is no good native renderer for that: NSAttributedString(html:) is slow, single-threaded and +/// ugly, and writing a real one is a project. So this is a web view carrying only the notes, +/// dressed to match, and that is a deliberate choice rather than a thing left undone. +struct ShowNotesView: UIViewRepresentable { + let html: String + let baseURL: URL? + /// The notes size themselves; the pane scrolls as one, so the view grows to fit. + @Binding var height: CGFloat + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIView(context: Context) -> WKWebView { + let cfg = WKWebViewConfiguration() + // Cookies, so a picture behind the same sign-in loads. + cfg.websiteDataStore = .default() + let web = WKWebView(frame: .zero, configuration: cfg) + web.navigationDelegate = context.coordinator + web.scrollView.isScrollEnabled = false + web.isOpaque = false + web.backgroundColor = .clear + web.scrollView.backgroundColor = .clear + return web + } + + func updateUIView(_ web: WKWebView, context: Context) { + guard context.coordinator.shown != html else { return } + context.coordinator.shown = html + web.loadHTMLString(page(for: context.environment.colorScheme), baseURL: baseURL) + } + + /// The notes wrapped in just enough stylesheet to belong to the app: the system font at the + /// body size, the label colours, and links in the accent. Nothing else is imposed -- the + /// markup is the publisher's and should read as they wrote it. + private func page(for scheme: ColorScheme) -> String { + let dark = scheme == .dark + let fg = dark ? "#f5f5f7" : "#1d1d1f" + let dim = dark ? "#aeaeb2" : "#515154" + let link = dark ? "#409cff" : "#0055aa" + let rule = dark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.12)" + return """ + + + \(html) + """ + } + + final class Coordinator: NSObject, WKNavigationDelegate { + private let parent: ShowNotesView + var shown: String? + + init(_ parent: ShowNotesView) { self.parent = parent } + + func webView(_ web: WKWebView, didFinish navigation: WKNavigation!) { + // Measure once it has laid out, so the pane can give it the room it asked for. + web.evaluateJavaScript("document.body.scrollHeight") { value, _ in + if let h = value as? CGFloat { self.parent.height = max(h, 1) } + } + } + + /// A link in the notes opens in Safari. Following one inside this view would replace the + /// notes with somebody's website and leave no way back. + func webView(_ web: WKWebView, + decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard action.navigationType == .linkActivated, let url = action.request.url else { + return decisionHandler(.allow) + } + UIApplication.shared.open(url) + decisionHandler(.cancel) + } + } +} diff --git a/ios/UITests/PlaybackTests.swift b/ios/UITests/PlaybackTests.swift index 1ad2cf1..24ad6cc 100644 --- a/ios/UITests/PlaybackTests.swift +++ b/ios/UITests/PlaybackTests.swift @@ -79,3 +79,30 @@ final class PlaybackTests: XCTestCase { return false } } + +/// Opening an item, which is a push away from the list and has to show what is in it. +final class ItemPaneTests: XCTestCase { + func testOpeningAnItemShowsItsFilesAndNotes() 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") + // The row, not its title: tapping the label does not activate the link, and with a + // selection binding on the list the tap was swallowed before the link ever saw it. + app.cells.element(boundBy: 0).tap() + + // The pane is titled with the feed, and carries the item's file and its notes. + XCTAssertTrue(app.staticTexts["Notes for the second."].waitForExistence(timeout: 15), + "the show notes did not render") + XCTAssertTrue(app.buttons["Delete file"].firstMatch.exists, + "the downloaded file is not offered for deletion") + XCTAssertTrue(app.buttons["Mark unread"].firstMatch.exists + || app.buttons["Mark read"].firstMatch.exists, + "no read control in the pane") + } +}