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>
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>
The reading pane: the item, its files with play, download and delete, read
and pin in the toolbar, a link to the original, and the notes.
The notes are a web view, deliberately. They are feed-supplied HTML that
ipx has already run through ammonia, and there is no good native renderer
for that -- NSAttributedString(html:) is slow, single-threaded and ugly,
and writing a real one is a project. So this carries only the notes, with
just enough stylesheet to belong to the app: the system font at body size,
the label colours, links in the accent. Nothing else is imposed, because
the markup is the publisher's. A link opens in Safari rather than inside
the view, where it would replace the notes with somebody's website and
leave no way back.
Deleting asks first, and says when the file is shared: one file serves
everyone reading the feed, so removing it is not a private act.
Two goes at the navigation. A row with a selection binding on the list
highlighted and went nowhere -- the binding takes the tap before the link
sees it. Without the binding, NavigationLink(value:) with a matching
navigationDestination still pushed nothing and gave no reason, so the link
carries its own destination now, which has nothing to get wrong.
Fourteen tests pass. The new one opens an item and looks for its notes, its
delete button and its read control, so a pane that renders empty fails
rather than passing quietly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>