From 9ab2c93beeef5f6e6467d2decffe73e1faba8725 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:19:23 -0700 Subject: [PATCH] feat(messages): tag-filtered feed via GET /api/messages?tag= 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/` and `/tags/` 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 --- app/src/main/AndroidManifest.xml | 9 + .../interlinedlist/android/MainActivity.kt | 6 + .../navigation/InterlinedListNavHost.kt | 31 ++- .../ui/feed/MessagesFeedScreenTest.kt | 65 +++++ .../data/DefaultMessagesRepository.kt | 113 +++++--- .../messages/data/MessagesRepository.kt | 23 +- .../messages/data/local/FeedEntryEntity.kt | 53 ++++ .../feature/messages/data/local/MessageDao.kt | 68 +++-- .../messages/data/local/MessageEntity.kt | 7 +- .../messages/data/local/MessagesDatabase.kt | 4 +- .../messages/data/remote/MessagesApi.kt | 7 + .../navigation/MessagesDestinations.kt | 40 +++ .../messages/navigation/TagFeedLink.kt | 90 +++++++ .../messages/ui/components/MessageCard.kt | 19 +- .../messages/ui/detail/MessageDetailScreen.kt | 8 + .../messages/ui/feed/MessagesFeedScreen.kt | 80 +++++- .../messages/ui/feed/MessagesFeedViewModel.kt | 38 ++- .../feature/messages/data/FakeMessageDao.kt | 56 +++- .../feature/messages/data/TagFeedTest.kt | 246 ++++++++++++++++++ .../messages/navigation/TagFeedLinkTest.kt | 118 +++++++++ .../messages/ui/FakeMessagesRepository.kt | 39 ++- .../messages/ui/feed/TagFeedViewModelTest.kt | 150 +++++++++++ 22 files changed, 1177 insertions(+), 93 deletions(-) create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/FeedEntryEntity.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/MessagesDestinations.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLink.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TagFeedTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLinkTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/TagFeedViewModelTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03d93a3..53c2f14 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,13 @@ destinations (shared list/document tokens, password reset, email verification, and the confirm/undo halves of an email change). autoVerify enables Android App Links verification. + + The site root is registered for the tag feed: the web links every + tag on a message card to "/?tag=" (there is no /tag/ + path — it 404s), and an intent filter cannot match on a query + string. So "/" is the narrowest filter that can catch a tag link. + A root link with no tag simply opens the app's normal start + destination, which shows the same feed the homepage does. --> @@ -37,6 +44,7 @@ + @@ -50,6 +58,7 @@ + diff --git a/app/src/main/java/com/interlinedlist/android/MainActivity.kt b/app/src/main/java/com/interlinedlist/android/MainActivity.kt index 3a58463..1ebf09d 100644 --- a/app/src/main/java/com/interlinedlist/android/MainActivity.kt +++ b/app/src/main/java/com/interlinedlist/android/MainActivity.kt @@ -13,6 +13,7 @@ import com.interlinedlist.android.core.datastore.ThemeMode import com.interlinedlist.android.core.datastore.ThemeSettingsStore import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.auth.nav.AuthRoutes +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations import com.interlinedlist.android.navigation.InterlinedListNavHost import com.interlinedlist.android.navigation.NotificationLaunch import dagger.hilt.android.AndroidEntryPoint @@ -40,6 +41,10 @@ class MainActivity : ComponentActivity() { // the VIEW intent's data. Both endpoints behind it are unauthenticated, so the // route resolves regardless of whether a session exists. val emailChangeRoute = AuthRoutes.routeForEmailChangeLink(intent?.dataString) + // A tapped tag link (`https://interlinedlist.com/?tag=…`, the URL the web's + // own tag chips point at) resolves to the tag-filtered feed. Resolved here + // rather than by implicit nav matching so the rule is unit-testable. + val tagFeedRoute = MessagesDestinations.routeForTagLink(intent?.dataString) enableEdgeToEdge() setContent { val themeMode by themeSettingsStore.themeMode.collectAsStateWithLifecycle() @@ -53,6 +58,7 @@ class MainActivity : ComponentActivity() { startLoggedIn = startLoggedIn, notificationRoute = notificationRoute, emailChangeRoute = emailChangeRoute, + tagFeedRoute = tagFeedRoute, ) } } diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 4d805dc..71a7967 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -59,6 +59,7 @@ import com.interlinedlist.android.feature.lists.ui.share.ShareRoute import com.interlinedlist.android.feature.lists.ui.share.SharedListRoute import com.interlinedlist.android.feature.lists.ui.share.SharedWithMeRoute import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute @@ -193,6 +194,7 @@ fun InterlinedListNavHost( startLoggedIn: Boolean, notificationRoute: String? = null, emailChangeRoute: String? = null, + tagFeedRoute: String? = null, ) { val navController = rememberNavController() NavHost( @@ -215,7 +217,8 @@ fun InterlinedListNavHost( composable(Routes.MAIN) { val context = LocalContext.current MainShell( - notificationRoute = notificationRoute, + // A launch is either a notification tap or a link tap, never both. + pendingRoute = notificationRoute ?: tagFeedRoute, onLoggedOut = { // Stop background sync/poll for the signed-out session. Cancellation // must never crash the sign-out flow, so any failure is swallowed. @@ -244,7 +247,7 @@ fun InterlinedListNavHost( */ @Composable private fun MainShell( - notificationRoute: String? = null, + pendingRoute: String? = null, onLoggedOut: () -> Unit, ) { val tabNav = rememberNavController() @@ -278,10 +281,11 @@ private fun MainShell( val pushRegistration: PushRegistrationViewModel = hiltViewModel() LaunchedEffect(Unit) { pushRegistration.runForSession() } - // Route straight to a tapped notification's destination once, when present. - val pendingRoute by rememberUpdatedState(notificationRoute) + // Route straight to the launch's destination once, when present: a tapped + // notification, or a tapped link (a tag feed) resolved in MainActivity. + val launchRoute by rememberUpdatedState(pendingRoute) LaunchedEffect(Unit) { - pendingRoute?.let { route -> + launchRoute?.let { route -> runCatching { tabNav.navigate(route) } } } @@ -321,6 +325,22 @@ private fun MainShell( MessagesRoute( onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, onOpenScheduled = { tabNav.navigate(Routes.MESSAGES_SCHEDULED) }, + onOpenTag = { tag -> tabNav.navigate(MessagesDestinations.tagFeedRoute(tag)) }, + ) + } + // The same feed screen, filtered to one tag. The tag arrives as a nav + // argument, so paging and the view switcher are shared, not forked. + composable( + MessagesDestinations.TAG_FEED, + arguments = listOf( + navArgument(MessagesDestinations.ARG_TAG) { type = NavType.StringType }, + ), + ) { + MessagesRoute( + onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, + onOpenScheduled = {}, + onOpenTag = { tag -> tabNav.navigate(MessagesDestinations.tagFeedRoute(tag)) }, + onBack = { tabNav.popBackStack() }, ) } composable( @@ -330,6 +350,7 @@ private fun MainShell( MessageDetailRoute( onBack = { tabNav.popBackStack() }, onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, + onOpenTag = { tag -> tabNav.navigate(MessagesDestinations.tagFeedRoute(tag)) }, ) } composable(Routes.MESSAGES_SCHEDULED) { diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt index 37dcb09..6596700 100644 --- a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt +++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt @@ -79,6 +79,8 @@ class MessagesFeedScreenTest { onPush: (Message) -> Unit = {}, onQuote: (Message) -> Unit = {}, onSelectTagSuggestion: (TagSuggestion) -> Unit = {}, + onOpenTag: ((String) -> Unit)? = null, + onBack: () -> Unit = {}, ) { composeRule.setContent { var state by mutableStateOf(initial) @@ -137,6 +139,8 @@ class MessagesFeedScreenTest { state = state.copy(isComposeOpen = true, quoteTarget = quoted) onQuote(quoted) }, + onOpenTag = onOpenTag, + onBack = onBack, ) } } @@ -593,6 +597,67 @@ class MessagesFeedScreenTest { composeRule.onNodeWithText(tag).assertIsDisplayed() } + @Test + fun tappingATag_opensThatTagsFeed() { + val opened = mutableListOf() + setFeed( + MessagesFeedUiState(messages = listOf(message("1", "tagged post", tags = listOf("lists")))), + onOpenTag = { opened += it }, + ) + + composeRule.onNodeWithTag(MessageCardTags.tagTag("lists")).performClick() + + assertThat(opened).containsExactly("lists") + } + + @Test + fun tappingATagWithSpacesAndPunctuation_passesItWhole() { + val tag = "life is short, o brave girl" + val opened = mutableListOf() + setFeed( + MessagesFeedUiState(messages = listOf(message("1", "tagged", tags = listOf(tag)))), + onOpenTag = { opened += it }, + ) + + composeRule.onNodeWithTag(MessageCardTags.tagTag(tag)).performClick() + + // Exactly the tag the card carried: not trimmed, split or lowercased. + assertThat(opened).containsExactly(tag) + } + + @Test + fun tags_areInert_whereTheHostWiresNoTagDestination() { + setFeed(MessagesFeedUiState(messages = listOf(message("1", "tagged", tags = listOf("lists"))))) + composeRule.onNodeWithTag(MessageCardTags.tagTag("lists")).assertHasNoClickAction() + } + + @Test + fun tagFeed_showsTheTagAndABackArrow_insteadOfTheComposerAndScheduled() { + val backs = mutableListOf() + setFeed( + MessagesFeedUiState( + tag = "lists", + messages = listOf(message("1", "tagged", tags = listOf("lists"))), + ), + onBack = { backs += Unit }, + ) + + composeRule.onNodeWithTag(MessagesFeedTags.TAG_TITLE).assertIsDisplayed() + // Composing here would post an untagged message into a feed it cannot join. + composeRule.onNodeWithTag(MessagesFeedTags.FAB).assertDoesNotExist() + composeRule.onNodeWithTag(MessagesFeedTags.SCHEDULED_ACTION).assertDoesNotExist() + + composeRule.onNodeWithTag(MessagesFeedTags.BACK).performClick() + assertThat(backs).hasSize(1) + } + + @Test + fun tagFeed_keepsTheViewPreferenceSwitcher() { + // The tag feed is a normal feed: the account's view preference still applies. + setFeed(MessagesFeedUiState(tag = "lists", messages = listOf(message("1", "tagged")))) + composeRule.onNodeWithTag(MessagesFeedTags.VIEW_PREFERENCES).assertIsDisplayed() + } + @Test fun noTagRow_isShown_forAnUntaggedMessage() { setFeed(MessagesFeedUiState(messages = listOf(message("1", "plain")))) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt index bf7ce67..8a04592 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt @@ -4,12 +4,16 @@ import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.datastore.SessionStore import com.interlinedlist.android.core.model.ViewingPreference +import com.interlinedlist.android.feature.messages.data.local.FeedEntryEntity +import com.interlinedlist.android.feature.messages.data.local.MAIN_FEED_KEY import com.interlinedlist.android.feature.messages.data.local.MessageDao +import com.interlinedlist.android.feature.messages.data.local.feedKeyFor import com.interlinedlist.android.feature.messages.data.local.toDomain import com.interlinedlist.android.feature.messages.data.local.toEntity import com.interlinedlist.android.feature.messages.data.remote.MessagesApi import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest import com.interlinedlist.android.feature.messages.data.remote.dto.EditMessageRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.MessageDto import com.interlinedlist.android.feature.messages.data.remote.dto.PaginationDto import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest @@ -48,8 +52,8 @@ class DefaultMessagesRepository @Inject constructor( private val dispatchers: DispatcherProvider, ) : MessagesRepository { - override fun observeFeed(): Flow> = - messageDao.observeFeed().map { rows -> rows.map { it.toDomain() } } + override fun observeFeed(tag: String?): Flow> = + messageDao.observeFeed(feedKeyFor(tag)).map { rows -> rows.map { it.toDomain() } } override fun observeReplies(messageId: String): Flow> = messageDao.observeReplies(messageId).map { rows -> rows.map { it.toDomain() } } @@ -61,51 +65,58 @@ class DefaultMessagesRepository @Inject constructor( messageDao.observeScheduled().map { rows -> rows.map { it.toDomain() } } /** - * Loads the head of the feed (no cursor) and replaces the cached feed with it, + * Loads the head of the feed (no cursor) and replaces that feed's cached rows, * restarting keyset pagination. Returns the next page's opaque cursor. + * + * A non-null [tag] runs exactly the same request with `tag=` added and + * replaces only *that* tag feed's membership — the message rows themselves are + * shared, so the main feed keeps its own ordering and loses nothing. */ - override suspend fun refreshFeed(preference: ViewingPreference): ApiResult = - withContext(dispatchers.io) { - when (val result = safeCall { - api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, onlyMine = preference.onlyMine) - }) { - is ApiResult.Success -> { - val page = result.data - val entities = page.rows.mapIndexed { index, dto -> - dto.toDomain(currentUserId()).toEntity(feedOrder = index.toLong()) - } - messageDao.clearFeed() - messageDao.insertAll(entities) - ApiResult.Success(page.nextCursor) - } - is ApiResult.Failure -> result + override suspend fun refreshFeed( + preference: ViewingPreference, + tag: String?, + ): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { + api.getMessages( + limit = PaginationDto.DEFAULT_LIMIT, + onlyMine = preference.onlyMine, + // Raw: Retrofit percent-encodes the tag exactly once. + tag = tag, + ) + }) { + is ApiResult.Success -> { + val page = result.data + messageDao.clearFeed(feedKeyFor(tag)) + cacheFeedPage(page.rows, tag = tag, firstPosition = 0L) + ApiResult.Success(page.nextCursor) } + is ApiResult.Failure -> result } + } /** * Appends the page following [cursor] to the tail of the cached feed. The - * cursor is opaque: it goes back to the API exactly as it arrived. Rows are - * keyed by id, so a row the server happens to repeat updates in place rather - * than duplicating. + * cursor is opaque: it goes back to the API exactly as it arrived, alongside + * the same [tag] the chain started under. Rows are keyed by id, so a row the + * server happens to repeat updates in place rather than duplicating. */ override suspend fun loadMoreFeed( cursor: String, preference: ViewingPreference, + tag: String?, ): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { api.getMessages( limit = PaginationDto.DEFAULT_LIMIT, cursor = cursor, onlyMine = preference.onlyMine, + tag = tag, ) }) { is ApiResult.Success -> { val page = result.data - val base = (messageDao.maxFeedOrder() ?: -1L) + 1L - val entities = page.rows.mapIndexed { index, dto -> - dto.toDomain(currentUserId()).toEntity(feedOrder = base + index) - } - messageDao.insertAll(entities) + val base = (messageDao.maxFeedPosition(feedKeyFor(tag)) ?: -1L) + 1L + cacheFeedPage(page.rows, tag = tag, firstPosition = base) ApiResult.Success(page.nextCursor) } is ApiResult.Failure -> result @@ -158,9 +169,20 @@ class DefaultMessagesRepository @Inject constructor( // Scheduled messages are cached in the scheduled view, not the feed. messageDao.upsert(message.toEntity(feedOrder = 0L)) } else { - // Insert at the very top of the feed. - val topOrder = (messageDao.maxFeedOrder() ?: 0L) - messageDao.upsert(message.toEntity(feedOrder = topOrder - 1L)) + // Insert at the very top of the main feed. A tag feed is a + // filtered view of the server's own answer, so a just-posted + // message only joins one once that feed is reloaded. + val head = (messageDao.minFeedPosition(MAIN_FEED_KEY) ?: 0L) - 1L + messageDao.upsert(message.toEntity(feedOrder = head)) + messageDao.upsertFeedEntries( + listOf( + FeedEntryEntity( + feedKey = MAIN_FEED_KEY, + messageId = message.id, + position = head, + ), + ), + ) } val crossPosts = result.data.crossPosts.mapNotNull { it.toDomainOrNull() } ApiResult.Success(CreatedMessage(message = message, crossPosts = crossPosts)) @@ -245,7 +267,7 @@ class DefaultMessagesRepository @Inject constructor( }) { is ApiResult.Success -> { val reply = result.data.data.toDomain(currentUserId()).copy(parentId = parentId) - val base = (messageDao.maxFeedOrder() ?: 0L) + 1L + val base = (messageDao.maxMessageOrder() ?: 0L) + 1L messageDao.upsert(reply.toEntity(feedOrder = base)) // Reflect the new reply count on the parent if it is cached. bumpReplyCount(parentId, delta = 1) @@ -458,6 +480,35 @@ class DefaultMessagesRepository @Inject constructor( private val ViewingPreference.onlyMine: Boolean? get() = true.takeIf { this == ViewingPreference.MINE } + /** + * Writes one page of a feed: the message rows (shared by every feed, so a dig + * or an edit made anywhere shows up everywhere), then this feed's membership + * starting at [firstPosition]. The two halves are what keep feeds independent + * — appending to a tag feed adds entries under its own key and never rewrites + * another feed's positions. + */ + private suspend fun cacheFeedPage( + rows: List, + tag: String?, + firstPosition: Long, + ) { + val messages = rows.map { it.toDomain(currentUserId()) } + messageDao.insertAll( + messages.mapIndexed { index, message -> + message.toEntity(feedOrder = firstPosition + index) + }, + ) + messageDao.upsertFeedEntries( + messages.mapIndexed { index, message -> + FeedEntryEntity( + feedKey = feedKeyFor(tag), + messageId = message.id, + position = firstPosition + index, + ) + }, + ) + } + private suspend fun safeCall(block: suspend () -> T): ApiResult = safeApiCall(json, block) @@ -513,7 +564,7 @@ class DefaultMessagesRepository @Inject constructor( private suspend fun currentEntity(id: String) = messageDao.observeMessage(id).first() private suspend fun existingOrderOrTop(id: String): Long = - currentEntity(id)?.feedOrder ?: ((messageDao.maxFeedOrder() ?: 0L) + 1L) + currentEntity(id)?.feedOrder ?: ((messageDao.maxMessageOrder() ?: 0L) + 1L) private suspend fun bumpReplyCount(parentId: String, delta: Int) { val parent = currentEntity(parentId) ?: return diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt index 43df63a..7f48c3f 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt @@ -18,8 +18,16 @@ import kotlinx.coroutines.flow.Flow */ interface MessagesRepository { - /** The cached top-level feed, newest-first, re-emitting on every change. */ - fun observeFeed(): Flow> + /** + * The cached top-level feed, newest-first, re-emitting on every change. + * + * [tag] selects *which* feed: null is the account's main feed, a non-null tag + * is the feed filtered to that tag (`GET /api/messages?tag=`). Both are the + * same feed in every other respect — same cursor paging, same view preference, + * same cached message rows — they differ only in which rows belong to them, so + * loading a tag feed never disturbs the main one. + */ + fun observeFeed(tag: String? = null): Flow> /** Cached replies to [messageId], re-emitting on every change. */ fun observeReplies(messageId: String): Flow> @@ -40,21 +48,28 @@ interface MessagesRepository { * the following/followers scopes are applied by the server from the saved * preference, which is why [setViewingPreference] must succeed before a * refresh can show a different view. + * + * [tag] scopes the request (and the cache it replaces) to one tag's feed; null + * refreshes the main feed. Refreshing a tag feed leaves the main feed's cached + * rows exactly where they were. */ suspend fun refreshFeed( preference: ViewingPreference = ViewingPreference.DEFAULT, + tag: String? = null, ): ApiResult /** * Fetches the page that follows [cursor] and appends it to the cached feed. * [cursor] is the opaque token a previous [refreshFeed]/[loadMoreFeed] * returned and is handed to the API verbatim — never construct or parse one. - * [preference] must match the one the page chain started under. Returns the - * cursor for the page after this one, or null at the end. + * [preference] and [tag] must match the ones the page chain started under — + * the cursor is only meaningful within the query that produced it. + * Returns the cursor for the page after this one, or null at the end. */ suspend fun loadMoreFeed( cursor: String, preference: ViewingPreference = ViewingPreference.DEFAULT, + tag: String? = null, ): ApiResult /** diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/FeedEntryEntity.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/FeedEntryEntity.kt new file mode 100644 index 0000000..e995109 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/FeedEntryEntity.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.messages.data.local + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * Membership of one cached message in one feed, with its position in that feed. + * + * Feeds are *lists over* the shared [MessageEntity] rows, not copies of them: the + * main feed and a tag-filtered feed routinely contain the same message, and both + * must see the same dig count, edits and deletions. Keeping membership in its own + * table is what lets a tag feed be cached at all without evicting the main feed — + * a message row can only hold one position, so storing the position on the message + * would mean the second feed to load silently stole rows from the first. + * + * [feedKey] identifies the feed: [MAIN_FEED_KEY] (the empty string) for the main + * feed, and the tag itself for a tag feed. Tags are never blank, so the two spaces + * cannot collide. + * + * The foreign key cascades, so deleting a message (own-message delete, block, mute) + * drops it from every feed it appeared in without extra bookkeeping. That cascade + * is also why message rows are written with an **upsert** rather than + * `@Insert(REPLACE)` — see [MessageDao.insertAll]. + */ +@Entity( + tableName = "feed_entry", + primaryKeys = ["feedKey", "messageId"], + foreignKeys = [ + ForeignKey( + entity = MessageEntity::class, + parentColumns = ["id"], + childColumns = ["messageId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("messageId")], +) +data class FeedEntryEntity( + val feedKey: String, + val messageId: String, + /** Server-relative position within this feed, captured at fetch time. */ + val position: Long, +) + +/** Feed key of the account's main (unfiltered) feed. */ +const val MAIN_FEED_KEY: String = "" + +/** + * The [FeedEntryEntity.feedKey] a feed scoped to [tag] stores its rows under; a + * null or blank tag is the main feed. + */ +fun feedKeyFor(tag: String?): String = tag?.takeIf { it.isNotBlank() } ?: MAIN_FEED_KEY diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt index a19a63a..b8e2282 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt @@ -1,8 +1,6 @@ package com.interlinedlist.android.feature.messages.data.local import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @@ -11,12 +9,22 @@ import kotlinx.coroutines.flow.Flow interface MessageDao { /** - * Top-level feed messages in server order; re-emits on every change. - * Scheduled (not-yet-published) messages are excluded — they live in their - * own view, not the public feed. + * Top-level messages of the feed [feedKey] identifies, in server order; + * re-emits on every change. Scheduled (not-yet-published) messages are + * excluded — they live in their own view, not a feed. + * + * Membership comes from `feed_entry`, so the main feed and a tag feed share + * the underlying message rows without either owning them. */ - @Query("SELECT * FROM message WHERE parentId IS NULL AND scheduledAt IS NULL ORDER BY feedOrder ASC") - fun observeFeed(): Flow> + @Query( + """ + SELECT m.* FROM message AS m + INNER JOIN feed_entry AS f ON f.messageId = m.id + WHERE f.feedKey = :feedKey AND m.parentId IS NULL AND m.scheduledAt IS NULL + ORDER BY f.position ASC + """, + ) + fun observeFeed(feedKey: String): Flow> /** Direct replies to a message in server order. */ @Query("SELECT * FROM message WHERE parentId = :parentId ORDER BY feedOrder ASC") @@ -30,31 +38,61 @@ interface MessageDao { @Query("SELECT * FROM message WHERE scheduledAt IS NOT NULL ORDER BY scheduledAt ASC") fun observeScheduled(): Flow> - @Insert(onConflict = OnConflictStrategy.REPLACE) + /** + * Writes message rows, updating the ones already cached. + * + * Deliberately an upsert and **not** `@Insert(REPLACE)`: REPLACE deletes the + * conflicting row before re-inserting it, which would fire `feed_entry`'s + * ON DELETE CASCADE and silently drop that message out of every feed it was + * already in — so re-fetching a message in a tag feed would evict it from the + * main feed. An upsert updates in place and leaves memberships alone. + */ + @Upsert suspend fun insertAll(messages: List) @Upsert suspend fun upsert(message: MessageEntity) + /** Places (or moves) messages within a feed. Message rows must exist first. */ + @Upsert + suspend fun upsertFeedEntries(entries: List) + @Query("DELETE FROM message WHERE id = :id") suspend fun deleteById(id: String) /** * Removes every cached message authored by [username] (used to hide a blocked - * or muted author's messages from the local feed/replies immediately). + * or muted author's messages from the local feed/replies immediately). Feed + * memberships cascade away with the rows. */ @Query("DELETE FROM message WHERE authorUsername = :username") suspend fun deleteByAuthorUsername(username: String) - /** Clears the top-level feed (used before writing a fresh refresh page). */ - @Query("DELETE FROM message WHERE parentId IS NULL AND scheduledAt IS NULL") - suspend fun clearFeed() + /** + * Empties the feed [feedKey] identifies, before writing a fresh refresh page. + * Only that feed's membership is dropped: the message rows stay, so another + * feed holding the same messages is untouched. + */ + @Query("DELETE FROM feed_entry WHERE feedKey = :feedKey") + suspend fun clearFeed(feedKey: String) /** Clears the cached scheduled messages (used before a fresh refresh). */ @Query("DELETE FROM message WHERE scheduledAt IS NOT NULL") suspend fun clearScheduled() - /** Largest feed-order position currently stored (for append/load-more). */ - @Query("SELECT MAX(feedOrder) FROM message WHERE parentId IS NULL AND scheduledAt IS NULL") - suspend fun maxFeedOrder(): Long? + /** Last position currently stored in a feed (for append/load-more). */ + @Query("SELECT MAX(position) FROM feed_entry WHERE feedKey = :feedKey") + suspend fun maxFeedPosition(feedKey: String): Long? + + /** First position currently stored in a feed (for inserting at the head). */ + @Query("SELECT MIN(position) FROM feed_entry WHERE feedKey = :feedKey") + suspend fun minFeedPosition(feedKey: String): Long? + + /** + * Largest sort position stored on any cached message row. Used to park a + * message the app fetched outside a feed (a reply, a detail-screen fetch) + * after everything already cached, rather than in front of it. + */ + @Query("SELECT MAX(feedOrder) FROM message") + suspend fun maxMessageOrder(): Long? } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt index b720b77..d1845e8 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt @@ -25,7 +25,12 @@ data class MessageEntity( val dugByMe: Boolean, val parentId: String?, val mine: Boolean, - /** Server-relative ordering position captured at fetch time (feed order). */ + /** + * Server-relative ordering position captured at fetch time, used to order a + * message's **replies**. A message's place in a *feed* is not stored here — + * the same message can sit in the main feed and in any number of tag feeds at + * once — but in [FeedEntryEntity]. + */ val feedOrder: Long, /** Attached image URLs, stored via [MessageConverters]. */ val imageUrls: List = emptyList(), diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt index edc3eef..7854884 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt @@ -9,8 +9,8 @@ import androidx.room.TypeConverters * `InterlinedListDatabase`. A disposable cache during early development. */ @Database( - entities = [MessageEntity::class], - version = 5, + entities = [MessageEntity::class, FeedEntryEntity::class], + version = 6, exportSchema = false, ) @TypeConverters(MessageConverters::class) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt index 37c4c8b..827ff16 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt @@ -43,12 +43,19 @@ interface MessagesApi { * `offset`, `onlyMine` and `tag`): there is no following/followers parameter, * because the server scopes the feed by the account's saved * `viewingPreference`. Null omits the parameter. + * + * [tag] filters the feed to messages carrying that tag. Tags are free-form and + * routinely contain spaces and punctuation (`life is short, o brave girl` is a + * real one), so the value is passed **raw** and Retrofit percent-encodes it + * exactly once — pre-encoding here would double-encode it and match nothing. + * Null omits the parameter, which is the unfiltered feed. */ @GET("api/messages") suspend fun getMessages( @Query("limit") limit: Int, @Query("cursor") cursor: String? = null, @Query("onlyMine") onlyMine: Boolean? = null, + @Query("tag") tag: String? = null, ): MessagesResponse /** Creates a new message (or a reply when `parentId` is set). The created diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/MessagesDestinations.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/MessagesDestinations.kt new file mode 100644 index 0000000..3d4fd95 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/MessagesDestinations.kt @@ -0,0 +1,40 @@ +package com.interlinedlist.android.feature.messages.navigation + +import java.net.URLEncoder + +/** + * Route keys this module owns, kept next to the screens that consume them so the + * app can host them without hand-building route strings. + */ +object MessagesDestinations { + + /** Nav argument carrying the tag a feed is filtered to. */ + const val ARG_TAG = "tag" + + /** Feed filtered to one tag; build a concrete route with [tagFeedRoute]. */ + const val TAG_FEED = "messages/tag/{$ARG_TAG}" + + /** + * Route for the feed of [tag] — the single entry point for opening a tag feed, + * whether from a tag on a message card, a trending list, or a deep link. + * + * The tag is percent-encoded into the path segment (with `%20` rather than + * `+`, which Navigation would hand back literally), because a tag is free-form + * and may contain spaces, commas and even slashes. + */ + fun tagFeedRoute(tag: String): String = "messages/tag/${encode(tag)}" + + /** + * Maps a tapped tag URL onto an in-app route, or null when the URI is not one. + * + * The app resolves the launch intent through here — the same way a tapped + * notification goes through `NotificationLaunch` and an email-change link goes + * through `AuthRoutes.routeForEmailChangeLink` — rather than relying on + * implicit `navDeepLink` matching, so the behaviour is covered by plain unit + * tests instead of only firing on a real device. + */ + fun routeForTagLink(uri: String?): String? = TagFeedLink.parse(uri)?.let(::tagFeedRoute) + + private fun encode(value: String): String = + URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20") +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLink.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLink.kt new file mode 100644 index 0000000..3a8c10b --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLink.kt @@ -0,0 +1,90 @@ +package com.interlinedlist.android.feature.messages.navigation + +import java.net.URI +import java.net.URLDecoder + +/** + * A tapped "show me this tag" link. + * + * The web renders every tag on a message card as a link to the home feed with a + * `tag` query parameter — `` — so the + * canonical tag URL is `https://interlinedlist.com/?tag=` and nothing else: + * `/tag/` and `/tags/` both 404 on the live site. + * + * Kept as plain JVM code (no `android.net.Uri`) so the parsing rules are covered by + * fast unit tests, exactly like the auth module's `EmailChangeLink`. + */ +object TagFeedLink { + + /** Query parameter carrying the tag, on both the web and app-scheme links. */ + const val TAG_PARAM = "tag" + + /** Host of the web links; also matched with a `www.` prefix. */ + const val WEB_HOST = "interlinedlist.com" + + /** Custom scheme the app registers for the same link. */ + const val APP_SCHEME = "interlinedlist" + + /** Authority of the custom-scheme form, `interlinedlist://tag?tag=…`. */ + const val APP_AUTHORITY = "tag" + + private val WEB_SCHEMES = setOf("https", "http") + + /** + * Parses [uri] into the tag it selects, or returns null when it is not a tag + * link. + * + * Recognised shapes (scheme/host case-insensitive, extra query parameters + * tolerated): + * - `https://interlinedlist.com/?tag=…` (and without the trailing slash) + * - `interlinedlist://tag?tag=…` + * + * The tag is returned **percent-decoded and verbatim otherwise**: tags are + * free-form and legitimately contain spaces, commas and case + * ("life is short, o brave girl" is a real one), so nothing here trims, + * lowercases or splits them. + * + * A link to any other path, a foreign host, a missing or blank tag, or a + * string that is not a URI at all yields null rather than an exception — a + * deep link must never crash the launch. + */ + fun parse(uri: String?): String? { + val trimmed = uri?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val parsed = runCatching { URI(trimmed) }.getOrNull() ?: return null + if (!parsed.isTagTarget()) return null + return parsed.rawQuery.queryParam(TAG_PARAM)?.takeIf { it.isNotBlank() } + } + + /** True when this URI addresses the tag surface (and not some other page). */ + private fun URI.isTagTarget(): Boolean { + val scheme = scheme?.lowercase() ?: return false + return when { + scheme in WEB_SCHEMES -> { + val host = host?.lowercase()?.removePrefix("www.") ?: return false + // The tag feed is the site *root* with a `tag` query, so any other + // path (a shared list, a profile) is deliberately not a tag link. + host == WEB_HOST && path.orEmpty().trim('/').isEmpty() + } + scheme == APP_SCHEME -> + (host ?: authority).orEmpty().lowercase() == APP_AUTHORITY && + path.orEmpty().trim('/').isEmpty() + else -> false + } + } + + /** Reads a single percent-decoded query parameter out of a raw query string. */ + private fun String?.queryParam(name: String): String? = this + ?.split('&') + ?.firstNotNullOfOrNull { pair -> + val separator = pair.indexOf('=') + if (separator <= 0) return@firstNotNullOfOrNull null + val key = pair.substring(0, separator).decodeOrNull() + if (!key.equals(name, ignoreCase = true)) return@firstNotNullOfOrNull null + pair.substring(separator + 1).decodeOrNull() + } + + /** Percent-decodes a query component, falling back to the raw text. */ + private fun String.decodeOrNull(): String? = + runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this) +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt index 9ef7ab6..df63f65 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt @@ -92,6 +92,9 @@ object MessageCardTags { * * [onPush] / [onQuote] are null where the host screen does not wire the actions; * they are also withheld for a message [Message.canBePushed] rules out. + * + * [onTagClick] opens the tag-filtered feed for a tapped tag; it is null on screens + * that have nowhere to send the user, and the tags then render as plain labels. */ @Composable fun MessageCard( @@ -109,6 +112,7 @@ fun MessageCard( onPush: (() -> Unit)? = null, onQuote: (() -> Unit)? = null, onOpenPushedMessage: (String) -> Unit = {}, + onTagClick: ((String) -> Unit)? = null, ) { Column( modifier = modifier @@ -184,7 +188,7 @@ fun MessageCard( } if (message.hasTags) { Spacer(Modifier.size(8.dp)) - TagRow(tags = message.tags) + TagRow(tags = message.tags, onTagClick = onTagClick) } Spacer(Modifier.size(8.dp)) EngagementRow( @@ -204,10 +208,14 @@ fun MessageCard( * extra fetch. Rendered as plain labels: a tag is a free-form string that may * contain spaces and punctuation, so it is shown exactly as stored rather than * being prettified into a hashtag. + * + * Each label opens that tag's feed when [onTagClick] is wired. The click sits + * outside the chip's padding so the whole chip is the target, and adds no layout + * of its own. */ @OptIn(ExperimentalLayoutApi::class) @Composable -private fun TagRow(tags: List) { +private fun TagRow(tags: List, onTagClick: ((String) -> Unit)? = null) { FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -223,6 +231,13 @@ private fun TagRow(tags: List) { modifier = Modifier .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceVariant) + .then( + if (onTagClick != null) { + Modifier.clickable { onTagClick(tag) } + } else { + Modifier + }, + ) .padding(horizontal = 8.dp, vertical = 2.dp) .testTag(MessageCardTags.tagTag(tag)), ) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt index 9ad4177..681f05c 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt @@ -61,12 +61,14 @@ object MessageDetailTags { * * @param onBack pops the detail screen off the back stack. * @param onOpenMessage navigates into a reply (which is itself a message). + * @param onOpenTag opens the feed filtered to a tapped tag; null leaves tags inert. */ @Composable fun MessageDetailRoute( onBack: () -> Unit, onOpenMessage: (String) -> Unit, modifier: Modifier = Modifier, + onOpenTag: ((String) -> Unit)? = null, viewModel: MessageDetailViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -74,6 +76,7 @@ fun MessageDetailRoute( state = state, onBack = onBack, onOpenMessage = onOpenMessage, + onOpenTag = onOpenTag, onDig = viewModel::onDig, onReplyTextChange = viewModel::onReplyTextChange, onPostReply = viewModel::postReply, @@ -113,6 +116,7 @@ fun MessageDetailScreen( onMuteUser: (Message) -> Unit = {}, onReportUser: (Message) -> Unit = {}, onFetchMetadata: (Message) -> Unit = {}, + onOpenTag: ((String) -> Unit)? = null, onDismissReport: () -> Unit = {}, onSubmitReport: (ReportReason, String) -> Unit = { _, _ -> }, onEditTextChange: (String) -> Unit = {}, @@ -154,6 +158,7 @@ fun MessageDetailScreen( onMuteUser = onMuteUser, onReportUser = onReportUser, onFetchMetadata = onFetchMetadata, + onOpenTag = onOpenTag, ) } } @@ -201,6 +206,7 @@ private fun Content( onMuteUser: (Message) -> Unit, onReportUser: (Message) -> Unit, onFetchMetadata: (Message) -> Unit, + onOpenTag: ((String) -> Unit)?, ) { val message = state.message Column( @@ -228,6 +234,7 @@ private fun Content( onOpenLink = { onFetchMetadata(message) }, // A push/quote here still opens the original it re-shares. onOpenPushedMessage = onOpenMessage, + onTagClick = onOpenTag, ) HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.outlineVariant) Text( @@ -250,6 +257,7 @@ private fun Content( onReportUser = { onReportUser(reply) }, onOpenLink = { onFetchMetadata(reply) }, onOpenPushedMessage = onOpenMessage, + onTagClick = onOpenTag, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt index 86514ef..806d73f 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Image @@ -131,6 +132,11 @@ object MessagesFeedTags { const val TAG_SUGGESTIONS = "messagesComposeTagSuggestions" const val TAG_SUGGESTION_PREFIX = "messagesComposeTagSuggestion_" + /** Back arrow shown instead of the tab bar when the feed is filtered to a tag. */ + const val BACK = "messagesFeedBack" + /** Title shown while the feed is filtered to a tag. */ + const val TAG_TITLE = "messagesFeedTagTitle" + /** The quoted message attached to the composer, and its always-public banner. */ const val QUOTE_ATTACHED = "messagesComposeQuoteAttached" const val QUOTE_PUBLIC_BANNER = "messagesComposeQuoteBanner" @@ -146,16 +152,23 @@ object MessagesFeedTags { } /** - * Hilt-wired feed entry point. The app's NavHost hosts this as the Messages tab. + * Hilt-wired feed entry point, hosted twice: as the Messages tab, and as the + * tag-filtered feed (`MessagesDestinations.TAG_FEED`). Which one it is comes from + * the ViewModel's nav arguments, so both get identical paging, view-preference and + * moderation behaviour from the same code. * * @param onOpenMessage navigates to the detail screen for the given message id. * @param onOpenScheduled navigates to the Scheduled messages screen. + * @param onOpenTag opens the feed filtered to a tapped tag. + * @param onBack pops the tag feed; ignored on the tab root, which has no back arrow. */ @Composable fun MessagesRoute( onOpenMessage: (String) -> Unit, onOpenScheduled: () -> Unit, modifier: Modifier = Modifier, + onOpenTag: ((String) -> Unit)? = null, + onBack: () -> Unit = {}, viewModel: MessagesFeedViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -167,6 +180,8 @@ fun MessagesRoute( onViewingPreferenceChange = viewModel::onViewingPreferenceChange, onOpenMessage = onOpenMessage, onOpenScheduled = onOpenScheduled, + onOpenTag = onOpenTag, + onBack = onBack, onDig = viewModel::onDig, onDelete = viewModel::onDelete, onPush = viewModel::onPush, @@ -225,6 +240,8 @@ fun MessagesFeedScreen( modifier: Modifier = Modifier, onViewingPreferenceChange: (ViewingPreference) -> Unit = {}, onOpenScheduled: () -> Unit = {}, + onOpenTag: ((String) -> Unit)? = null, + onBack: () -> Unit = {}, onPush: (Message) -> Unit = {}, onQuote: (Message) -> Unit = {}, onReport: (Message) -> Unit = {}, @@ -255,19 +272,49 @@ fun MessagesFeedScreen( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text("Messages") }, + title = { + if (state.tag != null) { + Text( + text = state.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(MessagesFeedTags.TAG_TITLE), + ) + } else { + Text("Messages") + } + }, + navigationIcon = { + // The tag feed is pushed on top of a tab, so it carries its own + // back affordance; the tab root does not. + if (state.isTagFeed) { + IconButton( + onClick = onBack, + modifier = Modifier.testTag(MessagesFeedTags.BACK), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + } + }, actions = { - IconButton( - onClick = onOpenScheduled, - modifier = Modifier.testTag(MessagesFeedTags.SCHEDULED_ACTION), - ) { - Icon(Icons.Filled.Schedule, contentDescription = "Scheduled messages") + if (!state.isTagFeed) { + IconButton( + onClick = onOpenScheduled, + modifier = Modifier.testTag(MessagesFeedTags.SCHEDULED_ACTION), + ) { + Icon(Icons.Filled.Schedule, contentDescription = "Scheduled messages") + } } }, ) }, floatingActionButton = { - if (!state.subscriptionRequired) { + // Composing from a tag feed would post an untagged message into a feed + // it cannot appear in, so the composer stays on the main feed. + if (!state.subscriptionRequired && !state.isTagFeed) { FloatingActionButton( onClick = onOpenCompose, modifier = Modifier.testTag(MessagesFeedTags.FAB), @@ -290,6 +337,7 @@ fun MessagesFeedScreen( onRefresh = onRefresh, onLoadMore = onLoadMore, onOpenMessage = onOpenMessage, + onOpenTag = onOpenTag, onDig = onDig, onDelete = onDelete, onPush = onPush, @@ -407,6 +455,7 @@ private fun FeedContent( onRefresh: () -> Unit, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, + onOpenTag: ((String) -> Unit)?, onDig: (Message) -> Unit, onDelete: (Message) -> Unit, onPush: (Message) -> Unit, @@ -426,11 +475,12 @@ private fun FeedContent( when { state.isEmpty && state.isRefreshing -> LoadingState() state.isEmpty && state.errorMessage != null -> ErrorState(state.errorMessage, onRefresh) - state.isEmpty -> EmptyState() + state.isEmpty -> EmptyState(tag = state.tag) else -> FeedList( state = state, onLoadMore = onLoadMore, onOpenMessage = onOpenMessage, + onOpenTag = onOpenTag, onDig = onDig, onDelete = onDelete, onPush = onPush, @@ -451,6 +501,7 @@ private fun FeedList( state: MessagesFeedUiState, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, + onOpenTag: ((String) -> Unit)?, onDig: (Message) -> Unit, onDelete: (Message) -> Unit, onPush: (Message) -> Unit, @@ -494,6 +545,7 @@ private fun FeedList( onQuote = { onQuote(message) }, // The embedded original opens on its own page. onOpenPushedMessage = onOpenMessage, + onTagClick = onOpenTag, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @@ -515,10 +567,16 @@ private fun LoadingState() { } @Composable -private fun EmptyState() { +private fun EmptyState(tag: String? = null) { Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Text( - text = "No messages yet. Be the first to post.", + // The tag feed hides the composer, so "be the first to post" would be + // an invitation the screen cannot honour. + text = if (tag != null) { + "Nothing tagged \u201C$tag\u201D yet." + } else { + "No messages yet. Be the first to post." + }, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt index 305cffe..d02bc9f 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.messages.ui.feed +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult @@ -13,6 +14,7 @@ import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate import com.interlinedlist.android.feature.messages.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -39,6 +41,11 @@ data class PendingAttachment( /** Feed screen state: the cached messages plus transient network/compose flags. */ data class MessagesFeedUiState( val messages: List = emptyList(), + /** + * The tag this feed is filtered to (`GET /api/messages?tag=`), or null for the + * account's main feed. Everything else on this screen behaves identically. + */ + val tag: String? = null, val isRefreshing: Boolean = false, val isLoadingMore: Boolean = false, val canLoadMore: Boolean = false, @@ -95,6 +102,9 @@ data class MessagesFeedUiState( ) { val isEmpty: Boolean get() = messages.isEmpty() + /** True when the feed is filtered to a single tag. */ + val isTagFeed: Boolean get() = tag != null + /** True while the composer is writing a quote of [quoteTarget]. */ val isQuoting: Boolean get() = quoteTarget != null @@ -212,11 +222,25 @@ private data class FeedTransientState( } } +/** + * The one feed ViewModel, used for both the account's main feed and a tag-filtered + * feed. The tag arrives as the [MessagesDestinations.ARG_TAG] nav argument and is + * the *only* difference between the two: the opaque cursor paging, the + * view-preference switcher, digs, edits and moderation are all the same code + * running under a different query. Forking this class for tags would have to + * re-implement every one of them, and would drift. The composer is the one part a + * tag feed leaves out, since a new post cannot be filed into someone else's tag. + */ @HiltViewModel class MessagesFeedViewModel @Inject constructor( private val repository: MessagesRepository, + savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { + /** Tag this feed is filtered to, or null when it is the main feed. */ + private val tag: String? = + savedStateHandle.get(MessagesDestinations.ARG_TAG)?.takeIf { it.isNotBlank() } + private val transient = MutableStateFlow(FeedTransientState()) /** @@ -231,9 +255,10 @@ class MessagesFeedViewModel @Inject constructor( * combined with transient flags into a single [MessagesFeedUiState]. */ val uiState: StateFlow = - combine(repository.observeFeed(), transient) { messages, t -> + combine(repository.observeFeed(tag), transient) { messages, t -> MessagesFeedUiState( messages = messages, + tag = tag, isRefreshing = t.isRefreshing, isLoadingMore = t.isLoadingMore, canLoadMore = t.canLoadMore, @@ -271,8 +296,11 @@ class MessagesFeedViewModel @Inject constructor( init { loadViewingPreferenceThenRefresh() - loadLinkedNetworks() - loadDefaultVisibility() + // A tag feed has no composer, so it does not pay for the composer's setup. + if (tag == null) { + loadLinkedNetworks() + loadDefaultVisibility() + } } /** @@ -381,7 +409,7 @@ class MessagesFeedViewModel @Inject constructor( ) } viewModelScope.launch { - when (val result = repository.refreshFeed(transient.value.viewingPreference)) { + when (val result = repository.refreshFeed(transient.value.viewingPreference, tag)) { is ApiResult.Success -> transient.update { it.copy(isRefreshing = false, nextCursor = result.data) } @@ -399,7 +427,7 @@ class MessagesFeedViewModel @Inject constructor( if (current.isLoadingMore || current.isRefreshing) return transient.update { it.copy(isLoadingMore = true) } viewModelScope.launch { - when (val result = repository.loadMoreFeed(cursor, current.viewingPreference)) { + when (val result = repository.loadMoreFeed(cursor, current.viewingPreference, tag)) { is ApiResult.Success -> transient.update { it.copy(isLoadingMore = false, nextCursor = result.data) } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt index d395836..1b67ebb 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt @@ -1,26 +1,34 @@ package com.interlinedlist.android.feature.messages.data +import com.interlinedlist.android.feature.messages.data.local.FeedEntryEntity +import com.interlinedlist.android.feature.messages.data.local.MAIN_FEED_KEY import com.interlinedlist.android.feature.messages.data.local.MessageDao import com.interlinedlist.android.feature.messages.data.local.MessageEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map /** * In-memory stand-in for the Room [MessageDao] so repository/ViewModel logic can * be unit-tested on the JVM without an Android runtime. Mirrors the query - * semantics of the real DAO (feed = parentId null, ordered by feedOrder). + * semantics of the real DAO: message rows are shared, and a feed is the ordered + * `feed_entry` membership over them (with the foreign key's cascade on delete). */ class FakeMessageDao : MessageDao { private val rows = MutableStateFlow>(emptyMap()) - private fun sorted(predicate: (MessageEntity) -> Boolean): List = - rows.value.values.filter(predicate).sortedBy { it.feedOrder } + /** Feed membership, keyed by `feedKey to messageId` like the real primary key. */ + private val entries = MutableStateFlow, FeedEntryEntity>>(emptyMap()) - override fun observeFeed(): Flow> = - rows.map { map -> - map.values.filter { it.parentId == null && it.scheduledAt == null }.sortedBy { it.feedOrder } + override fun observeFeed(feedKey: String): Flow> = + combine(rows, entries) { messages, membership -> + membership.values + .filter { it.feedKey == feedKey } + .sortedBy { it.position } + .mapNotNull { messages[it.messageId] } + .filter { it.parentId == null && it.scheduledAt == null } } override fun observeReplies(parentId: String): Flow> = @@ -42,25 +50,49 @@ class FakeMessageDao : MessageDao { rows.value = rows.value.toMutableMap().apply { put(message.id, message) } } + override suspend fun upsertFeedEntries(entries: List) { + this.entries.value = this.entries.value.toMutableMap().apply { + entries.forEach { put(it.feedKey to it.messageId, it) } + } + } + override suspend fun deleteById(id: String) { rows.value = rows.value.toMutableMap().apply { remove(id) } + cascade() } override suspend fun deleteByAuthorUsername(username: String) { rows.value = rows.value.filterValues { it.authorUsername != username } + cascade() } - override suspend fun clearFeed() { - rows.value = rows.value.filterValues { it.parentId != null || it.scheduledAt != null } + override suspend fun clearFeed(feedKey: String) { + entries.value = entries.value.filterValues { it.feedKey != feedKey } } override suspend fun clearScheduled() { rows.value = rows.value.filterValues { it.scheduledAt == null } + cascade() } - override suspend fun maxFeedOrder(): Long? = - sorted { it.parentId == null && it.scheduledAt == null }.maxOfOrNull { it.feedOrder } + override suspend fun maxFeedPosition(feedKey: String): Long? = + entries.value.values.filter { it.feedKey == feedKey }.maxOfOrNull { it.position } + + override suspend fun minFeedPosition(feedKey: String): Long? = + entries.value.values.filter { it.feedKey == feedKey }.minOfOrNull { it.position } + + override suspend fun maxMessageOrder(): Long? = rows.value.values.maxOfOrNull { it.feedOrder } + + /** Mirrors the `feed_entry -> message` foreign key's ON DELETE CASCADE. */ + private fun cascade() { + val live = rows.value.keys + entries.value = entries.value.filterValues { it.messageId in live } + } - /** Test helper: current feed snapshot. */ - fun feedSnapshot(): List = sorted { it.parentId == null && it.scheduledAt == null } + /** Test helper: current snapshot of a feed, main feed by default. */ + fun feedSnapshot(feedKey: String = MAIN_FEED_KEY): List = + entries.value.values + .filter { it.feedKey == feedKey } + .sortedBy { it.position } + .mapNotNull { rows.value[it.messageId] } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TagFeedTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TagFeedTest.kt new file mode 100644 index 0000000..b2bd96b --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TagFeedTest.kt @@ -0,0 +1,246 @@ +package com.interlinedlist.android.feature.messages.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.model.ViewingPreference +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.preferences.ViewingPreferenceStore +import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * The tag-filtered feed (`GET /api/messages?tag=`). + * + * It is the *same* feed: same endpoint, same opaque keyset cursor, same + * `onlyMine` view-preference mechanism, with one extra query parameter. These + * tests pin the three things that could quietly go wrong when a second feed + * shares one implementation: the tag reaching the wire intact (encoded exactly + * once), paging still being cursor-based on the filtered feed, and the tag feed + * leaving the main feed's cache alone. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TagFeedTest { + + private val dispatcher = StandardTestDispatcher() + + // Mirrors the production Json (see core:network NetworkModule). + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + private lateinit var server: MockWebServer + private lateinit var api: MessagesApi + private lateinit var userApi: InterlinedListApi + private lateinit var dao: FakeMessageDao + + /** A real tag from the live site: spaces *and* a comma, in one label. */ + private val awkwardTag = "life is short, o brave girl" + + /** An opaque, base64-padded token: nothing in the app may interpret it. */ + private val cursorAfterT2 = "MjAyNi0wOS0xM1QxMjowMDowMC4wMDBafHQy=" + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(MessagesApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) + dao = FakeMessageDao() + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = DefaultMessagesRepository( + api = api, + userApi = userApi, + viewingPreferenceStore = ViewingPreferenceStore(userApi, json), + messageDao = dao, + sessionStore = fakeSessionStore("me"), + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun page(ids: List, nextCursor: String? = null): String { + val rows = ids.joinToString(",") { """{ "id": "$it", "content": "$it" }""" } + val cursor = nextCursor?.let { "\"$it\"" } ?: "null" + return """ + { "messages": [$rows], + "pagination": { "limit": 20, "hasMore": ${nextCursor != null}, "nextCursor": $cursor } } + """.trimIndent() + } + + private fun enqueue(body: String) { + server.enqueue(MockResponse().setResponseCode(200).setBody(body)) + } + + // ---- the tag on the wire ---------------------------------------------- + + @Test + fun `a tag feed sends the tag it was asked for, and the main feed sends none`() = + runTest(dispatcher) { + enqueue(page(listOf("t1"))) + enqueue(page(listOf("m1"))) + val repo = repository() + + repo.refreshFeed(tag = "lists") + repo.refreshFeed() + + assertThat(server.takeRequest().requestUrl!!.queryParameter("tag")).isEqualTo("lists") + assertThat(server.takeRequest().requestUrl!!.queryParameter("tag")).isNull() + } + + @Test + fun `a tag with a space and a comma is encoded exactly once`() = runTest(dispatcher) { + enqueue(page(listOf("t1"))) + + repository().refreshFeed(tag = awkwardTag) + + val url = server.takeRequest().requestUrl!! + // Decoded, the server sees the tag whole. + assertThat(url.queryParameter("tag")).isEqualTo(awkwardTag) + // And on the wire it is percent-encoded once: a second pass would have + // turned "%20" into "%2520" and matched nothing. + val encoded = url.encodedQuery!! + assertThat(encoded).doesNotContain("%25") + assertThat(encoded).contains("%2C") + } + + @Test + fun `the tag rides alongside the view preference, not instead of it`() = runTest(dispatcher) { + enqueue(page(listOf("t1"))) + + repository().refreshFeed(preference = ViewingPreference.MINE, tag = "lists") + + val url = server.takeRequest().requestUrl!! + assertThat(url.queryParameter("tag")).isEqualTo("lists") + assertThat(url.queryParameter("onlyMine")).isEqualTo("true") + } + + // ---- paging on the filtered feed -------------------------------------- + + @Test + fun `the filtered feed pages by cursor, handed back verbatim and with no offset`() = + runTest(dispatcher) { + enqueue(page(listOf("t3", "t2"), nextCursor = cursorAfterT2)) + enqueue(page(listOf("t1"))) + val repo = repository() + + val cursor = (repo.refreshFeed(tag = awkwardTag) as ApiResult.Success).data!! + val next = repo.loadMoreFeed(cursor, tag = awkwardTag) + + assertThat(cursor).isEqualTo(cursorAfterT2) + assertThat((next as ApiResult.Success).data).isNull() // end of the feed + + val first = server.takeRequest().requestUrl!! + assertThat(first.queryParameter("cursor")).isNull() + assertThat(first.queryParameter("offset")).isNull() + + val second = server.takeRequest().requestUrl!! + // Verbatim: the padded token survives the round trip untouched... + assertThat(second.queryParameter("cursor")).isEqualTo(cursorAfterT2) + // ...still carrying the same tag, and still with no offset. + assertThat(second.queryParameter("tag")).isEqualTo(awkwardTag) + assertThat(second.queryParameter("offset")).isNull() + } + + @Test + fun `paging the filtered feed appends without duplicating or skipping rows`() = + runTest(dispatcher) { + enqueue(page(listOf("t3", "t2"), nextCursor = cursorAfterT2)) + enqueue(page(listOf("t1"))) + val repo = repository() + + val cursor = (repo.refreshFeed(tag = "lists") as ApiResult.Success).data!! + repo.loadMoreFeed(cursor, tag = "lists") + + val ids = repo.observeFeed(tag = "lists").first().map { it.id } + assertThat(ids).containsExactly("t3", "t2", "t1").inOrder() + assertThat(ids).containsNoDuplicates() + } + + // ---- the main feed's cache -------------------------------------------- + + @Test + fun `loading a tag feed leaves the main feed's cached rows untouched`() = runTest(dispatcher) { + enqueue(page(listOf("m3", "m2", "m1"))) + // The tag feed overlaps the main feed (m2) and brings rows of its own. + enqueue(page(listOf("m2", "t9"))) + val repo = repository() + + repo.refreshFeed() + repo.refreshFeed(tag = "lists") + + // The shared row belongs to both feeds at once, in each feed's own order. + assertThat(repo.observeFeed().first().map { it.id }) + .containsExactly("m3", "m2", "m1").inOrder() + assertThat(repo.observeFeed(tag = "lists").first().map { it.id }) + .containsExactly("m2", "t9").inOrder() + } + + @Test + fun `refreshing a tag feed evicts only that tag's rows`() = runTest(dispatcher) { + enqueue(page(listOf("m1"))) + enqueue(page(listOf("t1"))) + enqueue(page(listOf("t2"))) + val repo = repository() + + repo.refreshFeed() + repo.refreshFeed(tag = "lists") + repo.refreshFeed(tag = "lego") + + // Each feed stands alone: neither tag refresh cleared the other, or the main one. + assertThat(repo.observeFeed().first().map { it.id }).containsExactly("m1") + assertThat(repo.observeFeed(tag = "lists").first().map { it.id }).containsExactly("t1") + assertThat(repo.observeFeed(tag = "lego").first().map { it.id }).containsExactly("t2") + } + + @Test + fun `a dig made in a tag feed shows up in the main feed too`() = runTest(dispatcher) { + enqueue(page(listOf("shared"))) + enqueue(page(listOf("shared"))) + server.enqueue(MockResponse().setResponseCode(201)) + val repo = repository() + + repo.refreshFeed() + repo.refreshFeed(tag = "lists") + repo.setDug("shared", dug = true) + + // Feeds are lists *over* the cached messages, not copies of them. + assertThat(repo.observeFeed().first().single().dugByMe).isTrue() + assertThat(repo.observeFeed(tag = "lists").first().single().dugByMe).isTrue() + } + + @Test + fun `deleting a message drops it from every feed it appeared in`() = runTest(dispatcher) { + enqueue(page(listOf("gone", "stays"))) + enqueue(page(listOf("gone"))) + server.enqueue(MockResponse().setResponseCode(200)) + val repo = repository() + + repo.refreshFeed() + repo.refreshFeed(tag = "lists") + repo.deleteMessage("gone") + + assertThat(repo.observeFeed().first().map { it.id }).containsExactly("stays") + assertThat(repo.observeFeed(tag = "lists").first()).isEmpty() + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLinkTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLinkTest.kt new file mode 100644 index 0000000..29b3198 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLinkTest.kt @@ -0,0 +1,118 @@ +package com.interlinedlist.android.feature.messages.navigation + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The tag deep link. + * + * The canonical URL was read off the live site rather than guessed: the web's + * message card renders each tag as + * ``, and + * `https://interlinedlist.com/?tag=lists` really does serve a tag-filtered feed + * (`/tag/lists` and `/tags/lists` both 404). These tests pin that shape, so the + * intent filter and the resolver cannot quietly drift apart from it. + */ +class TagFeedLinkTest { + + @Test + fun `the web tag URL yields its tag`() { + assertThat(TagFeedLink.parse("https://interlinedlist.com/?tag=lists")).isEqualTo("lists") + } + + @Test + fun `the root without a trailing slash is still a tag URL`() { + assertThat(TagFeedLink.parse("https://interlinedlist.com?tag=lists")).isEqualTo("lists") + } + + @Test + fun `a percent-encoded tag comes back decoded, exactly once`() { + // The web encodes with encodeURIComponent, so a space is %20 and a comma + // is %2C. Decoding must restore the tag whole — not half-decoded, and not + // split on the comma or the spaces. + val decoded = TagFeedLink.parse( + "https://interlinedlist.com/?tag=life%20is%20short%2C%20o%20brave%20girl", + ) + assertThat(decoded).isEqualTo("life is short, o brave girl") + } + + @Test + fun `case and punctuation in the tag survive untouched`() { + assertThat(TagFeedLink.parse("https://interlinedlist.com/?tag=Hanabie.")).isEqualTo("Hanabie.") + } + + @Test + fun `www, http and extra query parameters are tolerated`() { + assertThat(TagFeedLink.parse("http://www.interlinedlist.com/?ref=x&tag=lego")) + .isEqualTo("lego") + } + + @Test + fun `the custom-scheme form resolves the same way`() { + assertThat(TagFeedLink.parse("interlinedlist://tag?tag=lists")).isEqualTo("lists") + } + + @Test + fun `a link to any other page is not a tag link`() { + // Only the site root carries the tag feed; a `tag` query hung off another + // page must not hijack that page's own deep link. + assertThat(TagFeedLink.parse("https://interlinedlist.com/lists/shared/xyz?tag=lists")).isNull() + assertThat(TagFeedLink.parse("https://interlinedlist.com/messages?tag=lists")).isNull() + assertThat(TagFeedLink.parse("https://interlinedlist.com/verify-email?token=abc")).isNull() + } + + @Test + fun `a foreign host is not a tag link`() { + assertThat(TagFeedLink.parse("https://example.com/?tag=lists")).isNull() + } + + @Test + fun `a root link with no usable tag is not a tag link`() { + assertThat(TagFeedLink.parse("https://interlinedlist.com/")).isNull() + assertThat(TagFeedLink.parse("https://interlinedlist.com/?tag=")).isNull() + assertThat(TagFeedLink.parse("https://interlinedlist.com/?tag=%20")).isNull() + } + + @Test + fun `garbage input returns null instead of throwing`() { + assertThat(TagFeedLink.parse("not a uri at all")).isNull() + assertThat(TagFeedLink.parse("")).isNull() + assertThat(TagFeedLink.parse(null)).isNull() + } + + // ---- route mapping ----------------------------------------------------- + + @Test + fun `routeForTagLink maps a tag URL onto the in-app tag feed route`() { + assertThat(MessagesDestinations.routeForTagLink("https://interlinedlist.com/?tag=lists")) + .isEqualTo("messages/tag/lists") + } + + @Test + fun `routeForTagLink percent-encodes a tag with spaces and punctuation`() { + // %20 rather than +: Navigation decodes the path segment with Uri.decode, + // which would hand a "+" straight back as a plus sign. + assertThat( + MessagesDestinations.routeForTagLink( + "https://interlinedlist.com/?tag=life%20is%20short%2C%20o%20brave%20girl", + ), + ).isEqualTo("messages/tag/life%20is%20short%2C%20o%20brave%20girl") + } + + @Test + fun `routeForTagLink ignores links it does not own`() { + assertThat(MessagesDestinations.routeForTagLink("https://interlinedlist.com/lists/shared/xyz")) + .isNull() + assertThat(MessagesDestinations.routeForTagLink(null)).isNull() + } + + @Test + fun `the tag feed route pattern and a built route share one path shape`() { + // A tag containing a slash must not split into two path segments, or the + // route would stop matching the pattern. + val route = MessagesDestinations.tagFeedRoute("a/b") + assertThat(route).isEqualTo("messages/tag/a%2Fb") + assertThat(route.count { it == '/' }) + .isEqualTo(MessagesDestinations.TAG_FEED.count { it == '/' }) + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt index 2e3578d..e32d58f 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt @@ -29,6 +29,8 @@ import kotlin.coroutines.cancellation.CancellationException class FakeMessagesRepository : MessagesRepository { private val feed = MutableStateFlow>(emptyList()) + /** Per-tag feeds, so a tag feed can be observed independently of the main one. */ + private val tagFeeds = MutableStateFlow>>(emptyMap()) private val replies = MutableStateFlow>>(emptyMap()) private val single = MutableStateFlow>(emptyMap()) private val scheduled = MutableStateFlow>(emptyList()) @@ -77,6 +79,15 @@ class FakeMessagesRepository : MessagesRepository { val refreshPreferences = mutableListOf() /** The preference each [loadMoreFeed] ran under, in order. */ val loadMorePreferences = mutableListOf() + /** The tag each [refreshFeed] ran under (null = main feed), in order. */ + val refreshTags = mutableListOf() + /** The tag each [loadMoreFeed] ran under (null = main feed), in order. */ + val loadMoreTags = mutableListOf() + /** Every tag [observeFeed] was subscribed to, in order. */ + val observedTags = mutableListOf() + /** How many times the composer-only lookups were made. */ + var linkedNetworksCalls = 0 + var defaultVisibilityCalls = 0 /** Every preference [setViewingPreference] was asked to PATCH, in order. */ val savedViewingPreferences = mutableListOf() var lastSetDug: Pair? = null @@ -140,13 +151,19 @@ class FakeMessagesRepository : MessagesRepository { data class ReportUserArgs(val username: String, val reason: ReportReason, val detail: String?) fun emitFeed(messages: List) { feed.value = messages } + fun emitTagFeed(tag: String, messages: List) { + tagFeeds.value = tagFeeds.value + (tag to messages) + } fun emitReplies(parentId: String, messages: List) { replies.value = replies.value + (parentId to messages) } fun emitMessage(message: Message) { single.value = single.value + (message.id to message) } fun emitScheduled(messages: List) { scheduled.value = messages } - override fun observeFeed(): Flow> = feed + override fun observeFeed(tag: String?): Flow> { + observedTags += tag + return if (tag == null) feed else tagFeeds.map { it[tag].orEmpty() } + } override fun observeReplies(messageId: String): Flow> = replies.map { it[messageId].orEmpty() } @@ -156,16 +173,22 @@ class FakeMessagesRepository : MessagesRepository { override fun observeScheduled(): Flow> = scheduled - override suspend fun refreshFeed(preference: ViewingPreference): ApiResult { + override suspend fun refreshFeed(preference: ViewingPreference, tag: String?): ApiResult { refreshCount++ refreshPreferences += preference + refreshTags += tag return refreshResult } - override suspend fun loadMoreFeed(cursor: String, preference: ViewingPreference): ApiResult { + override suspend fun loadMoreFeed( + cursor: String, + preference: ViewingPreference, + tag: String?, + ): ApiResult { loadMoreCount++ loadMoreCursors += cursor loadMorePreferences += preference + loadMoreTags += tag return loadMoreResult } @@ -207,9 +230,15 @@ class FakeMessagesRepository : MessagesRepository { } } - override suspend fun getLinkedNetworks(): ApiResult> = linkedNetworksResult + override suspend fun getLinkedNetworks(): ApiResult> { + linkedNetworksCalls++ + return linkedNetworksResult + } - override suspend fun getDefaultVisibility(): ApiResult = defaultVisibilityResult + override suspend fun getDefaultVisibility(): ApiResult { + defaultVisibilityCalls++ + return defaultVisibilityResult + } override suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult { uploadedImages++ diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/TagFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/TagFeedViewModelTest.kt new file mode 100644 index 0000000..fe757f2 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/TagFeedViewModelTest.kt @@ -0,0 +1,150 @@ +package com.interlinedlist.android.feature.messages.ui.feed + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.model.ViewingPreference +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations +import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +import com.interlinedlist.android.feature.messages.ui.sampleMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The tag feed is the *same* ViewModel as the main feed, told which tag it is + * showing through its nav argument. These tests pin that the tag reaches every + * feed call — including the second page — and that the main feed is unaffected + * when there is no tag argument at all. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TagFeedViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + /** A real tag from the live site: spaces *and* a comma, in one label. */ + private val awkwardTag = "life is short, o brave girl" + + private fun handle(tag: String) = + SavedStateHandle(mapOf(MessagesDestinations.ARG_TAG to tag)) + + @Test + fun `a tag feed refreshes under its tag, exactly as given`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo, handle(awkwardTag)) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().tag).isEqualTo(awkwardTag) + } + // Not trimmed, lowercased or split: the tag the card carried is the tag + // the query runs under. + assertThat(repo.refreshTags).containsExactly(awkwardTag) + } + + @Test + fun `the main feed asks for no tag`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.tag).isNull() + assertThat(state.isTagFeed).isFalse() + } + assertThat(repo.refreshTags).containsExactly(null) + } + + @Test + fun `the tag feed shows the tag's own cached rows, not the main feed's`() = + runTest(dispatcher) { + val repo = FakeMessagesRepository() + repo.emitFeed(listOf(sampleMessage(id = "main"))) + repo.emitTagFeed("lists", listOf(sampleMessage(id = "tagged", tags = listOf("lists")))) + val vm = MessagesFeedViewModel(repo, handle("lists")) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().messages.map { it.id }).containsExactly("tagged") + } + } + + @Test + fun `paging a tag feed carries the cursor and the tag together`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshResult = ApiResult.Success("opaque-cursor") + loadMoreResult = ApiResult.Success(null) + } + val vm = MessagesFeedViewModel(repo, handle("lists")) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().canLoadMore).isTrue() + vm.loadMore() + advanceUntilIdle() + assertThat(expectMostRecentItem().canLoadMore).isFalse() + } + + assertThat(repo.loadMoreCursors).containsExactly("opaque-cursor") + assertThat(repo.loadMoreTags).containsExactly("lists") + } + + @Test + fun `switching the view preference on a tag feed reloads it under the same tag`() = + runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo, handle("lists")) + + vm.uiState.test { + advanceUntilIdle() + vm.onViewingPreferenceChange(ViewingPreference.MINE) + advanceUntilIdle() + assertThat(expectMostRecentItem().viewingPreference).isEqualTo(ViewingPreference.MINE) + } + + // The view switcher is shared with the main feed, so the tag feed keeps + // honouring it — and never loses its tag doing so. + assertThat(repo.savedViewingPreferences).containsExactly(ViewingPreference.MINE) + assertThat(repo.refreshTags).containsExactly("lists", "lists").inOrder() + assertThat(repo.refreshPreferences.last()).isEqualTo(ViewingPreference.MINE) + } + + @Test + fun `a blank tag argument is treated as the main feed`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo, handle(" ")) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().isTagFeed).isFalse() + } + assertThat(repo.refreshTags).containsExactly(null) + } + + @Test + fun `a tag feed does not set up the composer it never shows`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo, handle("lists")) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().linkedNetworks).isEmpty() + } + // The cross-post destinations and default visibility are composer-only + // lookups; a screen with no composer should not pay for them. + assertThat(repo.linkedNetworksCalls).isEqualTo(0) + assertThat(repo.defaultVisibilityCalls).isEqualTo(0) + } +}