A native feed list and item list (#3)
The lists are native now, and the page is a button in the toolbar: it is still where signing in happens, and it is still the whole of settings, the admin page, the directory and OPML, which were never going to be rewritten. LibraryStore holds a page of items and asks for the next, because the sorting, filtering and searching are the server's work and ten thousand items have no business being in memory to be sorted here. Read and pinned are set locally and sent after, as the page's readWrites map does and 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. The interface follows the account's light or dark rather than the phone's, and defaults to dark when nobody has chosen, because that is what ipx's own theme.ts does. Following the system instead put a light list in front of a dark page. preferredColorScheme was not enough on its own -- inside a hosting controller it did not reach the hierarchy -- so the style is overridden on the controller, which also carries to the page presented over it. The sidebar had to be broken into sub-views: the whole list in one expression was more than the type checker would work through, and it said so rather than compiling it. Tested against the real library, 135 feeds and eleven thousand items, and the playback test now drives the native row rather than the page's button. Thirteen pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
179
ios/Sources/LibraryStore.swift
Normal file
179
ios/Sources/LibraryStore.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
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?
|
||||
|
||||
@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() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user