Files
ipodderx-app/ios/Sources/ShowNotesView.swift
Ray Slakinski 292941524e 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 <noreply@anthropic.com>
2026-09-20 09:34:34 -04:00

91 lines
4.0 KiB
Swift

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 """
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{color-scheme:\(dark ? "dark" : "light")}
body{margin:0;background:transparent;color:\(fg);
font:16px/1.55 -apple-system,system-ui,sans-serif;
overflow-wrap:break-word}
a{color:\(link)}
img,video{max-width:100%;height:auto;border-radius:8px}
blockquote{margin:0 0 0 12px;padding-left:12px;border-left:2px solid \(rule);color:\(dim)}
pre{overflow-x:auto;padding:10px;border-radius:8px;background:\(rule)}
code{font-family:ui-monospace,monospace}
hr{border:0;border-top:1px solid \(rule)}
table{max-width:100%;overflow-x:auto;display:block}
</style></head><body>\(html)</body></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)
}
}
}