selectEntry in the page calls markRead, so picking a row reads it. Natively an item could be opened, read and left, and it stayed bold. With it comes the detail that would have been missed by guessing: on the Unread tab the item you were reading is dropped when you move on to the next one, not whenever a refresh next comes along, so nothing vanishes from under the pointer mid-click. A flag that does not save rolls back and says so, where the page toasts. The keys are Feedly's set, from player.ts: j n and k p, shift J and K, o m s v, shift A, r, [, space, the arrows, g then a d p l or s, and ? for the list of them. The pair window is the page's second and a half, and p and s mean different things with and without a g in front, which they do there too. They are UIKeyCommand rather than SwiftUI's onKeyPress, which is iOS 17, and they hang off the hosting controller rather than the controller that owns it: SwiftUI's views hold first responder, so the chain starts inside that hierarchy and an override further up is never consulted. What is tested is the map, in KeysTests -- that p is Previous alone and Popular after g, that every mapped key is registered, that the window is 1.5s -- because a wrong letter there loses a shortcut silently. What is not tested is whether a press arrives at all: the simulator drops key events unless something is focused, and running the same tests against Catalyst, where the keyboard is the point, needs the runner to have accessibility permission it does not have here. That test is skipped with the reason written in it rather than deleted or left red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
239 lines
9.5 KiB
Swift
239 lines
9.5 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? { didSet { if selected != oldValue { opened() } } }
|
|
/// Something to say when a write does not save. The page toasts; this is the same thing
|
|
/// waiting for a view to show it.
|
|
@Published var problem: String?
|
|
|
|
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.
|
|
// Currently Listening is the server's in_progress filter, not a pass over the page
|
|
// we happened to receive: trimming fifty rows here showed whichever started episodes
|
|
// were near the top of the library and quietly left out the rest.
|
|
let page = try await api.entries(
|
|
feed: place.feedId,
|
|
filter: place == .listening ? .inProgress : filter,
|
|
search: search,
|
|
sort: sort,
|
|
direction: direction,
|
|
offset: offset)
|
|
guard !Task.isCancelled else { return }
|
|
entries = offset == 0 ? page.entries : entries + page.entries
|
|
total = 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: - opening an item
|
|
|
|
/// Opening an item is reading it, as it is in the page.
|
|
///
|
|
/// And on the Unread tab the one you were reading goes when you move on to the next, rather
|
|
/// than whenever a refresh next happens to come along -- which left a handful of read items
|
|
/// sitting in the list for a while. It goes on the way out, so nothing disappears from under
|
|
/// the pointer as it is being clicked.
|
|
private func opened() {
|
|
guard let id = selected, let entry = entries.first(where: { $0.id == id }) else { return }
|
|
|
|
if filter == .unread, let leaving = previous, leaving != id,
|
|
let was = entries.first(where: { $0.id == leaving }), was.read {
|
|
entries.removeAll { $0.id == leaving }
|
|
total = max(0, total - 1)
|
|
}
|
|
previous = id
|
|
|
|
if !entry.read { setRead(entry, true) }
|
|
}
|
|
|
|
private var previous: IPX.Entry.ID?
|
|
|
|
/// Move by one, and open it. Nothing to do at either end of the list.
|
|
func step(_ by: Int) {
|
|
guard !entries.isEmpty else { return }
|
|
let at = entries.firstIndex { $0.id == selected } ?? -1
|
|
let next = at < 0 ? 0 : min(entries.count - 1, max(0, at + by))
|
|
selected = entries[next].id
|
|
// Near the end, fetch the next page so stepping does not stop at fifty.
|
|
if next >= entries.count - 3 { loadMore() }
|
|
}
|
|
|
|
/// The feed after or before this one, in the order the sidebar shows them.
|
|
func stepFeed(_ by: Int, within order: [LibraryStore.Place]) {
|
|
guard let at = order.firstIndex(of: place) else { return place = order.first ?? .all }
|
|
let next = at + by
|
|
guard order.indices.contains(next) else { return }
|
|
place = order[next]
|
|
}
|
|
|
|
var selectedEntryLive: IPX.Entry? { entries.first { $0.id == selected } }
|
|
|
|
// 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)
|
|
await refreshFeeds()
|
|
} catch {
|
|
replace(entry.id) { $0.with(read: !read) }
|
|
problem = "That did not save: \((error as? LocalizedError)?.errorDescription ?? "\(error)")"
|
|
}
|
|
}
|
|
}
|
|
|
|
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) }
|
|
problem = "That did not save: \((error as? LocalizedError)?.errorDescription ?? "\(error)")"
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|