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? private var loadTask: Task? 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) } }