Opening an item reads it, and the page's keys (#11)

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>
This commit is contained in:
2026-09-20 11:26:05 -04:00
parent a13a797bf5
commit d68f1d0d1d
8 changed files with 371 additions and 12 deletions

View File

@@ -36,7 +36,10 @@ final class LibraryStore: ObservableObject {
@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?
@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>?
@@ -130,6 +133,49 @@ final class LibraryStore: ObservableObject {
}
}
// 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
@@ -138,8 +184,13 @@ final class LibraryStore: ObservableObject {
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) } }
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)")"
}
}
}
@@ -147,7 +198,10 @@ final class LibraryStore: ObservableObject {
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) } }
catch {
replace(entry.id) { $0.with(flagged: !pinned) }
problem = "That did not save: \((error as? LocalizedError)?.errorDescription ?? "\(error)")"
}
}
}