Skip to content

feat(messages): tag-filtered feed, sharing the feed implementation (#29) - #122

Merged
Adron merged 1 commit into
parity/queuefrom
issue/29-tag-feed
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/29-tag-feed

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #29. Also fixes #97. Part of epic #27.

The tag feed is not a fork

MessagesFeedViewModel reads the tag from SavedStateHandle and is hosted twice from the NavHost.
#22's opaque cursor paging, #19's view-preference switcher, digs, edits, moderation and
pull-to-refresh are the same code under a different query. observeFeed/refreshFeed/
loadMoreFeed each took a tag: String? defaulting to the main feed, so every existing call site is
unchanged. The tag goes to Retrofit raw, so it is percent-encoded exactly once.

The composer is the one thing a tag feed omits — a new post cannot be filed into someone else's tag —
which also lets it skip two composer-only network calls.

The Room cache was the real design problem

The main feed and a tag feed routinely contain the same message, and a message row cannot hold
two positions — so keeping feedOrder on the message meant the second feed to load would steal
rows from the first
.

Feed membership moved to a feed_entry(feedKey, messageId, position) table over shared message rows
(feedKey = "" for the main feed, the tag itself otherwise; tags are never blank, so they cannot
collide). Refreshing a tag feed now clears only its own memberships; a dig/edit/delete in either feed
is seen by both; ON DELETE CASCADE drops a deleted message from every feed at once.

One sharp edge found and fixed while reviewing: @Insert(OnConflictStrategy.REPLACE) deletes
before re-inserting, which would have fired that cascade and silently evicted rows from the main
feed
. insertAll is now @Upsert, with the reason recorded in both the DAO and the entity.

The deep-link URL is verified, not guessed

/tag/lists, /tags/lists, /tags, /explore and /trending all 404; /messages turned out
to be Direct Messages, not the feed. Pulling the home page's RSC payload and JS chunk surfaced the
web's own tag chip:

A.tags.map(e => jsxs(Link, { href: "/?tag=".concat(encodeURIComponent(e)), … }))

and https://interlinedlist.com/?tag=lists does serve a filtered feed — verified, including for
tag=life is short, o brave girl. So the canonical URL is
https://interlinedlist.com/?tag=<url-encoded tag>.

⚠️ One product decision worth a second opinion

The manifest now registers https://interlinedlist.com/. An intent filter cannot match on a
query string
, so / is the narrowest filter that can catch /?tag=…. The consequence: a plain
homepage link will now also open the app, landing on its normal start destination — which shows the
same feed, so it is not broken, but it is a wider claim than the feature needs. Called out in a
manifest comment. Say if you would rather not claim it.

Also fixes #97

A newly created message was inserted at maxFeedOrder - 1 — near the bottom of a loaded feed
rather than the top, because the feed sorts ascending. It is now minPosition - 1, a genuine head
insert. The existing "at the top of the feed" test passed before only because it used a single-row
feed.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 1348 tests, 0 failures (234
in :feature:messages).

Tests cover: the tag on the wire verbatim; %20/%2C present but no %25, proving
single-encoding; tag alongside onlyMine; cursor paging on the filtered feed with the token verbatim
and no offset; the main feed untouched by a tag refresh; per-tag eviction isolation; cross-feed
dig and delete; 14 deep-link cases (web/www/http/custom-scheme, decode-once, other pages and
foreign hosts rejected, blank tag rejected, garbage → null). I also inspected the generated
MessageDao_Impl/MessagesDatabase_Impl to confirm the JOIN compiled, that message writes are
INSERT/UPDATE rather than INSERT OR REPLACE, and that the FK is created ON DELETE CASCADE
under PRAGMA foreign_keys = ON.

DB v5 → v6. Compose tests compile, not executed (no emulator).

Remaining risks

  • No Room SQL is exercised at test time. The repo has no Robolectric and no DAO tests, so the
    join table is verified only by Room's compile-time query validation plus the fake.
  • Orphan message rows: clearFeed now drops memberships rather than message rows, so rows that
    leave every feed linger. Bounded in practice, and the DB is fallbackToDestructiveMigration. A
    prune was deliberately not added, because the obvious one would delete a message the detail screen
    is currently showing.
  • A deep link arriving while signed out is dropped, exactly as notification routes already are.

MessagesDestinations.tagFeedRoute(tag) is the single entry point #30 (trending) will call.

Tapping a tag on a message card now opens that tag's feed, and a tag URL
from elsewhere deep-links straight to it.

The tag feed is not a second feed implementation: `MessagesFeedViewModel`
takes the tag from its nav argument and is hosted twice, so #22's opaque
cursor paging and #19's view-preference handling apply unchanged. The
repository's `observeFeed`/`refreshFeed`/`loadMoreFeed` each gained a
`tag` parameter that defaults to the main feed; the tag rides on the wire
raw so Retrofit percent-encodes it exactly once (tags are free-form and
really do contain spaces and commas).

Caching a second feed meant the Room cache could no longer store a
message's feed position on the message row: the main feed and a tag feed
routinely hold the same message, and one row cannot hold two positions.
Feed membership moved to a `feed_entry(feedKey, messageId, position)`
table over the shared message rows, so a tag feed neither reorders nor
evicts the main feed, while a dig, edit or delete made in either is seen
by both. Message rows are written with an upsert rather than
`@Insert(REPLACE)`, because REPLACE's delete would cascade through that
table and silently drop rows out of feeds they were already in.

The deep link was established against the live site rather than guessed:
the web renders each tag as `href="/?tag=" + encodeURIComponent(tag)` and
`https://interlinedlist.com/?tag=lists` really does serve a filtered feed,
while `/tag/<tag>` and `/tags/<tag>` both 404. `MessagesDestinations`
resolves that URL the same way `AuthRoutes.routeForEmailChangeLink` and
`NotificationLaunch` resolve theirs, so `MainActivity` stays a one-liner
and the rule is covered by JVM tests. An intent filter cannot match on a
query string, so the site root is what the manifest has to register.

`MessagesDestinations.tagFeedRoute(tag)` is the single entry point for
opening a tag feed, ready for the trending surface to call.

Tests: tag reaches the query exactly as given and is encoded once; the
filtered feed pages by cursor with no offset; refreshing or paging a tag
feed leaves the main feed's cached rows and order intact; deletes and digs
cross feeds; the deep link resolves and a non-tag URL does not; tapping a
tag hands back the whole tag, spaces and commas included.

Closes #29
@Adron

Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Merged into parity/queue. One conflict resolved on the way in: MainActivity.onCreate gained blog-link resolution from #88 (PR #121) and tag-link resolution here — independent additions, both kept. Re-verified after the merge: :app:assembleDebug testDebugUnitTest green.

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