feat(messages): tag-filtered feed, sharing the feed implementation (#29) - #122
Merged
Merged
Conversation
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
This was referenced Sep 16, 2026
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #29. Also fixes #97. Part of epic #27.
The tag feed is not a fork
MessagesFeedViewModelreads the tag fromSavedStateHandleand 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/loadMoreFeedeach took atag: String?defaulting to the main feed, so every existing call site isunchanged. 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
feedOrderon the message meant the second feed to load would stealrows 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 cannotcollide). Refreshing a tag feed now clears only its own memberships; a dig/edit/delete in either feed
is seen by both;
ON DELETE CASCADEdrops a deleted message from every feed at once.One sharp edge found and fixed while reviewing:
@Insert(OnConflictStrategy.REPLACE)deletesbefore re-inserting, which would have fired that cascade and silently evicted rows from the main
feed.
insertAllis 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,/exploreand/trendingall 404;/messagesturned outto be Direct Messages, not the feed. Pulling the home page's RSC payload and JS chunk surfaced the
web's own tag chip:
and
https://interlinedlist.com/?tag=listsdoes serve a filtered feed — verified, including fortag=life is short, o brave girl. So the canonical URL ishttps://interlinedlist.com/?tag=<url-encoded tag>.The manifest now registers
https://interlinedlist.com/. An intent filter cannot match on aquery string, so
/is the narrowest filter that can catch/?tag=…. The consequence: a plainhomepage 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 feedrather than the top, because the feed sorts ascending. It is now
minPosition - 1, a genuine headinsert. 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 (234in
:feature:messages).Tests cover: the tag on the wire verbatim;
%20/%2Cpresent but no%25, provingsingle-encoding; tag alongside
onlyMine; cursor paging on the filtered feed with the token verbatimand no
offset; the main feed untouched by a tag refresh; per-tag eviction isolation; cross-feeddig and delete; 14 deep-link cases (web/
www/http/custom-scheme, decode-once, other pages andforeign hosts rejected, blank tag rejected, garbage → null). I also inspected the generated
MessageDao_Impl/MessagesDatabase_Implto confirm the JOIN compiled, that message writes areINSERT/UPDATErather thanINSERT OR REPLACE, and that the FK is createdON DELETE CASCADEunder
PRAGMA foreign_keys = ON.DB v5 → v6. Compose tests compile, not executed (no emulator).
Remaining risks
join table is verified only by Room's compile-time query validation plus the fake.
clearFeednow drops memberships rather than message rows, so rows thatleave every feed linger. Bounded in practice, and the DB is
fallbackToDestructiveMigration. Aprune was deliberately not added, because the obvious one would delete a message the detail screen
is currently showing.
MessagesDestinations.tagFeedRoute(tag)is the single entry point #30 (trending) will call.