Release 0.5.4: remembered view, Auto theme, Currently Listening

- Remember the feed/place and tab across a reload or new visit; an
  unknown or unsubscribed one lands on All Subscriptions instead of the
  first feed alphabetically.
- Add an Auto theme that follows the system's light/dark setting, and
  move Dark/Light/Classic/Auto into Settings as a dropdown alongside the
  header button's toggle.
- Add Currently Listening below Popular: episodes started and not
  finished, across every subscribed feed, one tap to resume. Reuses the
  existing entries/filter machinery (Filter::InProgress) rather than a
  new endpoint.
- Likely fix for the iOS bug where the topbar stopped responding to taps
  until a hard refresh (100vh -> 100dvh); unverified on a real device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
This commit is contained in:
2026-09-14 15:38:03 +00:00
parent be3820bbbd
commit a30edff248
7 changed files with 222 additions and 33 deletions

View File

@@ -685,6 +685,10 @@ pub enum Filter {
Unread,
Downloaded,
Flagged,
/// Started (a saved playback position past the first few seconds) but not finished
/// (`markPlayed` in the UI marks an item read at 90% played, so unread is "not finished"
/// here too). Currently Listening, below Popular, is this filter on every feed at once.
InProgress,
}
impl Filter {
@@ -693,6 +697,7 @@ impl Filter {
"unread" => Self::Unread,
"downloaded" => Self::Downloaded,
"flagged" => Self::Flagged,
"in_progress" => Self::InProgress,
_ => Self::All,
}
}
@@ -708,6 +713,7 @@ impl Filter {
"EXISTS (SELECT 1 FROM enclosures x
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
}
Self::InProgress => "coalesce(s.position, 0) > 5 AND coalesce(s.read, 0) = 0",
}
}
}
@@ -1656,18 +1662,27 @@ mod tests {
"INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
('f','a','Alpha dive','notes one',100),
('f','b','Beta', 'notes two',200),
('f','c','Gamma dive','notes three',300);
('f','c','Gamma dive','notes three',300),
('f','d','Delta', 'notes four',400),
('f','e','Epsilon', 'notes five',500),
('f','g','Gimel', 'notes six',600);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','b','u1','/tmp/b','done');
-- Read and starred belong to a person now, so say which one.
INSERT INTO users (id, name, is_admin) VALUES (7,'reader',1);
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(7,'f','b',1,0),
(7,'f','c',1,1);",
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES
(7,'f','b',1,0,0),
(7,'f','c',1,1,0),
-- Started and not finished: this is Currently Listening.
(7,'f','d',0,0,42),
-- Already finished: not Currently Listening, however far it got.
(7,'f','e',1,0,42),
-- Barely touched (opened, closed within seconds): not Currently Listening.
(7,'f','g',0,0,3);",
)
.unwrap();
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged] {
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] {
// Both paths must run without erroring, and agree with each other.
let order = order_sql("published", "desc");
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).unwrap();
@@ -1679,13 +1694,17 @@ mod tests {
assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees");
}
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 3);
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 6);
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 3);
assert_eq!(db.count_in(7, Some("f"), Filter::Downloaded, None).unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::Flagged, None).unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("dive")).unwrap(), 2);
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1,
"search is case-insensitive and covers the description");
// Currently Listening: started, not finished, and not just an accidental tap.
let listening = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order_sql("published", "desc")).unwrap();
assert_eq!(listening.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["d"]);
}
#[test]