Skip to content

feat(messages): trending tags rail (#30) - #129

Merged
Adron merged 3 commits into
parity/queuefrom
issue/30-trending-tags
Sep 16, 2026
Merged

Adron merged 3 commits into
parity/queuefrom
issue/30-trending-tags

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #30. Part of epic #27 — the last of its three sub-issues.

Zero :app changes were needed: #29 already wires onOpenTag at both MessagesRoute call
sites, so taps flow through the existing MessagesDestinations.tagFeedRoute(tag) entry point.

The issue's premise about the window is wrong, and no label was invented

helpapi/utility-endpoints.txt plus live probing establish that window is a request
parameter
(day | week | month, default week) and that the response reports nothing about
the period
— it is a bare {"tags":[{"tag","count","lastUsedAt"}]}.

Worse: the server silently falls back to week for an unrecognised value. ?window=bogus
returned byte-identical results to ?window=week, with no error — the same
accept-and-quietly-rewrite behaviour found on saved-view config.mode (#51) and theme (#36).

So the app sends window=week explicitly from a typed TrendingWindow enum and derives the heading
from the window it requested — "Trending this week" is true because the request makes it true,
not because the API said so. lastUsedAt, the one recency the payload does carry, goes into each
chip's accessibility label.

Placement

The rail is the first row inside the feed's LazyColumn, not a fixed band. The top of that
screen is already contested by #19's view-preference switcher and the composer FAB, so a permanent
band would cost ~78dp forever; as a list row it is walked past on every visit and then scrolls away.

It is also rendered in the feed's empty state, where "somewhere to go" matters most — covering
both a fresh account and the tag feed's own "Nothing tagged X yet". It renders on tag feeds too
(lateral hopping between tags), and is omitted entirely where the host wires no onOpenTag.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 1484 tests, 0 failures. TDD
— tests written before each layer. 20 new JVM tests: the live payload parses with spaces, commas and
mixed case intact; rows missing or nulling count/lastUsedAt parse; a row with no usable tag is
dropped; unknown keys ignored; window/limit verified on the wire; a 500 is an error, not an
empty list
; the four UI states are decided in one status property so EMPTY and ERROR are provably
distinct; a tapped tag becomes messages/tag/life%20is%20short%2C%20o%20brave%20girl and decodes
back byte-for-byte. 10 Compose tests compile, not executed (no emulator).

Reviewer notes

  • Trending is network-only (no Room cache), unlike the offline-first feed: it is a "right now"
    surface, and a stale cached rail would be worse than the honest empty/error states. A failed
    refresh keeps previously loaded chips rather than replacing them with a banner.
  • Each nav entry gets its own TrendingTagsViewModel, so opening a tag feed makes one extra small
    trending request. Sharing it across the graph was not worth the scoping complexity.
  • This branch was cut before Tags: tag-filtered feed (GET /api/messages?tag=) #29 landed on the queue, so it carries an explicit merge of Tags: tag-filtered feed (GET /api/messages?tag=) #29 plus a
    merge bringing it up to the current queue tip. The PR diff is exactly this issue's 14 files.

#30 opens the tag-filtered feed via MessagesDestinations.tagFeedRoute(tag),
which lands with #29 and was not yet on this branch's base.
Surfaces GET /api/tags/trending as a horizontally scrolling rail of tag
chips, and every chip opens that tag's feed through the single entry point
#29 left behind, MessagesDestinations.tagFeedRoute(tag) — with the tag
handed over byte-for-byte, because real tags contain spaces and commas
("life is short, o brave girl").

Placement: the rail is the first row *inside* the feed list, not a fixed
band above it. The top of that screen is already spoken for by the view
switcher (#19) and the composer, so the rail is walked past on every visit
and then scrolls away. It is also pinned into the feed's empty state,
where having somewhere to go matters most — including the tag feed's own
"nothing tagged X yet".

The window is a *request* parameter, never response metadata: the live
payload is { tags: [ { tag, count, lastUsedAt } ] } and reports nothing
about the period it covers, while the server silently falls back to `week`
for any value it does not recognise. So the app sends window=week
explicitly from a typed TrendingWindow and labels the rail from the window
it asked for — "Trending this week" is true because the request made it
true. lastUsedAt, the one recency the payload does report, is spoken in
each chip's accessibility label.

Parsing is defensive: rows may lack count or lastUsedAt, may null them,
and may carry keys the API adds later; a row with no usable tag is dropped
rather than rendered as a blank chip. Loading, tags, empty and error are
four explicit states decided in one place, so a quiet instance ("no
trending tags yet") can never be mistaken for a failed lookup (which gets
its own message and a Retry), and neither can ever be a blank strip.

Tests: the live payload shape and its odd rows (unit), the window and
limit on the wire plus the failure path (MockWebServer), the four states
and the tag surviving intact into the route (ViewModel), and the rendered
states, the tap, and the rail's two homes in the feed (Compose).

Closes #30
Kept both deep-link resolutions in MainActivity: #89's blog subscription
route from the queue and the tag-feed route this branch carries.
@Adron
Adron merged commit 1d105e1 into parity/queue Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant