From 8c8c5361c77f6621f630dcab6f96d41e958f184b Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:37:51 -0700 Subject: [PATCH] feat(messages): trending tags rail at the top of the feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces GET /api/tags/trending as a horizontally scrolling rail of tag chips, and every chip opens that tag's feed through the single entry point #29 left behind, MessagesDestinations.tagFeedRoute(tag) — with the tag handed over byte-for-byte, because real tags contain spaces and commas ("life is short, o brave girl"). Placement: the rail is the first row *inside* the feed list, not a fixed band above it. The top of that screen is already spoken for by the view switcher (#19) and the composer, so the rail is walked past on every visit and then scrolls away. It is also pinned into the feed's empty state, where having somewhere to go matters most — including the tag feed's own "nothing tagged X yet". The window is a *request* parameter, never response metadata: the live payload is { tags: [ { tag, count, lastUsedAt } ] } and reports nothing about the period it covers, while the server silently falls back to `week` for any value it does not recognise. So the app sends window=week explicitly from a typed TrendingWindow and labels the rail from the window it asked for — "Trending this week" is true because the request made it true. lastUsedAt, the one recency the payload does report, is spoken in each chip's accessibility label. Parsing is defensive: rows may lack count or lastUsedAt, may null them, and may carry keys the API adds later; a row with no usable tag is dropped rather than rendered as a blank chip. Loading, tags, empty and error are four explicit states decided in one place, so a quiet instance ("no trending tags yet") can never be mistaken for a failed lookup (which gets its own message and a Retry), and neither can ever be a blank strip. Tests: the live payload shape and its odd rows (unit), the window and limit on the wire plus the failure path (MockWebServer), the four states and the tag surviving intact into the route (ViewModel), and the rendered states, the tap, and the rail's two homes in the feed (Compose). Closes #30 --- .../ui/trending/TrendingTagsRailTest.kt | 192 ++++++++++++++++ .../data/DefaultMessagesRepository.kt | 14 ++ .../messages/data/MessagesRepository.kt | 24 ++ .../messages/data/remote/MessagesApi.kt | 17 ++ .../messages/data/remote/dto/TagsResponse.kt | 35 +++ .../feature/messages/domain/TrendingTag.kt | 43 ++++ .../messages/ui/feed/MessagesFeedScreen.kt | 73 ++++-- .../messages/ui/trending/TrendingTagsRail.kt | 215 ++++++++++++++++++ .../ui/trending/TrendingTagsViewModel.kt | 101 ++++++++ .../feature/messages/data/TrendingTagsTest.kt | 143 ++++++++++++ .../remote/dto/TrendingTagsResponseTest.kt | 93 ++++++++ .../messages/ui/FakeMessagesRepository.kt | 14 ++ .../ui/trending/TrendingTagDescriptionTest.kt | 40 ++++ .../ui/trending/TrendingTagsViewModelTest.kt | 149 ++++++++++++ 14 files changed, 1137 insertions(+), 16 deletions(-) create mode 100644 feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRailTest.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TrendingTag.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRail.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModel.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TrendingTagsTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TrendingTagsResponseTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagDescriptionTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModelTest.kt diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRailTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRailTest.kt new file mode 100644 index 0000000..8c00c73 --- /dev/null +++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRailTest.kt @@ -0,0 +1,192 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.ui.feed.MessagesFeedScreen +import com.interlinedlist.android.feature.messages.ui.feed.MessagesFeedTags +import com.interlinedlist.android.feature.messages.ui.feed.MessagesFeedUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The trending rail: that each of its states is visibly *something*, that the + * failure state is not mistakable for the quiet one, and that a tapped tag hands + * back the exact string the feed must be filtered by. + */ +@RunWith(AndroidJUnit4::class) +class TrendingTagsRailTest { + + @get:Rule + val composeRule = createComposeRule() + + /** 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 setRail( + state: TrendingTagsUiState, + onOpenTag: (String) -> Unit = {}, + onRetry: () -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + TrendingTagsRail(state = state, onOpenTag = onOpenTag, onRetry = onRetry) + } + } + } + + @Test + fun tags_areOfferedInTheServersOrder_withTheWindowTheyWereAskedFor() { + setRail( + TrendingTagsUiState( + tags = listOf( + TrendingTag("Lego", count = 2, lastUsedAt = "2026-09-12T20:40:05.777Z"), + TrendingTag(awkwardTag, count = 1), + ), + ), + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.CHIPS).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag("Lego")).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag(awkwardTag)).assertIsDisplayed() + // The window is the one the app requested; the response never reports one. + composeRule.onNodeWithText("Trending this week").assertIsDisplayed() + } + + @Test + fun tappingATag_handsBackTheExactTagString() { + val opened = mutableListOf() + setRail( + TrendingTagsUiState(tags = listOf(TrendingTag(awkwardTag, count = 1))), + onOpenTag = { opened += it }, + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag(awkwardTag)).performClick() + + // Not trimmed, split on the comma, or turned into a hashtag: this string + // is what MessagesDestinations.tagFeedRoute encodes into the tag feed. + assertThat(opened).containsExactly(awkwardTag) + } + + @Test + fun noTrendingTags_rendersTheEmptyState_ratherThanABlankStrip() { + setRail(TrendingTagsUiState(tags = emptyList())) + + composeRule.onNodeWithTag(TrendingTagsRailTags.EMPTY).assertIsDisplayed() + composeRule.onNodeWithText(NO_TRENDING_TAGS).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.ERROR).assertDoesNotExist() + composeRule.onNodeWithTag(TrendingTagsRailTags.CHIPS).assertDoesNotExist() + } + + @Test + fun aFailedLookup_looksDifferentFromAQuietInstance_andCanBeRetried() { + val retries = mutableListOf() + setRail( + TrendingTagsUiState(errorMessage = "No connection. Check your network and try again."), + onRetry = { retries += Unit }, + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.ERROR).assertIsDisplayed() + // "Nothing is trending" and "we could not find out" are different answers. + composeRule.onNodeWithTag(TrendingTagsRailTags.EMPTY).assertDoesNotExist() + + composeRule.onNodeWithTag(TrendingTagsRailTags.RETRY).performClick() + assertThat(retries).hasSize(1) + } + + @Test + fun theFirstLoad_showsProgress_ratherThanClaimingThereIsNothing() { + setRail(TrendingTagsUiState(isLoading = true)) + + composeRule.onNodeWithTag(TrendingTagsRailTags.PROGRESS).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.EMPTY).assertDoesNotExist() + } + + @Test + fun staleTagsSurviveAFailedRefresh_insteadOfBeingReplacedByAnError() { + setRail( + TrendingTagsUiState( + tags = listOf(TrendingTag("lists", count = 6)), + errorMessage = "No connection. Check your network and try again.", + ), + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag("lists")).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.ERROR).assertDoesNotExist() + } + + // --- where it lives ---------------------------------------------------- + + private fun setFeed( + state: MessagesFeedUiState, + trending: TrendingTagsUiState, + onOpenTag: ((String) -> Unit)? = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + MessagesFeedScreen( + state = state, + trending = trending, + onRefresh = {}, + onLoadMore = {}, + onOpenMessage = {}, + onDig = {}, + onDelete = {}, + onOpenCompose = {}, + onDismissCompose = {}, + onComposeTextChange = {}, + onPost = {}, + onOpenTag = onOpenTag, + ) + } + } + } + + private fun message(id: String) = Message( + id = id, content = "a message", authorId = "u1", authorUsername = "adron", + authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, + digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = false, + ) + + @Test + fun theRail_ridesAtTheTopOfTheFeed_whereItIsWalkedPast() { + setFeed( + MessagesFeedUiState(messages = listOf(message("1"))), + TrendingTagsUiState(tags = listOf(TrendingTag("lists", count = 6))), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.LIST).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.RAIL).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag("lists")).assertIsDisplayed() + } + + @Test + fun anEmptyFeed_stillOffersSomewhereToGo() { + setFeed( + MessagesFeedUiState(messages = emptyList()), + TrendingTagsUiState(tags = listOf(TrendingTag("lists", count = 6))), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.EMPTY).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag("lists")).assertIsDisplayed() + } + + @Test + fun theRail_isAbsentWhereThereIsNowhereToSendTheUser() { + setFeed( + MessagesFeedUiState(messages = listOf(message("1"))), + TrendingTagsUiState(tags = listOf(TrendingTag("lists", count = 6))), + onOpenTag = null, + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.RAIL).assertDoesNotExist() + } +} 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 8a04592..86abb98 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 @@ -29,6 +29,8 @@ 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.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow import com.interlinedlist.android.feature.messages.domain.asPushedOriginal import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -460,6 +462,18 @@ class DefaultMessagesRepository @Inject constructor( } } + override suspend fun trendingTags( + window: TrendingWindow, + limit: Int, + ): ApiResult> = withContext(dispatchers.io) { + // window.wire, never a raw string: the server accepts anything and + // quietly counts a week instead of telling us the value was wrong. + when (val result = safeCall { api.trendingTags(window = window.wire, limit = limit) }) { + is ApiResult.Success -> ApiResult.Success(result.data.toDomain()) + is ApiResult.Failure -> result + } + } + override suspend fun search(query: String): ApiResult> = withContext(dispatchers.io) { when (val result = safeCall { api.search(query = query, limit = PaginationDto.DEFAULT_LIMIT, offset = 0) 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 7f48c3f..367f198 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 @@ -9,6 +9,8 @@ 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.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow import kotlinx.coroutines.flow.Flow /** @@ -129,6 +131,20 @@ interface MessagesRepository { */ suspend fun autocompleteTags(query: String, limit: Int = TAG_SUGGESTION_LIMIT): ApiResult> + /** + * The most-used tags across public messages in the trailing [window], from + * `GET /api/tags/trending`, in the server's order (count descending). + * + * Network-only, like [autocompleteTags]: trending is a discovery surface for + * *right now*, so a cached copy would be worse than an honest empty/error + * state. [window] is sent explicitly because the response never reports which + * period it covers — the request is what makes the surface's wording true. + */ + suspend fun trendingTags( + window: TrendingWindow = TrendingWindow.WEEK, + limit: Int = TRENDING_TAG_LIMIT, + ): ApiResult> + /** * Pushes (reposts) [messageId] as-is: posts `pushedMessageId` with **no** * content, always publicly. A quote — the same repost with the user's own @@ -216,5 +232,13 @@ interface MessagesRepository { companion object { /** How many tag suggestions to ask for (server default 10, max 50). */ const val TAG_SUGGESTION_LIMIT = 10 + + /** + * How many trending tags to ask for (server default 20, max 100). Kept + * short deliberately: they render as one horizontally scrolling row, and + * a rail nobody reaches the end of is no more discoverable than a short + * one. + */ + const val TRENDING_TAG_LIMIT = 12 } } 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 827ff16..6ae7b6d 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 @@ -11,6 +11,7 @@ import com.interlinedlist.android.feature.messages.data.remote.dto.MetadataRespo import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.ScheduledMessagesResponse import com.interlinedlist.android.feature.messages.data.remote.dto.TagAutocompleteResponse +import com.interlinedlist.android.feature.messages.data.remote.dto.TrendingTagsResponse import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest import okhttp3.MultipartBody import retrofit2.http.Body @@ -137,6 +138,22 @@ interface MessagesApi { @Query("limit") limit: Int? = null, ): TagAutocompleteResponse + /** + * The most-used tags across **public** messages inside a trailing [window]. + * + * [window] must be one of `day`, `week` or `month`: the server falls back to + * `week` for anything else **without reporting it**, so a typo would silently + * mislabel the surface. [limit] defaults to 20 server-side and is clamped to + * 100. The response is a bare `{ "tags": [ { tag, count, lastUsedAt } ] }` — + * it does **not** echo the window back, so the caller is the only thing that + * knows which period the counts cover. + */ + @GET("api/tags/trending") + suspend fun trendingTags( + @Query("window") window: String, + @Query("limit") limit: Int, + ): TrendingTagsResponse + /** Reports a message with a reason (and optional free-text detail). */ @POST("api/messages/{id}/report") suspend fun report(@Path("id") id: String, @Body body: ReportRequest) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt index ba70ed4..bc816ee 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt @@ -1,6 +1,7 @@ package com.interlinedlist.android.feature.messages.data.remote.dto import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import com.interlinedlist.android.feature.messages.domain.TrendingTag import kotlinx.serialization.Serializable /** @@ -30,3 +31,37 @@ data class TagSuggestionDto( return TagSuggestion(tag = label, count = count) } } + +/** + * Response from `GET /api/tags/trending?window=…&limit=…`: + * `{ "tags": [ { "tag": "Lego", "count": 2, "lastUsedAt": "2026-09-12T20:40:05.777Z" } ] }`. + * + * Verified live: there is **no window metadata on the response** and no `data` + * envelope or pagination — the window is only ever something the caller asks + * for. Rows arrive ordered by `count` (descending), then most-recently-used + * first, and that order is preserved exactly as sent. + */ +@Serializable +data class TrendingTagsResponse( + val tags: List = emptyList(), +) { + /** The rows as domain values, dropping any entry with no usable tag. */ + fun toDomain(): List = tags.mapNotNull { it.toDomainOrNull() } +} + +/** One `{ tag, count, lastUsedAt }` row of the trending response. */ +@Serializable +data class TrendingTagDto( + val tag: String = "", + val count: Int = 0, + val lastUsedAt: String? = null, +) { + fun toDomainOrNull(): TrendingTag? { + val label = tag.takeIf { it.isNotBlank() } ?: return null + return TrendingTag( + tag = label, + count = count, + lastUsedAt = lastUsedAt?.takeIf { it.isNotBlank() }, + ) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TrendingTag.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TrendingTag.kt new file mode 100644 index 0000000..20bf9fc --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TrendingTag.kt @@ -0,0 +1,43 @@ +package com.interlinedlist.android.feature.messages.domain + +/** + * One row of `GET /api/tags/trending`: a tag used on public messages inside the + * requested trailing window, how many public messages used it there, and when it + * was last used. + * + * Like [TagSuggestion], [tag] is a **free-form label** (spaces and punctuation + * included) and is the exact string the tag feed queries by, so nothing here + * trims, lowercases or tokenises it. + */ +data class TrendingTag( + val tag: String, + /** Public messages using this tag within the window; the ordering key. */ + val count: Int = 0, + /** + * ISO-8601 instant of the most recent public message carrying this tag, or + * null when the API omitted it. Raw text: the UI formats it, nothing parses + * it for logic. + */ + val lastUsedAt: String? = null, +) + +/** + * The trailing window `GET /api/tags/trending?window=` counts over. + * + * The window is a **request** parameter, never part of the response: the live + * payload is `{ tags: [ { tag, count, lastUsedAt } ] }` and says nothing about + * the period it covers. The app therefore labels the surface from the window it + * asked for, and an unknown value would be silently swallowed by the server + * (which falls back to `week` without complaining) — so only these three + * documented values may ever be sent. + */ +enum class TrendingWindow( + /** The wire value for the `window` query parameter. */ + val wire: String, + /** How to describe this window in the UI, e.g. "Trending this week". */ + val label: String, +) { + DAY("day", "today"), + WEEK("week", "this week"), + MONTH("month", "this month"), +} 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 806d73f..1b2af82 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 @@ -85,6 +85,9 @@ import com.interlinedlist.android.feature.messages.ui.components.MessageCard import com.interlinedlist.android.feature.messages.ui.components.ModerationDialog import com.interlinedlist.android.feature.messages.ui.components.ReportDialog import com.interlinedlist.android.feature.messages.ui.readMediaBytes +import com.interlinedlist.android.feature.messages.ui.trending.TrendingTagsRail +import com.interlinedlist.android.feature.messages.ui.trending.TrendingTagsUiState +import com.interlinedlist.android.feature.messages.ui.trending.TrendingTagsViewModel import java.time.Instant import java.time.temporal.ChronoUnit @@ -170,11 +173,17 @@ fun MessagesRoute( onOpenTag: ((String) -> Unit)? = null, onBack: () -> Unit = {}, viewModel: MessagesFeedViewModel = hiltViewModel(), + trendingViewModel: TrendingTagsViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + // Trending has its own ViewModel: it outlives a feed refresh, and a trending + // lookup that fails must not read as a feed that failed. + val trending by trendingViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current MessagesFeedScreen( state = state, + trending = trending, + onRetryTrending = trendingViewModel::refresh, onRefresh = viewModel::refresh, onLoadMore = viewModel::loadMore, onViewingPreferenceChange = viewModel::onViewingPreferenceChange, @@ -238,6 +247,8 @@ fun MessagesFeedScreen( onComposeTextChange: (String) -> Unit, onPost: () -> Unit, modifier: Modifier = Modifier, + trending: TrendingTagsUiState = TrendingTagsUiState(), + onRetryTrending: () -> Unit = {}, onViewingPreferenceChange: (ViewingPreference) -> Unit = {}, onOpenScheduled: () -> Unit = {}, onOpenTag: ((String) -> Unit)? = null, @@ -330,10 +341,22 @@ fun MessagesFeedScreen( enabled = !state.isChangingViewingPreference, onSelect = onViewingPreferenceChange, ) + // A rail of doors that open nothing is not worth its space, so it + // only exists where the host wired somewhere to go. + val trendingRail: (@Composable () -> Unit)? = onOpenTag?.let { openTag -> + { + TrendingTagsRail( + state = trending, + onOpenTag = openTag, + onRetry = onRetryTrending, + ) + } + } when { state.subscriptionRequired -> LockedState(message = state.errorMessage) else -> FeedContent( state = state, + trendingRail = trendingRail, onRefresh = onRefresh, onLoadMore = onLoadMore, onOpenMessage = onOpenMessage, @@ -452,6 +475,7 @@ private val ViewingPreference.label: String @Composable private fun FeedContent( state: MessagesFeedUiState, + trendingRail: (@Composable () -> Unit)?, onRefresh: () -> Unit, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, @@ -475,9 +499,10 @@ private fun FeedContent( when { state.isEmpty && state.isRefreshing -> LoadingState() state.isEmpty && state.errorMessage != null -> ErrorState(state.errorMessage, onRefresh) - state.isEmpty -> EmptyState(tag = state.tag) + state.isEmpty -> EmptyState(tag = state.tag, trendingRail = trendingRail) else -> FeedList( state = state, + trendingRail = trendingRail, onLoadMore = onLoadMore, onOpenMessage = onOpenMessage, onOpenTag = onOpenTag, @@ -499,6 +524,7 @@ private fun FeedContent( @Composable private fun FeedList( state: MessagesFeedUiState, + trendingRail: (@Composable () -> Unit)?, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, onOpenTag: ((String) -> Unit)?, @@ -529,6 +555,10 @@ private fun FeedList( .fillMaxSize() .testTag(MessagesFeedTags.LIST), ) { + // First row of the feed rather than a fixed band above it: the switcher + // and the composer already own the top of this screen, so the rail earns + // its place by scrolling away once the reader is past it. + trendingRail?.let { rail -> item(key = "trendingTags") { rail() } } items(state.messages, key = { it.id }) { message -> MessageCard( message = message, @@ -567,21 +597,32 @@ private fun LoadingState() { } @Composable -private fun EmptyState(tag: String? = null) { - Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { - Text( - // 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, - modifier = Modifier.testTag(MessagesFeedTags.EMPTY), - ) +private fun EmptyState( + tag: String? = null, + trendingRail: (@Composable () -> Unit)? = null, +) { + Column(Modifier.fillMaxSize()) { + // An empty feed is exactly where somewhere-to-go matters most, so the + // rail stays on screen instead of scrolling with a list that has no rows. + trendingRail?.invoke() + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + // 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, + modifier = Modifier.testTag(MessagesFeedTags.EMPTY), + ) + } } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRail.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRail.kt new file mode 100644 index 0000000..5feae13 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRail.kt @@ -0,0 +1,215 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.ui.relativeTime +import java.time.Instant + +/** Stable test tags for the trending-tags rail. */ +object TrendingTagsRailTags { + const val RAIL = "trendingTagsRail" + const val TITLE = "trendingTagsTitle" + const val CHIPS = "trendingTagsChips" + const val PROGRESS = "trendingTagsProgress" + const val EMPTY = "trendingTagsEmpty" + const val ERROR = "trendingTagsError" + const val RETRY = "trendingTagsRetry" + + /** Prefix for one trending tag chip; suffixed with the tag itself. */ + const val CHIP_PREFIX = "trendingTag_" + + fun chipTag(tag: String): String = CHIP_PREFIX + tag +} + +/** What the surface says when the instance genuinely has no trending tags. */ +const val NO_TRENDING_TAGS = "No trending tags yet. Tag a message to start one." + +/** + * The trending tags, as one horizontally scrolling row of doors into tag feeds. + * + * Placed at the top of the feed list (and inside its empty state) rather than on + * a screen of its own: a discovery surface nobody walks past is not discovery. + * It scrolls away with the feed because the top of this screen is already spoken + * for by the view-preference switcher and the composer. + * + * Every state is rendered explicitly — an instance with nothing trending says so, + * and a failed lookup says something different — so the rail never degenerates + * into an unexplained blank strip. + */ +@Composable +fun TrendingTagsRail( + state: TrendingTagsUiState, + onOpenTag: (String) -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp) + .testTag(TrendingTagsRailTags.RAIL), + ) { + Text( + // "this week" is the window the app asked for; the response does not + // report one, so nothing here may claim a period it did not request. + text = state.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag(TrendingTagsRailTags.TITLE), + ) + Spacer(Modifier.height(6.dp)) + when (state.status) { + TrendingTagsStatus.LOADING -> Box(Modifier.padding(horizontal = 16.dp)) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(20.dp) + .testTag(TrendingTagsRailTags.PROGRESS), + ) + } + + TrendingTagsStatus.TAGS -> LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .testTag(TrendingTagsRailTags.CHIPS), + ) { + items(state.tags, key = { it.tag }) { trending -> + TrendingTagChip(trending = trending, onClick = { onOpenTag(trending.tag) }) + } + } + + TrendingTagsStatus.EMPTY -> Text( + text = NO_TRENDING_TAGS, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag(TrendingTagsRailTags.EMPTY), + ) + + TrendingTagsStatus.ERROR -> Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), + ) { + Text( + text = state.errorMessage.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .testTag(TrendingTagsRailTags.ERROR), + ) + TextButton( + onClick = onRetry, + modifier = Modifier.testTag(TrendingTagsRailTags.RETRY), + ) { + Text("Retry") + } + } + } + Spacer(Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } +} + +/** + * One tag, with its count. The label is the tag exactly as stored — free-form + * text that may contain spaces and punctuation, never prettified into a hashtag + * — truncated rather than allowed to push the rest of the rail off screen. + */ +@Composable +private fun TrendingTagChip(trending: TrendingTag, onClick: () -> Unit) { + val description = trendingTagDescription(trending) + AssistChip( + onClick = onClick, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = trending.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 180.dp), + ) + if (trending.count > 0) { + Spacer(Modifier.size(6.dp)) + Text( + text = trending.count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + modifier = Modifier + .semantics { contentDescription = description } + .testTag(TrendingTagsRailTags.chipTag(trending.tag)), + ) +} + +/** + * Spoken label for a trending chip: the tag, how many messages carry it, and — + * the one thing the payload reports about recency — when it was last used. + * + * [now] is injectable so the wording stays deterministic in tests. + */ +fun trendingTagDescription(trending: TrendingTag, now: Instant = Instant.now()): String = + buildString { + append(trending.tag) + if (trending.count > 0) { + append(", ${trending.count} ") + append(if (trending.count == 1) "message" else "messages") + } + val recency = relativeTime(trending.lastUsedAt, now) + if (recency.isNotBlank()) append(", last used $recency") + } + +@Preview(showBackground = true) +@Composable +private fun TrendingTagsRailPreview() { + InterlinedListTheme { + TrendingTagsRail( + state = TrendingTagsUiState( + tags = listOf( + TrendingTag("Lego", count = 2, lastUsedAt = "2026-09-12T20:40:05.777Z"), + TrendingTag("life is short, o brave girl", count = 1), + ), + ), + onOpenTag = {}, + onRetry = {}, + ) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModel.kt new file mode 100644 index 0000000..ad7af4a --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModel.kt @@ -0,0 +1,101 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow +import com.interlinedlist.android.feature.messages.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Which of the four mutually exclusive things the trending surface is showing. */ +enum class TrendingTagsStatus { + /** The first load has not answered yet. */ + LOADING, + + /** Tags to offer. */ + TAGS, + + /** + * The instance is genuinely quiet — nobody tagged a public message in the + * window. A real answer, rendered as such rather than as nothing at all. + */ + EMPTY, + + /** The lookup failed. Deliberately not the same thing as [EMPTY]. */ + ERROR, +} + +/** State of the trending-tags surface. */ +data class TrendingTagsUiState( + /** + * The trailing window the counts were requested for. The response never says + * which period it covers, so this — the window the app *asked* for — is the + * only honest source for the surface's wording. + */ + val window: TrendingWindow = TrendingWindow.WEEK, + val tags: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, +) { + /** + * The single place that decides what renders, so "empty" and "failed" can + * never collapse into the same blank strip. + * + * Tags outrank both: a refresh that fails (or is still running) leaves the + * doors that already work on screen rather than replacing them with a banner. + */ + val status: TrendingTagsStatus + get() = when { + tags.isNotEmpty() -> TrendingTagsStatus.TAGS + errorMessage != null -> TrendingTagsStatus.ERROR + isLoading -> TrendingTagsStatus.LOADING + else -> TrendingTagsStatus.EMPTY + } + + /** e.g. "Trending this week" — derived from the window that was requested. */ + val title: String get() = "Trending ${window.label}" +} + +/** + * Loads `GET /api/tags/trending` for the feed's trending rail. + * + * Deliberately separate from `MessagesFeedViewModel`: the rail has its own + * lifecycle (it survives a feed refresh, fails on its own, and retries on its + * own), and folding four more fields into the feed's state would make a failed + * trending lookup look like a failed feed. + */ +@HiltViewModel +class TrendingTagsViewModel @Inject constructor( + private val repository: MessagesRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(TrendingTagsUiState(isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + /** (Re)loads the trending tags; also the Retry action of the error state. */ + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + when (val result = repository.trendingTags(window = _uiState.value.window)) { + is ApiResult.Success -> _uiState.update { + it.copy(tags = result.data, isLoading = false, errorMessage = null) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TrendingTagsTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TrendingTagsTest.kt new file mode 100644 index 0000000..d85bbc3 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TrendingTagsTest.kt @@ -0,0 +1,143 @@ +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.common.result.AppError +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.interlinedlist.android.feature.messages.domain.TrendingWindow +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 + +/** + * Trending tags (`GET /api/tags/trending`). + * + * The window is a **request** parameter the response never echoes back, and the + * server silently falls back to `week` for any value it does not recognise — so + * the request itself is the only place the app's "this week" wording can be kept + * honest. These tests pin the parameters on the wire, the mapping, and the + * failure path the surface renders as its error state. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TrendingTagsTest { + + 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 + + @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) + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = DefaultMessagesRepository( + api = api, + userApi = userApi, + viewingPreferenceStore = ViewingPreferenceStore(userApi, json), + messageDao = FakeMessageDao(), + sessionStore = fakeSessionStore("me"), + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun enqueue(code: Int, body: String) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun `asks for the window and limit it will label the surface with`() = runTest(dispatcher) { + enqueue(200, """{ "tags": [] }""") + + repository().trendingTags() + + val request = server.takeRequest() + assertThat(request.path).startsWith("/api/tags/trending?") + // Sent explicitly, never left to the server default: the UI says "this + // week", so the request must be the one that makes that true. + assertThat(request.requestUrl?.queryParameter("window")).isEqualTo(TrendingWindow.WEEK.wire) + assertThat(request.requestUrl?.queryParameter("limit")) + .isEqualTo(MessagesRepository.TRENDING_TAG_LIMIT.toString()) + } + + @Test + fun `a different window is sent as the API's documented wire value`() = runTest(dispatcher) { + enqueue(200, """{ "tags": [] }""") + + repository().trendingTags(window = TrendingWindow.MONTH) + + assertThat(server.takeRequest().requestUrl?.queryParameter("window")).isEqualTo("month") + } + + @Test + fun `maps the live payload in the server's order`() = runTest(dispatcher) { + enqueue( + 200, + """ + { + "tags": [ + { "tag": "Lego", "count": 2, "lastUsedAt": "2026-09-12T20:40:05.777Z" }, + { "tag": "life is short, o brave girl", "count": 1, + "lastUsedAt": "2026-09-11T03:44:52.334Z" } + ] + } + """.trimIndent(), + ) + + val result = repository().trendingTags() + + val tags = (result as ApiResult.Success).data + assertThat(tags.map { it.tag }) + .containsExactly("Lego", "life is short, o brave girl").inOrder() + assertThat(tags.first().count).isEqualTo(2) + assertThat(tags.last().lastUsedAt).isEqualTo("2026-09-11T03:44:52.334Z") + } + + @Test + fun `a quiet instance reports no trending tags, not a failure`() = runTest(dispatcher) { + enqueue(200, """{ "tags": [] }""") + + val result = repository().trendingTags() + + assertThat((result as ApiResult.Success).data).isEmpty() + } + + @Test + fun `a server failure surfaces as an error, never as an empty list`() = runTest(dispatcher) { + enqueue(500, """{ "error": "boom", "code": "server_error" }""") + + val result = repository().trendingTags() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Server::class.java) + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TrendingTagsResponseTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TrendingTagsResponseTest.kt new file mode 100644 index 0000000..6a4e87a --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TrendingTagsResponseTest.kt @@ -0,0 +1,93 @@ +package com.interlinedlist.android.feature.messages.data.remote.dto + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import org.junit.Test + +/** + * `GET /api/tags/trending` answers with a bare `{ "tags": [ … ] }` — no `data` + * envelope, no pagination, and (verified live) **no window metadata**: the + * trailing window is something the caller *asks* for, never something the + * response reports back. These tests pin that shape and the defensive parsing + * around it, because tags are free-form user text: real ones contain spaces, + * commas and mixed case. + */ +class TrendingTagsResponseTest { + + // Mirrors NetworkModule's configuration. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + + private fun decode(body: String) = json.decodeFromString(TrendingTagsResponse.serializer(), body) + + @Test + fun `reads the live payload, keeping the server's order and its odd tags`() { + val response = decode( + """ + { + "tags": [ + { "tag": "Lego", "count": 2, "lastUsedAt": "2026-09-12T20:40:05.777Z" }, + { "tag": "nuclear god", "count": 2, "lastUsedAt": "2026-09-11T03:00:14.845Z" }, + { "tag": "life is short, o brave girl", "count": 1, + "lastUsedAt": "2026-09-11T03:44:52.334Z" } + ] + } + """.trimIndent(), + ) + + // Spaces, a comma and mixed case all survive verbatim: the tag is the key + // the tag feed queries by, so any normalisation here would break the link. + assertThat(response.toDomain().map { it.tag }) + .containsExactly("Lego", "nuclear god", "life is short, o brave girl") + .inOrder() + assertThat(response.toDomain().first().count).isEqualTo(2) + assertThat(response.toDomain().first().lastUsedAt).isEqualTo("2026-09-12T20:40:05.777Z") + } + + @Test + fun `a row missing or nulling fields still parses`() { + val response = decode( + """ + { + "tags": [ + { "tag": "quiet" }, + { "tag": "nulled", "count": null, "lastUsedAt": null } + ] + } + """.trimIndent(), + ) + + val tags = response.toDomain() + assertThat(tags.map { it.tag }).containsExactly("quiet", "nulled").inOrder() + assertThat(tags.map { it.count }).containsExactly(0, 0) + assertThat(tags.map { it.lastUsedAt }).containsExactly(null, null) + } + + @Test + fun `rows without a usable tag are dropped rather than rendered blank`() { + val response = decode( + """{ "tags": [ { "count": 9 }, { "tag": " ", "count": 4 }, { "tag": "real" } ] }""", + ) + + assertThat(response.toDomain().map { it.tag }).containsExactly("real") + } + + @Test + fun `unknown keys the API may add later are ignored`() { + val response = decode( + """ + { + "window": "week", + "tags": [ { "tag": "lists", "count": 6, "score": 0.42 } ] + } + """.trimIndent(), + ) + + assertThat(response.toDomain().map { it.tag }).containsExactly("lists") + } + + @Test + fun `an empty or absent tag list decodes to no tags`() { + assertThat(decode("""{ "tags": [] }""").toDomain()).isEmpty() + assertThat(decode("{}").toDomain()).isEmpty() + } +} 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 e32d58f..f4beaaa 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 @@ -13,6 +13,8 @@ import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.PushedMessage import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow @@ -66,6 +68,10 @@ class FakeMessagesRepository : MessagesRepository { /** Per-query canned autocomplete answers; anything else falls back below. */ val autocompleteResponses = mutableMapOf>>() var autocompleteResult: ApiResult> = ApiResult.Success(emptyList()) + /** What [trendingTags] answers with. */ + var trendingTagsResult: ApiResult> = ApiResult.Success(emptyList()) + /** Every window [trendingTags] was asked for, in order. */ + val trendingWindows = mutableListOf() /** The account's saved feed preference, as read from `GET /api/user`. */ var viewingPreferenceResult: ApiResult = ApiResult.Success(ViewingPreference.ALL) /** What the `PATCH /api/user/update` of the preference answers with. */ @@ -343,6 +349,14 @@ class FakeMessagesRepository : MessagesRepository { return autocompleteResponses[query] ?: autocompleteResult } + override suspend fun trendingTags( + window: TrendingWindow, + limit: Int, + ): ApiResult> { + trendingWindows += window + return trendingTagsResult + } + override suspend fun search(query: String): ApiResult> = searchResult } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagDescriptionTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagDescriptionTest.kt new file mode 100644 index 0000000..6e082e6 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagDescriptionTest.kt @@ -0,0 +1,40 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import org.junit.Test +import java.time.Instant + +/** + * The chip's spoken label. `lastUsedAt` is the only recency the payload actually + * reports (there is no window metadata on the response), so this is where it is + * used — and it has to survive rows that arrive without it. + */ +class TrendingTagDescriptionTest { + + private val now = Instant.parse("2026-09-16T18:00:00Z") + + @Test + fun `reads the tag, its count and when it was last used`() { + val description = trendingTagDescription( + TrendingTag("Lego", count = 2, lastUsedAt = "2026-09-14T18:00:00Z"), + now, + ) + + assertThat(description).isEqualTo("Lego, 2 messages, last used 2d") + } + + @Test + fun `a single message is not pluralised`() { + val description = trendingTagDescription(TrendingTag("lists", count = 1), now) + + assertThat(description).isEqualTo("lists, 1 message") + } + + @Test + fun `a row missing its count and timestamp still reads as the tag`() { + val description = trendingTagDescription(TrendingTag("life is short, o brave girl"), now) + + assertThat(description).isEqualTo("life is short, o brave girl") + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModelTest.kt new file mode 100644 index 0000000..5f7fb9c --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModelTest.kt @@ -0,0 +1,149 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +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.common.result.AppError +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations +import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +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 +import java.net.URLDecoder + +/** + * The trending-tags surface. + * + * Its whole job is to be a set of doors: every state it can be in has to look + * deliberate (a quiet instance has no trending tags, and that is not a bug), and + * the tag behind each door has to reach the tag feed byte-for-byte — trending + * tags are free-form labels with spaces and commas in them. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TrendingTagsViewModelTest { + + 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 repo(result: ApiResult>) = + FakeMessagesRepository().apply { trendingTagsResult = result } + + @Test + fun `loads the trending tags on open, in the server's order`() = runTest(dispatcher) { + val repo = repo( + ApiResult.Success( + listOf( + TrendingTag("Lego", count = 2, lastUsedAt = "2026-09-12T20:40:05.777Z"), + TrendingTag(awkwardTag, count = 1, lastUsedAt = "2026-09-11T03:44:52.334Z"), + ), + ), + ) + val vm = TrendingTagsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.status).isEqualTo(TrendingTagsStatus.TAGS) + // The server ranks them; the rail never re-sorts or re-labels. + assertThat(state.tags.map { it.tag }) + .containsExactly("Lego", awkwardTag).inOrder() + } + } + + @Test + fun `starts in a loading state rather than claiming there is nothing`() = runTest(dispatcher) { + val vm = TrendingTagsViewModel(repo(ApiResult.Success(emptyList()))) + + vm.uiState.test { + assertThat(awaitItem().status).isEqualTo(TrendingTagsStatus.LOADING) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `an empty answer is an empty state, not a blank`() = runTest(dispatcher) { + val vm = TrendingTagsViewModel(repo(ApiResult.Success(emptyList()))) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + // A new or quiet instance legitimately has no trending tags: the + // surface must say so instead of rendering nothing at all. + assertThat(state.status).isEqualTo(TrendingTagsStatus.EMPTY) + assertThat(state.errorMessage).isNull() + } + } + + @Test + fun `a failure is an error state, distinct from empty`() = runTest(dispatcher) { + val vm = TrendingTagsViewModel(repo(ApiResult.Failure(AppError.Network("offline")))) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.status).isEqualTo(TrendingTagsStatus.ERROR) + assertThat(state.status).isNotEqualTo(TrendingTagsStatus.EMPTY) + assertThat(state.errorMessage).isNotNull() + assertThat(state.tags).isEmpty() + } + } + + @Test + fun `retry asks again and recovers`() = runTest(dispatcher) { + val repo = repo(ApiResult.Failure(AppError.Network("offline"))) + val vm = TrendingTagsViewModel(repo) + advanceUntilIdle() + + repo.trendingTagsResult = ApiResult.Success(listOf(TrendingTag("lists", count = 6))) + vm.refresh() + advanceUntilIdle() + + vm.uiState.test { + val state = expectMostRecentItem() + assertThat(state.status).isEqualTo(TrendingTagsStatus.TAGS) + assertThat(state.errorMessage).isNull() + } + assertThat(repo.trendingWindows).hasSize(2) + } + + @Test + fun `asks for the window it labels the surface with`() = runTest(dispatcher) { + val repo = repo(ApiResult.Success(emptyList())) + val vm = TrendingTagsViewModel(repo) + advanceUntilIdle() + + // The response carries no window metadata, so the request is the only + // thing that makes this wording true. + assertThat(repo.trendingWindows).containsExactly(TrendingWindow.WEEK) + assertThat(vm.uiState.value.window).isEqualTo(TrendingWindow.WEEK) + assertThat(vm.uiState.value.title).isEqualTo("Trending this week") + } + + @Test + fun `a tapped trending tag routes to that tag's feed, byte-for-byte`() = runTest(dispatcher) { + val vm = TrendingTagsViewModel(repo(ApiResult.Success(listOf(TrendingTag(awkwardTag))))) + advanceUntilIdle() + + val tapped = vm.uiState.value.tags.single().tag + val route = MessagesDestinations.tagFeedRoute(tapped) + + assertThat(route).isEqualTo("messages/tag/life%20is%20short%2C%20o%20brave%20girl") + // What the nav argument hands the tag feed is the tag the API gave us. + val decoded = URLDecoder.decode(route.removePrefix("messages/tag/"), Charsets.UTF_8.name()) + assertThat(decoded).isEqualTo(awkwardTag) + } +}