Files
ipodderx-app/ios/Sources/LibraryStore.swift
Ray Slakinski 33c3790fe3 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>
2026-09-20 09:38:26 -04:00

186 lines
7.0 KiB
Swift

import Foundation
import SwiftUI
/// What the lists are showing, and how it changes.
///
/// The sorting, filtering and searching are the server's work: `GET /api/entries` takes them all
/// and pages fifty at a time, so this holds a page and asks for the next rather than keeping ten
/// thousand items in memory and sorting them here.
@MainActor
final class LibraryStore: ObservableObject {
/// Where the list is pointed. The places come before any feed, as they do in the page.
enum Place: Hashable {
case all
case listening
case feed(String)
var feedId: String? { if case .feed(let id) = self { return id } else { return nil } }
}
@Published private(set) var feeds: [IPX.Feed] = []
@Published private(set) var entries: [IPX.Entry] = []
@Published private(set) var total = 0
@Published private(set) var loading = false
@Published private(set) var failure: String?
/// Light or dark as the account has it, nil for auto. ipx keeps the theme on the user so it
/// 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() } } }
@Published var sort: IPX.Sort = .published { didSet { reload() } }
@Published var direction: IPX.Direction = .desc { didSet { reload() } }
@Published var search = "" { didSet { searchChanged() } }
@Published var selected: IPX.Entry.ID?
private let api: API
private var searchTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>?
init(api: API) { self.api = api }
var canLoadMore: Bool { entries.count < total }
var selectedEntry: IPX.Entry? { entries.first { $0.id == selected } }
/// The feed a row belongs to, for its name and art. All Subscriptions mixes them, so a row
/// cannot assume the selected feed is its own.
func feed(_ id: String) -> IPX.Feed? { feeds.first { $0.id == id } }
var title: String {
switch place {
case .all: return "All Subscriptions"
case .listening: return "Currently Listening"
case .feed(let id): return feed(id)?.name ?? id
}
}
// MARK: - loading
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
// Dark when nobody has chosen, because that is ipx's own default -- theme.ts
// starts at modern/dark rather than at the system. Following the phone here would
// put a light list in front of a dark page.
default: appearance = .dark
}
}
do {
feeds = try await api.feeds()
failure = nil
} catch {
failure = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
}
func reload() {
loadTask?.cancel()
loadTask = Task { await load(offset: 0) }
}
func loadMore() {
guard !loading, canLoadMore else { return }
loadTask = Task { await load(offset: entries.count) }
}
private func load(offset: Int) async {
loading = true
defer { loading = false }
do {
// Currently Listening is not a feed; it is the started-but-unfinished filter, which
// the page reaches through the same route.
let page = try await api.entries(
feed: place.feedId,
filter: place == .listening ? .all : filter,
search: search,
sort: sort,
direction: direction,
offset: offset)
guard !Task.isCancelled else { return }
var rows = offset == 0 ? page.entries : entries + page.entries
if place == .listening {
rows = rows.filter { $0.position > 0 && !$0.read }
}
entries = rows
total = place == .listening ? rows.count : page.total
failure = nil
} catch is CancellationError {
} catch {
failure = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
}
/// Typing should not fire a request per keystroke, nor wait so long it feels broken.
private func searchChanged() {
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
reload()
}
}
// MARK: - writing
/// Read and pinned are set here first and sent after. The page does the same, for the same
/// reason: a list asked for before the write lands answers with the old state, which put the
/// dot back on an item just read.
func setRead(_ entry: IPX.Entry, _ read: Bool) {
replace(entry.id) { $0.with(read: read) }
Task {
do { try await api.setRead(read, feed: entry.feedId, guid: entry.guid) }
catch { replace(entry.id) { $0.with(read: !read) } }
}
}
func setPinned(_ entry: IPX.Entry, _ pinned: Bool) {
replace(entry.id) { $0.with(flagged: pinned) }
Task {
do { try await api.setPinned(pinned, feed: entry.feedId, guid: entry.guid) }
catch { replace(entry.id) { $0.with(flagged: !pinned) } }
}
}
func readAll() {
let ids = entries.map(\.id)
for id in ids { replace(id) { $0.with(read: true) } }
Task {
try? await api.readAll(feed: place.feedId)
await refreshFeeds()
}
}
func scan() {
Task {
try? await api.fetch(feed: place.feedId)
}
}
private func replace(_ id: IPX.Entry.ID, _ change: (IPX.Entry) -> IPX.Entry) {
guard let at = entries.firstIndex(where: { $0.id == id }) else { return }
entries[at] = change(entries[at])
}
}
extension IPX.Entry {
/// A copy with one flag moved. The type is decoded from the server and has no setters, which
/// is deliberate: the only things that change locally are the two this person owns.
func with(read: Bool? = nil, flagged: Bool? = nil) -> IPX.Entry {
IPX.Entry(guid: guid, feedId: feedId, title: title, link: link, published: published,
description: description, read: read ?? self.read,
flagged: flagged ?? self.flagged, image: image, duration: duration,
episode: episode, season: season, position: position, enclosures: enclosures)
}
}