Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String>()
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<Unit>()
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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -460,6 +462,18 @@ class DefaultMessagesRepository @Inject constructor(
}
}

override suspend fun trendingTags(
window: TrendingWindow,
limit: Int,
): ApiResult<List<TrendingTag>> = 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<List<Message>> = withContext(dispatchers.io) {
when (val result = safeCall {
api.search(query = query, limit = PaginationDto.DEFAULT_LIMIT, offset = 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -129,6 +131,20 @@ interface MessagesRepository {
*/
suspend fun autocompleteTags(query: String, limit: Int = TAG_SUGGESTION_LIMIT): ApiResult<List<TagSuggestion>>

/**
* 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<List<TrendingTag>>

/**
* Pushes (reposts) [messageId] as-is: posts `pushedMessageId` with **no**
* content, always publicly. A quote — the same repost with the user's own
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

/**
Expand Down Expand Up @@ -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<TrendingTagDto> = emptyList(),
) {
/** The rows as domain values, dropping any entry with no usable tag. */
fun toDomain(): List<TrendingTag> = 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() },
)
}
}
Original file line number Diff line number Diff line change
@@ -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"),
}
Loading
Loading