From 5de17db922f50aa6006707f5a547aac1e202da49 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:54:10 -0700 Subject: [PATCH] feat(messages): tag posts with prefix autocomplete in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a tag input to the composer, sends `tags[]` on `POST /api/messages`, and renders a message's tags on its card from the `tags[]` the feed already returns (no extra fetch). Tags are free-form labels, not hashtag tokens: the live API returns tags containing spaces and punctuation (e.g. "life is short, o brave girl"), so the input never splits what is typed and the card never prettifies it. A tag is committed deliberately — Add, the keyboard's Done action, or tapping a suggestion. Suggestions come from `GET /api/tags/autocomplete`, whose query parameter is `q` (`prefix` is a 400). Matching is the server's case-insensitive literal prefix and the results are shown in its order, unfiltered: re-matching client side would misrepresent the endpoint's semantics. The lookup is debounced by 300 ms and every keystroke supersedes the last, cancelling both the pending delay and any request already in flight, so a fast typist issues one request rather than a pile of concurrent ones. A response is applied only while it still answers the current text, so an overtaken reply for an older prefix can never overwrite newer suggestions. Cached messages gain a tags column (messages db 4 -> 5, destructive as before). Tests: the create request carries `tags` (and omits the field when empty); a tag with spaces and a comma round-trips out and back unchanged; autocomplete asks with `q` and keeps the server's order; debounce, per-keystroke cancellation, and a late response for an older prefix being discarded; the card renders tags. Closes #28 --- .../ui/feed/MessagesFeedScreenTest.kt | 92 ++++ .../data/DefaultMessagesRepository.kt | 20 + .../messages/data/MessagesRepository.kt | 21 + .../messages/data/local/MessageEntity.kt | 4 + .../messages/data/local/MessagesDatabase.kt | 2 +- .../messages/data/remote/MessagesApi.kt | 17 + .../messages/data/remote/dto/MessageDto.kt | 8 + .../data/remote/dto/MessagesResponse.kt | 5 + .../messages/data/remote/dto/TagsResponse.kt | 32 ++ .../feature/messages/domain/Message.kt | 9 + .../feature/messages/domain/TagSuggestion.kt | 18 + .../messages/ui/components/MessageCard.kt | 44 ++ .../messages/ui/feed/MessagesFeedScreen.kt | 162 +++++++ .../messages/ui/feed/MessagesFeedViewModel.kt | 162 ++++++- .../data/DefaultMessagesRepositoryTest.kt | 108 +++++ .../data/remote/dto/MessageDtoMapperTest.kt | 21 + .../messages/ui/FakeMessagesRepository.kt | 60 ++- .../messages/ui/feed/ComposerTagsTest.kt | 395 ++++++++++++++++++ 18 files changed, 1167 insertions(+), 13 deletions(-) create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TagSuggestion.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/ComposerTagsTest.kt 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 9d8bf69..37dcb09 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 @@ -12,6 +12,7 @@ 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.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme @@ -20,6 +21,7 @@ import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.PushedMessage +import com.interlinedlist.android.feature.messages.domain.TagSuggestion import com.interlinedlist.android.feature.messages.ui.components.EditMessageSheetTags import com.interlinedlist.android.feature.messages.ui.components.MessageCardTags import com.interlinedlist.android.feature.messages.ui.components.MessageMediaTags @@ -44,6 +46,7 @@ class MessagesFeedScreenTest { publiclyVisible: Boolean = true, pushCount: Int = 0, pushedMessage: PushedMessage? = null, + tags: List = emptyList(), ) = Message( id = id, content = body, authorId = "u1", authorUsername = "adron", authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, @@ -52,6 +55,7 @@ class MessagesFeedScreenTest { pushCount = pushCount, pushedMessageId = pushedMessage?.id, pushedMessage = pushedMessage, + tags = tags, ) private fun original( @@ -74,6 +78,7 @@ class MessagesFeedScreenTest { onViewingPreferenceChange: ((ViewingPreference) -> Unit)? = null, onPush: (Message) -> Unit = {}, onQuote: (Message) -> Unit = {}, + onSelectTagSuggestion: (TagSuggestion) -> Unit = {}, ) { composeRule.setContent { var state by mutableStateOf(initial) @@ -107,6 +112,26 @@ class MessagesFeedScreenTest { onBlockUser = onBlockUser, onMuteUser = onMuteUser, onReportUser = onReportUser, + onTagQueryChange = { state = state.copy(tagQuery = it) }, + onCommitTag = { + val tag = state.tagQuery.trim() + state = if (tag.isEmpty() || tag in state.composeTags) { + state.copy(tagQuery = "") + } else { + state.copy(composeTags = state.composeTags + tag, tagQuery = "") + } + }, + onSelectTagSuggestion = { suggestion -> + state = state.copy( + composeTags = state.composeTags + suggestion.tag, + tagQuery = "", + tagSuggestions = emptyList(), + ) + onSelectTagSuggestion(suggestion) + }, + onRemoveTag = { tag -> + state = state.copy(composeTags = state.composeTags - tag) + }, onPush = onPush, onQuote = { quoted -> state = state.copy(isComposeOpen = true, quoteTarget = quoted) @@ -542,4 +567,71 @@ class MessagesFeedScreenTest { composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PRIVATE).assertHasClickAction() } + + // --- tags --------------------------------------------------------------- + + @Test + fun tags_areRendered_onTheCard() { + setFeed( + MessagesFeedUiState( + // Straight from the feed payload's tags[] — no extra fetch. + messages = listOf(message("1", "tagged post", tags = listOf("lists", "Lego"))), + ), + ) + composeRule.onNodeWithTag(MessageCardTags.TAGS).assertIsDisplayed() + composeRule.onNodeWithTag(MessageCardTags.tagTag("lists")).assertIsDisplayed() + composeRule.onNodeWithTag(MessageCardTags.tagTag("Lego")).assertIsDisplayed() + composeRule.onNodeWithText("lists").assertIsDisplayed() + } + + @Test + fun aTagWithSpacesAndPunctuation_isRendered_whole() { + val tag = "life is short, o brave girl" + setFeed(MessagesFeedUiState(messages = listOf(message("1", "tagged", tags = listOf(tag))))) + // One label, not four: tags are free-form strings, never word tokens. + composeRule.onNodeWithTag(MessageCardTags.tagTag(tag)).assertIsDisplayed() + composeRule.onNodeWithText(tag).assertIsDisplayed() + } + + @Test + fun noTagRow_isShown_forAnUntaggedMessage() { + setFeed(MessagesFeedUiState(messages = listOf(message("1", "plain")))) + composeRule.onNodeWithTag(MessageCardTags.TAGS).assertDoesNotExist() + } + + @Test + fun composer_addsATypedTag_asAChip() { + setFeed(MessagesFeedUiState(isComposeOpen = true)) + composeRule.onNodeWithTag(MessagesFeedTags.COMPOSE_TAG_INPUT).performTextInput("lists") + composeRule.onNodeWithTag(MessagesFeedTags.COMPOSE_TAG_ADD).performClick() + + composeRule.onNodeWithTag(MessagesFeedTags.composeTagTag("lists")).assertIsDisplayed() + } + + @Test + fun composer_removesACommittedTag() { + setFeed(MessagesFeedUiState(isComposeOpen = true, composeTags = listOf("lists"))) + composeRule.onNodeWithTag(MessagesFeedTags.composeTagTag("lists")).performClick() + composeRule.onNodeWithTag(MessagesFeedTags.composeTagTag("lists")).assertDoesNotExist() + } + + @Test + fun composer_showsSuggestions_andAddsTheTappedOne() { + var selected: TagSuggestion? = null + setFeed( + MessagesFeedUiState( + isComposeOpen = true, + tagQuery = "l", + // The server matched case-insensitively; the app shows its answer + // exactly as given, in its order. + tagSuggestions = listOf(TagSuggestion("lists", 8), TagSuggestion("Lego", 2)), + ), + onSelectTagSuggestion = { selected = it }, + ) + composeRule.onNodeWithTag(MessagesFeedTags.TAG_SUGGESTIONS).assertIsDisplayed() + composeRule.onNodeWithTag(MessagesFeedTags.tagSuggestionTag("Lego")).performClick() + + assert(selected?.tag == "Lego") + composeRule.onNodeWithTag(MessagesFeedTags.composeTagTag("Lego")).assertIsDisplayed() + } } 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 5f3b49b..bf7ce67 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 @@ -24,6 +24,7 @@ import com.interlinedlist.android.feature.messages.domain.LinkedNetwork 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.asPushedOriginal import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -119,6 +120,7 @@ class DefaultMessagesRepository @Inject constructor( crossPost: CrossPostSelection, visibility: MessageVisibility, pushedMessageId: String?, + tags: List, ): ApiResult = withContext(dispatchers.io) { val isReshare = pushedMessageId != null val request = CreateMessageRequest( @@ -138,6 +140,9 @@ class DefaultMessagesRepository @Inject constructor( imageUrls = imageUrls.ifEmpty { null }, videoUrls = videoUrls.ifEmpty { null }, scheduledAt = scheduledAt, + // Sent verbatim — a tag may contain spaces and punctuation. Omitted + // entirely (explicitNulls = false) when the composer added none. + tags = tags.ifEmpty { null }, // Encode cross-post targets per the create schema. explicitNulls=false // drops these when empty/false, so a plain post keeps its original body. mastodonProviderIds = crossPost.mastodonProviderIds.ifEmpty { null }, @@ -418,6 +423,21 @@ class DefaultMessagesRepository @Inject constructor( } } + /** + * Asks the server for suggestions and hands back exactly what it said, in the + * order it said it. The matching rule (case-insensitive literal prefix) lives + * on the server; re-filtering here would misrepresent it. + */ + override suspend fun autocompleteTags( + query: String, + limit: Int, + ): ApiResult> = withContext(dispatchers.io) { + when (val result = safeCall { api.autocompleteTags(query = query, 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 95b1085..43df63a 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 @@ -8,6 +8,7 @@ import com.interlinedlist.android.feature.messages.domain.LinkedNetwork 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 kotlinx.coroutines.flow.Flow /** @@ -87,6 +88,9 @@ interface MessagesRepository { * **quote**, and [pushMessage] is the no-comment **push**. Amplifying someone * else's message is always public, so a non-null [pushedMessageId] overrides * [visibility] with [MessageVisibility.PUSH_OR_QUOTE]. + * + * [tags] are sent as `tags[]`, verbatim: free-form labels that may contain + * spaces and punctuation. An empty list omits the field entirely. */ suspend fun createMessage( content: String, @@ -96,8 +100,20 @@ interface MessagesRepository { crossPost: CrossPostSelection = CrossPostSelection.NONE, visibility: MessageVisibility = MessageVisibility.PUBLIC, pushedMessageId: String? = null, + tags: List = emptyList(), ): ApiResult + /** + * Tag suggestions for the prefix the user is typing, from + * `GET /api/tags/autocomplete` (query parameter **`q`**). + * + * The server does the matching: a **case-insensitive literal prefix** over + * existing public tags. The result is returned in the server's order and is + * never re-filtered or fuzzy-matched here — doing so would show (or hide) + * suggestions the server never chose. Network-only: suggestions are not cached. + */ + suspend fun autocompleteTags(query: String, limit: Int = TAG_SUGGESTION_LIMIT): ApiResult> + /** * Pushes (reposts) [messageId] as-is: posts `pushedMessageId` with **no** * content, always publicly. A quote — the same repost with the user's own @@ -181,4 +197,9 @@ interface MessagesRepository { /** Full-text search over top-level messages (does not touch the feed cache). */ suspend fun search(query: String): ApiResult> + + companion object { + /** How many tag suggestions to ask for (server default 10, max 50). */ + const val TAG_SUGGESTION_LIMIT = 10 + } } 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 1999f0c..b720b77 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 @@ -33,6 +33,8 @@ data class MessageEntity( val videoUrls: List = emptyList(), /** Link-preview card, stored via [MessageConverters]; null when none. */ val linkPreview: LinkPreview? = null, + /** Free-form tags, stored as JSON via [MessageConverters]. */ + val tags: List = emptyList(), /** Future send time for a scheduled message; null for a normal message. */ val scheduledAt: String? = null, /** Last-edited instant; null when the message has not been edited. */ @@ -66,6 +68,7 @@ fun MessageEntity.toDomain(): Message = Message( imageUrls = imageUrls, videoUrls = videoUrls, linkPreview = linkPreview, + tags = tags, scheduledAt = scheduledAt, editedAt = editedAt, publiclyVisible = publiclyVisible, @@ -91,6 +94,7 @@ fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( imageUrls = imageUrls, videoUrls = videoUrls, linkPreview = linkPreview, + tags = tags, scheduledAt = scheduledAt, editedAt = editedAt, publiclyVisible = publiclyVisible, 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 c1c1c49..edc3eef 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 @@ -10,7 +10,7 @@ import androidx.room.TypeConverters */ @Database( entities = [MessageEntity::class], - version = 4, + version = 5, 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 7318082..37c4c8b 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 @@ -10,6 +10,7 @@ import com.interlinedlist.android.feature.messages.data.remote.dto.MessagesRespo import com.interlinedlist.android.feature.messages.data.remote.dto.MetadataResponse 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.UserReportRequest import okhttp3.MultipartBody import retrofit2.http.Body @@ -113,6 +114,22 @@ interface MessagesApi { @GET("api/user/identities") suspend fun getIdentities(): IdentitiesResponse + /** + * Tag suggestions for a prefix the user is typing. + * + * The query parameter is **`q`** — the endpoint 400s with + * `{"error":"Query parameter 'q' is required","code":"bad_request"}` for + * anything else (notably `prefix`). Matching is a **case-insensitive literal + * prefix** over existing public tags (`%` and `_` are not wildcards) and a + * single leading `#` is stripped server-side; an empty `q` is a 400. + * [limit] defaults to 10 server-side and is clamped to 50. + */ + @GET("api/tags/autocomplete") + suspend fun autocompleteTags( + @Query("q") query: String, + @Query("limit") limit: Int? = null, + ): TagAutocompleteResponse + /** 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/MessageDto.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt index e5dd12a..2f8cf21 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt @@ -35,6 +35,12 @@ data class MessageDto( val videoUrls: List = emptyList(), /** Fetched link-preview metadata for the first URL in the body, if any. */ val linkMetadata: LinkMetadataDto? = null, + /** + * Free-form tags on the message. The feed already carries these, so the card + * renders them without a second fetch. Values may contain spaces and + * punctuation — never split or normalise them. + */ + val tags: List = emptyList(), /** Future send time for a scheduled message; null once published. */ val scheduledAt: String? = null, /** False when the message is private (visible only to its author). */ @@ -114,6 +120,8 @@ fun MessageDto.toDomain(currentUserId: String?): Message { imageUrls = imageUrls, videoUrls = videoUrls, linkPreview = linkMetadata?.toDomain(), + // Kept verbatim: a tag is a label, not a token. + tags = tags, scheduledAt = scheduledAt, publiclyVisible = publiclyVisible, pushCount = pushCount, diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt index dcfc9bc..24e3cea 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt @@ -115,6 +115,10 @@ data class CrossPostStatusDto( * push (repost), and with content it is a quote. It is mutually exclusive with * [parentId] and [scheduledAt], and a push/quote is always public. * + * [tags] are free-form string labels ("Case-sensitive; lowercase recommended" — + * `/help/api/messages`). They are sent exactly as the user committed them: a tag + * may contain spaces and punctuation, so nothing splits or rewrites them. + * * Only non-null fields are serialised (the shared Json uses `explicitNulls = * false`), so a plain InterlinedList-only post sends just * `{ content, publiclyVisible }` and a bare push sends no `content` at all. @@ -129,6 +133,7 @@ data class CreateMessageRequest( val imageUrls: List? = null, val videoUrls: List? = null, val scheduledAt: String? = null, + val tags: List? = null, val mastodonProviderIds: List? = null, val crossPostToBluesky: Boolean? = null, val crossPostToLinkedIn: Boolean? = null, 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 new file mode 100644 index 0000000..ba70ed4 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt @@ -0,0 +1,32 @@ +package com.interlinedlist.android.feature.messages.data.remote.dto + +import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import kotlinx.serialization.Serializable + +/** + * Response from `GET /api/tags/autocomplete?q=…`: + * `{ "tags": [ { "tag": "lists", "count": 8 }, … ] }`. + * + * Suggestions arrive already ordered by count (descending), then alphabetically, + * and already prefix-matched case-insensitively by the server — so the app keeps + * the order and the contents exactly as sent. + */ +@Serializable +data class TagAutocompleteResponse( + val tags: List = emptyList(), +) { + /** The suggestions as domain values, dropping any entry with no tag. */ + fun toDomain(): List = tags.mapNotNull { it.toDomainOrNull() } +} + +/** One `{ tag, count }` entry in an autocomplete (or trending) tag response. */ +@Serializable +data class TagSuggestionDto( + val tag: String = "", + val count: Int = 0, +) { + fun toDomainOrNull(): TagSuggestion? { + val label = tag.takeIf { it.isNotBlank() } ?: return null + return TagSuggestion(tag = label, count = count) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt index 8a8d183..89a3d19 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt @@ -29,6 +29,12 @@ data class Message( val videoUrls: List = emptyList(), /** Link-preview card built from fetched metadata, when present. */ val linkPreview: LinkPreview? = null, + /** + * Free-form labels attached to the message, exactly as the feed returned + * them (`tags[]`). They may contain spaces and punctuation, so they are never + * tokenised or normalised — only rendered. + */ + val tags: List = emptyList(), /** * ISO-8601 send time for a scheduled (not-yet-published) message; null for a * normal message. Present on rows returned by the scheduled endpoint. @@ -65,6 +71,9 @@ data class Message( /** True when any image or video media is attached. */ val hasMedia: Boolean get() = imageUrls.isNotEmpty() || videoUrls.isNotEmpty() + /** True when the card should render a tag row. */ + val hasTags: Boolean get() = tags.isNotEmpty() + /** True when the message has been edited since it was posted. */ val isEdited: Boolean get() = editedAt != null diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TagSuggestion.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TagSuggestion.kt new file mode 100644 index 0000000..95546a9 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TagSuggestion.kt @@ -0,0 +1,18 @@ +package com.interlinedlist.android.feature.messages.domain + +/** + * One autocomplete suggestion from `GET /api/tags/autocomplete`: an existing + * public tag and how many public messages already use it. + * + * A tag is a **free-form label**, not a hashtag token: the live endpoint happily + * returns values containing spaces and punctuation (e.g. + * `"life is short, o brave girl"`), so nothing here may tokenise or normalise it. + * The server matches a **case-insensitive literal prefix** against [tag]; the app + * shows exactly what the server returned and never filters or fuzzy-matches on + * top of it, which would show suggestions the server would never have given. + */ +data class TagSuggestion( + val tag: String, + /** Public messages using this tag; the server orders suggestions by it. */ + val count: Int = 0, +) 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 d9241c2..9ef7ab6 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 @@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -70,6 +72,13 @@ object MessageCardTags { const val PUSH_HEADER = "messagePushHeader" /** The embedded original rendered inside a push or a quote. */ const val PUSHED_ORIGINAL = "messagePushedOriginal" + + /** The row of tags on a tagged message. */ + const val TAGS = "messageTags" + /** Prefix for one tag label; suffixed with the tag itself. */ + const val TAG_PREFIX = "messageTag_" + + fun tagTag(tag: String): String = TAG_PREFIX + tag } /** @@ -173,6 +182,10 @@ fun MessageCard( Spacer(Modifier.size(8.dp)) MessageMedia(message = message, onOpenLink = onOpenLink) } + if (message.hasTags) { + Spacer(Modifier.size(8.dp)) + TagRow(tags = message.tags) + } Spacer(Modifier.size(8.dp)) EngagementRow( message = message, @@ -186,6 +199,37 @@ fun MessageCard( } } +/** + * The message's tags, straight from the `tags[]` the feed already returned — no + * 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. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TagRow(tags: List) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .fillMaxWidth() + .testTag(MessageCardTags.TAGS), + ) { + tags.forEach { tag -> + Text( + text = tag, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 2.dp) + .testTag(MessageCardTags.tagTag(tag)), + ) + } + } +} + /** Dig, reply, and — where the rules allow it — push and quote. */ @Composable private fun EngagementRow( 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 c945fd2..86514ef 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 @@ -24,6 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState 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.filled.Add import androidx.compose.material.icons.filled.Close @@ -33,6 +35,7 @@ import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Sell import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -42,6 +45,7 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField @@ -60,6 +64,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -73,6 +78,7 @@ import com.interlinedlist.android.feature.messages.domain.LinkedNetwork 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.ui.components.EditMessageSheet import com.interlinedlist.android.feature.messages.ui.components.MessageCard import com.interlinedlist.android.feature.messages.ui.components.ModerationDialog @@ -115,12 +121,26 @@ object MessagesFeedTags { const val VISIBILITY_PRIVATE = "messagesComposeVisibilityPrivate" const val VISIBILITY_HINT = "messagesComposeVisibilityHint" + /** The composer's tag field, its Add action, and the committed tag chips. */ + const val COMPOSE_TAG_INPUT = "messagesComposeTagInput" + const val COMPOSE_TAG_ADD = "messagesComposeTagAdd" + const val COMPOSE_TAGS = "messagesComposeTags" + /** Prefix for a committed tag chip; suffixed with the tag. */ + const val COMPOSE_TAG_PREFIX = "messagesComposeTag_" + /** The autocomplete suggestion row, and one suggestion chip within it. */ + const val TAG_SUGGESTIONS = "messagesComposeTagSuggestions" + const val TAG_SUGGESTION_PREFIX = "messagesComposeTagSuggestion_" + /** The quoted message attached to the composer, and its always-public banner. */ const val QUOTE_ATTACHED = "messagesComposeQuoteAttached" const val QUOTE_PUBLIC_BANNER = "messagesComposeQuoteBanner" fun destinationTag(networkId: String): String = DESTINATION_PREFIX + networkId + fun composeTagTag(tag: String): String = COMPOSE_TAG_PREFIX + tag + + fun tagSuggestionTag(tag: String): String = TAG_SUGGESTION_PREFIX + tag + fun viewPreferenceTag(preference: ViewingPreference): String = VIEW_PREFERENCE_PREFIX + preference.wire } @@ -169,6 +189,10 @@ fun MessagesRoute( } }, onRemoveAttachment = viewModel::onRemoveAttachment, + onTagQueryChange = viewModel::onTagQueryChange, + onCommitTag = viewModel::commitTag, + onSelectTagSuggestion = viewModel::onSelectTagSuggestion, + onRemoveTag = viewModel::onRemoveTag, onScheduleChange = viewModel::onScheduleChange, onVisibilityChange = viewModel::onVisibilityChange, onToggleNetwork = viewModel::onToggleNetwork, @@ -211,6 +235,10 @@ fun MessagesFeedScreen( onFetchMetadata: (Message) -> Unit = {}, onAttachMedia: (Uri, Boolean) -> Unit = { _, _ -> }, onRemoveAttachment: (PendingAttachment) -> Unit = {}, + onTagQueryChange: (String) -> Unit = {}, + onCommitTag: () -> Unit = {}, + onSelectTagSuggestion: (TagSuggestion) -> Unit = {}, + onRemoveTag: (String) -> Unit = {}, onScheduleChange: (String?) -> Unit = {}, onVisibilityChange: (MessageVisibility) -> Unit = {}, onToggleNetwork: (String) -> Unit = {}, @@ -285,6 +313,10 @@ fun MessagesFeedScreen( onPost = onPost, onAttachMedia = onAttachMedia, onRemoveAttachment = onRemoveAttachment, + onTagQueryChange = onTagQueryChange, + onCommitTag = onCommitTag, + onSelectTagSuggestion = onSelectTagSuggestion, + onRemoveTag = onRemoveTag, onScheduleChange = onScheduleChange, onVisibilityChange = onVisibilityChange, onToggleNetwork = onToggleNetwork, @@ -545,6 +577,10 @@ private fun ComposeSheet( onPost: () -> Unit, onAttachMedia: (Uri, Boolean) -> Unit, onRemoveAttachment: (PendingAttachment) -> Unit, + onTagQueryChange: (String) -> Unit, + onCommitTag: () -> Unit, + onSelectTagSuggestion: (TagSuggestion) -> Unit, + onRemoveTag: (String) -> Unit, onScheduleChange: (String?) -> Unit, onVisibilityChange: (MessageVisibility) -> Unit, onToggleNetwork: (String) -> Unit, @@ -624,6 +660,20 @@ private fun ComposeSheet( } } + Spacer(Modifier.height(12.dp)) + TagInput( + tags = state.composeTags, + query = state.tagQuery, + suggestions = state.tagSuggestions, + canCommit = state.canCommitTag, + isLoadingSuggestions = state.isLoadingTagSuggestions, + enabled = !state.isPosting, + onQueryChange = onTagQueryChange, + onCommit = onCommitTag, + onSelectSuggestion = onSelectTagSuggestion, + onRemoveTag = onRemoveTag, + ) + Spacer(Modifier.height(12.dp)) VisibilityRow( visibility = state.composeVisibility, @@ -703,6 +753,118 @@ private fun AttachmentRow( } } +/** + * The tag input: the tags already added, a field to type the next one, and the + * server's prefix suggestions underneath. + * + * A tag is a **free-form label**, not a hashtag — the live API happily returns + * tags containing spaces and punctuation — so the field never splits what is + * typed. A tag is committed deliberately: by tapping Add, by pressing the + * keyboard's Done action, or by tapping one of the suggestions. + * + * The suggestions are rendered in the order the server sent them and are not + * re-filtered here: matching is the server's case-insensitive literal prefix, + * and second-guessing it would show (or hide) results it would never have given. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun TagInput( + tags: List, + query: String, + suggestions: List, + canCommit: Boolean, + isLoadingSuggestions: Boolean, + enabled: Boolean, + onQueryChange: (String) -> Unit, + onCommit: () -> Unit, + onSelectSuggestion: (TagSuggestion) -> Unit, + onRemoveTag: (String) -> Unit, +) { + Column { + Text( + text = "Tags", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (tags.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().testTag(MessagesFeedTags.COMPOSE_TAGS), + ) { + tags.forEach { tag -> + InputChip( + selected = true, + onClick = { onRemoveTag(tag) }, + enabled = enabled, + label = { Text(tag) }, + trailingIcon = { + Icon( + Icons.Filled.Close, + contentDescription = "Remove tag $tag", + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.testTag(MessagesFeedTags.composeTagTag(tag)), + ) + } + } + } + Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + enabled = enabled, + singleLine = true, + placeholder = { Text("Add a tag") }, + leadingIcon = { + // The lookup is debounced, so say when one is actually running. + if (isLoadingSuggestions) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Filled.Sell, contentDescription = null, modifier = Modifier.size(18.dp)) + } + }, + trailingIcon = { + TextButton( + onClick = onCommit, + enabled = enabled && canCommit, + modifier = Modifier.testTag(MessagesFeedTags.COMPOSE_TAG_ADD), + ) { + Text("Add") + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onCommit() }), + modifier = Modifier + .fillMaxWidth() + .testTag(MessagesFeedTags.COMPOSE_TAG_INPUT), + ) + if (suggestions.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().testTag(MessagesFeedTags.TAG_SUGGESTIONS), + ) { + suggestions.forEach { suggestion -> + AssistChip( + onClick = { onSelectSuggestion(suggestion) }, + enabled = enabled, + label = { Text(suggestion.chipLabel) }, + modifier = Modifier.testTag( + MessagesFeedTags.tagSuggestionTag(suggestion.tag), + ), + ) + } + } + } + } +} + +/** A suggestion reads as the tag itself, with its usage count when it has one. */ +private val TagSuggestion.chipLabel: String + get() = if (count > 0) "$tag ($count)" else tag + /** * The Public / Private control. Seeded from the account's default-visibility * preference and overridable for this message only; the selection is always sent 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 cd1c8ab..305cffe 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 @@ -12,9 +12,12 @@ import com.interlinedlist.android.feature.messages.domain.LinkedNetwork 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.ui.isSubscriptionGate import com.interlinedlist.android.feature.messages.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -53,6 +56,14 @@ data class MessagesFeedUiState( val attachments: List = emptyList(), /** Optional future send time (ISO-8601) for the in-progress compose. */ val scheduledAt: String? = null, + /** Tags already committed to the in-progress compose, in the order added. */ + val composeTags: List = emptyList(), + /** What the user has typed into the tag field but not yet committed. */ + val tagQuery: String = "", + /** Server-supplied prefix suggestions for [tagQuery], in the server's order. */ + val tagSuggestions: List = emptyList(), + /** True while a suggestion lookup is pending or in flight. */ + val isLoadingTagSuggestions: Boolean = false, /** * Visibility the in-progress compose will post with: the account's * `defaultPubliclyVisible` preference unless the user overrode it here — or @@ -100,6 +111,10 @@ data class MessagesFeedUiState( get() = (composeText.isNotBlank() || attachments.any { it.hostedUrl != null }) && !isPosting && !isUploading + /** A tag can be committed when the field holds something new and non-blank. */ + val canCommitTag: Boolean + get() = tagQuery.trim().let { it.isNotEmpty() && it !in composeTags } + /** True when the account has no linked networks to cross-post to. */ val hasNoLinkedNetworks: Boolean get() = linkedNetworks.isEmpty() @@ -145,6 +160,10 @@ private data class FeedTransientState( val isPosting: Boolean = false, val attachments: List = emptyList(), val scheduledAt: String? = null, + val composeTags: List = emptyList(), + val tagQuery: String = "", + val tagSuggestions: List = emptyList(), + val isLoadingTagSuggestions: Boolean = false, /** The account preference; the fallback until/unless the user overrides it. */ val defaultVisibility: MessageVisibility = MessageVisibility.PUBLIC, /** The user's per-message choice for the open composer; null = use the default. */ @@ -181,6 +200,16 @@ private data class FeedTransientState( /** More pages remain exactly while the server handed back a cursor. */ val canLoadMore: Boolean get() = nextCursor != null + + /** + * The tags to send with the post: the committed ones, plus whatever is still + * typed in the field. Requiring a separate commit tap before posting would + * silently drop a tag the user clearly intended. + */ + val tagsToPost: List + get() = tagQuery.trim().let { pending -> + if (pending.isEmpty() || pending in composeTags) composeTags else composeTags + pending + } } @HiltViewModel @@ -190,6 +219,13 @@ class MessagesFeedViewModel @Inject constructor( private val transient = MutableStateFlow(FeedTransientState()) + /** + * The one in-flight tag-suggestion lookup, held so the next keystroke can + * cancel it. Cancelling covers both halves of the work: the pending debounce + * delay and, if it already started, the network request itself. + */ + private var tagSuggestionJob: Job? = null + /** * Room is the source of truth: the feed list comes from the cache Flow and is * combined with transient flags into a single [MessagesFeedUiState]. @@ -210,6 +246,10 @@ class MessagesFeedViewModel @Inject constructor( isPosting = t.isPosting, attachments = t.attachments, scheduledAt = t.scheduledAt, + composeTags = t.composeTags, + tagQuery = t.tagQuery, + tagSuggestions = t.tagSuggestions, + isLoadingTagSuggestions = t.isLoadingTagSuggestions, composeVisibility = t.composeVisibility, quoteTarget = t.quoteTarget, linkedNetworks = t.linkedNetworks, @@ -440,17 +480,25 @@ class MessagesFeedViewModel @Inject constructor( it.copy(isComposeOpen = true, errorMessage = null, crossPostStatuses = emptyList()) } - fun dismissCompose() = transient.update { - it.copy( - isComposeOpen = false, - composeText = "", - attachments = emptyList(), - scheduledAt = null, - // Drop the per-message override; the next compose starts from the default. - visibilityOverride = null, - quoteTarget = null, - selectedNetworkIds = emptySet(), - ) + fun dismissCompose() { + // Nothing left to suggest for: drop the pending/in-flight lookup. + tagSuggestionJob?.cancel() + transient.update { + it.copy( + isComposeOpen = false, + composeText = "", + attachments = emptyList(), + scheduledAt = null, + composeTags = emptyList(), + tagQuery = "", + tagSuggestions = emptyList(), + isLoadingTagSuggestions = false, + // Drop the per-message override; the next compose starts from the default. + visibilityOverride = null, + quoteTarget = null, + selectedNetworkIds = emptySet(), + ) + } } /** @@ -523,6 +571,80 @@ class MessagesFeedViewModel @Inject constructor( it.copy(attachments = it.attachments - attachment) } + // --- tags -------------------------------------------------------------- + + /** + * Records the in-progress tag text and asks the server for suggestions. + * + * Every keystroke **supersedes** the last one: the previous lookup is + * cancelled — whether it is still waiting out the debounce or already has a + * request in flight — so a fast typist produces one request, not a pile of + * concurrent ones. Only after [TAG_SUGGESTION_DEBOUNCE_MS] of quiet does the + * call actually go out. + * + * The response is applied only while it still answers the current text. A + * reply that arrives after the user has typed on is dropped, so a slow + * response for an older prefix can never overwrite a newer one. + */ + fun onTagQueryChange(value: String) { + transient.update { it.copy(tagQuery = value) } + tagSuggestionJob?.cancel() + // The server 400s on an empty `q`, and there is nothing to complete. + val query = value.trim() + if (query.isEmpty()) { + transient.update { it.copy(tagSuggestions = emptyList(), isLoadingTagSuggestions = false) } + return + } + transient.update { it.copy(isLoadingTagSuggestions = true) } + tagSuggestionJob = viewModelScope.launch { + delay(TAG_SUGGESTION_DEBOUNCE_MS) + val result = repository.autocompleteTags(query) + // Guard against a stale answer: by the time a response lands the user + // may have typed on, and the newer lookup's answer is the right one. + if (transient.value.tagQuery.trim() != query) return@launch + transient.update { + it.copy( + // Suggestions are an assist, not the task: a failed lookup + // just leaves the user typing their own tag, with no error. + tagSuggestions = (result as? ApiResult.Success)?.data.orEmpty(), + isLoadingTagSuggestions = false, + ) + } + } + } + + /** + * Commits whatever is in the tag field as a tag. Only surrounding whitespace + * is trimmed: a tag is a free-form label ("life is short, o brave girl" is a + * real one), so the text is never split on spaces or otherwise rewritten. + * Duplicates are ignored — tags are case-sensitive, so only an exact repeat + * counts as one. + */ + fun commitTag() = addTag(transient.value.tagQuery) + + /** Adds a suggestion the user tapped, exactly as the server spelled it. */ + fun onSelectTagSuggestion(suggestion: TagSuggestion) = addTag(suggestion.tag) + + /** Removes an already-committed tag from the in-progress compose. */ + fun onRemoveTag(tag: String) = transient.update { + it.copy(composeTags = it.composeTags - tag) + } + + private fun addTag(raw: String) { + val tag = raw.trim() + if (tag.isEmpty()) return + // The field is now empty, so there is nothing left to suggest for. + tagSuggestionJob?.cancel() + transient.update { + it.copy( + composeTags = if (tag in it.composeTags) it.composeTags else it.composeTags + tag, + tagQuery = "", + tagSuggestions = emptyList(), + isLoadingTagSuggestions = false, + ) + } + } + fun post() { val snapshot = transient.value val text = snapshot.composeText.trim() @@ -534,6 +656,8 @@ class MessagesFeedViewModel @Inject constructor( // Fold the selected linked networks into the cross-post request fields. val selected = snapshot.linkedNetworks.filter { it.id in snapshot.selectedNetworkIds } val crossPost = CrossPostSelection.from(selected) + // The compose is closing either way; a suggestion lookup is now moot. + tagSuggestionJob?.cancel() transient.update { it.copy(isPosting = true, errorMessage = null, crossPostStatuses = emptyList()) } viewModelScope.launch { when ( @@ -546,6 +670,9 @@ class MessagesFeedViewModel @Inject constructor( visibility = snapshot.composeVisibility, // Present only for a quote; the repository forces it public. pushedMessageId = snapshot.quoteTarget?.id, + // Committed chips, plus anything still sitting uncommitted in + // the field — the user meant that word as a tag too. + tags = snapshot.tagsToPost, ) ) { is ApiResult.Success -> transient.update { @@ -555,6 +682,10 @@ class MessagesFeedViewModel @Inject constructor( composeText = "", attachments = emptyList(), scheduledAt = null, + composeTags = emptyList(), + tagQuery = "", + tagSuggestions = emptyList(), + isLoadingTagSuggestions = false, visibilityOverride = null, quoteTarget = null, selectedNetworkIds = emptySet(), @@ -677,4 +808,13 @@ class MessagesFeedViewModel @Inject constructor( private fun FeedTransientState.withError(error: AppError?): FeedTransientState = if (error == null) this else copy(errorMessage = error.toUserMessage(), subscriptionRequired = error.isSubscriptionGate) + + companion object { + /** + * Quiet period before a tag prefix is looked up. Long enough that typing + * a word straight through costs one request, short enough that the + * suggestions still feel live. + */ + const val TAG_SUGGESTION_DEBOUNCE_MS = 300L + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt index 250191c..a7cd7c8 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt @@ -900,4 +900,112 @@ class DefaultMessagesRepositoryTest { assertThat(error).isInstanceOf(AppError.Forbidden::class.java) assertThat(error.message).isEqualTo("You cannot push your own message") } + + // --- tags -------------------------------------------------------------- + + @Test + fun `createMessage sends the tags array`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "t1", "content": "tagged", "tags": ["lists", "llms"] } }""", + ) + val repo = repository() + + val result = repo.createMessage(content = "tagged", tags = listOf("lists", "llms")) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"tags\":[\"lists\",\"llms\"]") + } + + @Test + fun `createMessage omits tags entirely when there are none`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", "data": { "id": "t2", "content": "plain" } }""", + ) + val repo = repository() + + repo.createMessage(content = "plain") + + assertThat(server.takeRequest().body.readUtf8()).doesNotContain("tags") + } + + @Test + fun `a tag containing spaces and punctuation round-trips unchanged`() = runTest(dispatcher) { + // Straight from the live API: tags are free-form labels, not word tokens. + val tag = "life is short, o brave girl" + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "t3", "content": "tagged", + "tags": ["life is short, o brave girl"] } }""", + ) + val repo = repository() + + val result = repo.createMessage(content = "tagged", tags = listOf(tag)) + + // Out: one whole string, not split on the spaces or the comma. + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"tags\":[\"life is short, o brave girl\"]") + // Back: the same single tag, verbatim, on the message and in the cache. + assertThat((result as ApiResult.Success).data.message.tags).containsExactly(tag) + assertThat(repo.observeMessage("t3").first()?.tags).containsExactly(tag) + } + + @Test + fun `refreshFeed keeps the tags the feed already returned`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "messages": [ { "id": "1", "content": "hi", "tags": ["lists", "Lego"] }, + { "id": "2", "content": "no tags" } ], + "pagination": { "hasMore": false } }""", + ) + val repo = repository() + + repo.refreshFeed() + + val cached = repo.observeFeed().first() + assertThat(cached.first { it.id == "1" }.tags).containsExactly("lists", "Lego").inOrder() + assertThat(cached.first { it.id == "2" }.tags).isEmpty() + } + + @Test + fun `autocompleteTags asks with q and keeps the server's order`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "tags": [ { "tag": "lists", "count": 8 }, { "tag": "llms", "count": 5 }, + { "tag": "Lego", "count": 2 }, + { "tag": "life is short, o brave girl", "count": 2 } ] }""", + ) + val repo = repository() + + val result = repo.autocompleteTags("l") + + val request = server.takeRequest().requestUrl!! + assertThat(request.encodedPath).isEqualTo("/api/tags/autocomplete") + // `prefix` is a 400 from this endpoint — the parameter really is `q`. + assertThat(request.queryParameter("q")).isEqualTo("l") + assertThat(request.queryParameter("prefix")).isNull() + assertThat(request.queryParameter("limit")).isEqualTo("10") + val suggestions = (result as ApiResult.Success).data + // Case-insensitive matching is the server's job, and so is the ordering: + // "Lego" is a legitimate answer for "l" and must not be filtered out here. + assertThat(suggestions.map { it.tag }) + .containsExactly("lists", "llms", "Lego", "life is short, o brave girl").inOrder() + assertThat(suggestions.first().count).isEqualTo(8) + } + + @Test + fun `autocompleteTags surfaces the server's own rejection`() = runTest(dispatcher) { + enqueueJson(400, """{ "error": "Query parameter 'q' is required", "code": "bad_request" }""") + val repo = repository() + + val result = repo.autocompleteTags("") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.message) + .isEqualTo("Query parameter 'q' is required") + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt index 2ab37a7..bd98251 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt @@ -264,4 +264,25 @@ class MessageDtoMapperTest { assertThat(message.pushedMessage).isNull() assertThat(message.isReshare).isFalse() } + + @Test + fun `carries the tags the feed returned, verbatim`() { + val message = MessageDto( + id = "m3", + content = "tagged", + // Free-form labels: mixed case and inner punctuation are both real. + tags = listOf("lists", "Lego", "life is short, o brave girl"), + ).toDomain(currentUserId = null) + + assertThat(message.tags) + .containsExactly("lists", "Lego", "life is short, o brave girl").inOrder() + assertThat(message.hasTags).isTrue() + } + + @Test + fun `an untagged message has no tags`() { + val message = MessageDto(id = "m4", content = "plain").toDomain(currentUserId = null) + assertThat(message.tags).isEmpty() + assertThat(message.hasTags).isFalse() + } } 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 5a37f8f..2e3578d 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 @@ -12,9 +12,14 @@ import com.interlinedlist.android.feature.messages.domain.Message 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 kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException /** * A configurable in-memory [MessagesRepository] for ViewModel tests. Feed/reply @@ -56,6 +61,9 @@ class FakeMessagesRepository : MessagesRepository { var cancelScheduledResult: ApiResult = ApiResult.Success(Unit) var reportResult: ApiResult = ApiResult.Success(Unit) var metadataResult: ApiResult? = null + /** Per-query canned autocomplete answers; anything else falls back below. */ + val autocompleteResponses = mutableMapOf>>() + var autocompleteResult: ApiResult> = ApiResult.Success(emptyList()) /** 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. */ @@ -86,6 +94,32 @@ class FakeMessagesRepository : MessagesRepository { var blockedUsernames = mutableListOf() var mutedUsernames = mutableListOf() var lastReportUser: ReportUserArgs? = null + /** Every query [autocompleteTags] was asked for, in order. */ + val autocompleteQueries = mutableListOf() + /** Queries whose in-flight call was cancelled before it could answer. */ + val cancelledAutocompleteQueries = mutableListOf() + /** Queries the fake actually answered (as opposed to never getting to). */ + val completedAutocompleteQueries = mutableListOf() + private val autocompleteGates = mutableMapOf() + + /** + * Makes the lookup for [query] hang until [releaseAutocomplete], so a test can + * hold a request "in flight" across the next keystroke. + * + * [ignoreCancellation] models the nastier race: the server had already + * answered, so the response lands **even though** the caller was cancelled. + * Only a stale-query guard can discard that one. + */ + fun gateAutocomplete(query: String, ignoreCancellation: Boolean = false) { + autocompleteGates[query] = TagGate(CompletableDeferred(), ignoreCancellation) + } + + /** Lets a gated lookup answer. */ + fun releaseAutocomplete(query: String) { + autocompleteGates[query]?.signal?.complete(Unit) + } + + private class TagGate(val signal: CompletableDeferred, val ignoreCancellation: Boolean) /** Snapshot of the arguments passed to the last [createMessage] call. */ data class CreateArgs( @@ -96,6 +130,7 @@ class FakeMessagesRepository : MessagesRepository { val crossPost: CrossPostSelection = CrossPostSelection.NONE, val visibility: MessageVisibility = MessageVisibility.PUBLIC, val pushedMessageId: String? = null, + val tags: List = emptyList(), ) /** Snapshot of the arguments passed to the last [report] call. */ @@ -151,9 +186,10 @@ class FakeMessagesRepository : MessagesRepository { crossPost: CrossPostSelection, visibility: MessageVisibility, pushedMessageId: String?, + tags: List, ): ApiResult { lastCreate = CreateArgs( - content, imageUrls, videoUrls, scheduledAt, crossPost, visibility, pushedMessageId, + content, imageUrls, videoUrls, scheduledAt, crossPost, visibility, pushedMessageId, tags, ) return when (val result = createResult) { is ApiResult.Success -> ApiResult.Success(CreatedMessage(result.data, createCrossPosts)) @@ -258,6 +294,26 @@ class FakeMessagesRepository : MessagesRepository { return metadataResult ?: ApiResult.Failure(AppError.Unknown("metadataResult not set")) } + override suspend fun autocompleteTags(query: String, limit: Int): ApiResult> { + autocompleteQueries += query + autocompleteGates[query]?.let { gate -> + if (gate.ignoreCancellation) { + // The request is already past the point of no return: it answers + // whatever the caller does. + withContext(NonCancellable) { gate.signal.await() } + } else { + try { + gate.signal.await() + } catch (cancellation: CancellationException) { + cancelledAutocompleteQueries += query + throw cancellation + } + } + } + completedAutocompleteQueries += query + return autocompleteResponses[query] ?: autocompleteResult + } + override suspend fun search(query: String): ApiResult> = searchResult } @@ -290,6 +346,7 @@ fun sampleMessage( pushCount: Int = 0, pushedMessageId: String? = null, pushedMessage: PushedMessage? = null, + tags: List = emptyList(), ) = Message( id = id, content = content, @@ -311,6 +368,7 @@ fun sampleMessage( pushCount = pushCount, pushedMessageId = pushedMessageId, pushedMessage = pushedMessage, + tags = tags, ) /** Builds a sample embedded original (the `pushedMessage` on a push/quote). */ diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/ComposerTagsTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/ComposerTagsTest.kt new file mode 100644 index 0000000..3c1063a --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/ComposerTagsTest.kt @@ -0,0 +1,395 @@ +package com.interlinedlist.android.feature.messages.ui.feed + +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.TagSuggestion +import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +import com.interlinedlist.android.feature.messages.ui.sampleMessage +import com.interlinedlist.android.feature.messages.ui.feed.MessagesFeedViewModel.Companion.TAG_SUGGESTION_DEBOUNCE_MS +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +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 composer's tag input: what gets posted, and how prefix autocomplete behaves + * while the user types. + * + * The autocomplete contract is deliberately strict, because getting it wrong is + * invisible until it hurts: every keystroke supersedes the last (one request per + * pause, not one per key), and a response that lost the race is discarded rather + * than allowed to overwrite newer suggestions. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ComposerTagsTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(repo: FakeMessagesRepository) = MessagesFeedViewModel(repo) + + /** Types [text] one character at a time, faster than the debounce window. */ + private fun MessagesFeedViewModel.typeFast(text: String) { + text.indices.forEach { index -> onTagQueryChange(text.substring(0, index + 1)) } + } + + // --- posting tags ------------------------------------------------------ + + @Test + fun `post sends the committed tags`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { createResult = ApiResult.Success(sampleMessage()) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onComposeTextChange("tagged post") + vm.onTagQueryChange("lists") + vm.commitTag() + vm.onTagQueryChange("llms") + vm.commitTag() + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.tags).containsExactly("lists", "llms").inOrder() + } + + @Test + fun `post sends no tags when none were added`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { createResult = ApiResult.Success(sampleMessage()) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onComposeTextChange("plain post") + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.tags).isEmpty() + } + + @Test + fun `a tag left uncommitted in the field is still posted`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { createResult = ApiResult.Success(sampleMessage()) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onComposeTextChange("post") + vm.onTagQueryChange("lists") + vm.commitTag() + // Typed, but the user hit Post without committing this one. + vm.onTagQueryChange(" llms ") + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.tags).containsExactly("lists", "llms").inOrder() + } + + @Test + fun `a tag containing spaces and punctuation is kept whole`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { createResult = ApiResult.Success(sampleMessage()) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onComposeTextChange("post") + // A real tag from the live API: spaces and a comma, not a hashtag token. + vm.onTagQueryChange("life is short, o brave girl") + vm.commitTag() + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.tags).containsExactly("life is short, o brave girl") + } + + @Test + fun `committing a tag clears the field and its suggestions`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + autocompleteResponses["lis"] = ApiResult.Success(listOf(TagSuggestion("lists", 8))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onTagQueryChange("lis") + advanceUntilIdle() + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().tagSuggestions).hasSize(1) + + vm.commitTag() + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.composeTags).containsExactly("lis") + assertThat(state.tagQuery).isEmpty() + assertThat(state.tagSuggestions).isEmpty() + } + } + + @Test + fun `the same tag is not added twice`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onTagQueryChange("lists") + vm.commitTag() + vm.onTagQueryChange("lists") + vm.commitTag() + advanceUntilIdle() + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().composeTags).containsExactly("lists") + } + } + + @Test + fun `a removed tag is not posted`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { createResult = ApiResult.Success(sampleMessage()) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onComposeTextChange("post") + vm.onTagQueryChange("keep") + vm.commitTag() + vm.onTagQueryChange("drop") + vm.commitTag() + vm.onRemoveTag("drop") + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.tags).containsExactly("keep") + } + + @Test + fun `a successful post clears the tag state for the next compose`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { createResult = ApiResult.Success(sampleMessage()) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onComposeTextChange("post") + vm.onTagQueryChange("lists") + vm.commitTag() + vm.post() + advanceUntilIdle() + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.composeTags).isEmpty() + assertThat(state.tagQuery).isEmpty() + } + } + + @Test + fun `tapping a suggestion adds it exactly as the server spelled it`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { createResult = ApiResult.Success(sampleMessage()) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onComposeTextChange("post") + vm.onTagQueryChange("le") + // The server answers case-insensitively: "le" can suggest "Lego". + vm.onSelectTagSuggestion(TagSuggestion("Lego", 2)) + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.tags).containsExactly("Lego") + } + + // --- autocomplete: debounce ------------------------------------------- + + @Test + fun `typing a word straight through issues a single request for the final prefix`() = + runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.typeFast("list") + advanceUntilIdle() + + assertThat(repo.autocompleteQueries).containsExactly("list") + } + + @Test + fun `no request goes out before the debounce window elapses`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onTagQueryChange("li") + advanceTimeBy(TAG_SUGGESTION_DEBOUNCE_MS - 1) + + assertThat(repo.autocompleteQueries).isEmpty() + + advanceUntilIdle() + assertThat(repo.autocompleteQueries).containsExactly("li") + } + + @Test + fun `a pause between words issues one request per word`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.typeFast("li") + advanceUntilIdle() + vm.typeFast("lists") + advanceUntilIdle() + + assertThat(repo.autocompleteQueries).containsExactly("li", "lists").inOrder() + } + + @Test + fun `clearing the field asks for nothing and drops the suggestions`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + autocompleteResponses["li"] = ApiResult.Success(listOf(TagSuggestion("lists", 8))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onTagQueryChange("li") + advanceUntilIdle() + vm.onTagQueryChange("") + advanceUntilIdle() + + // An empty `q` is a 400 from this endpoint; it must never be sent. + assertThat(repo.autocompleteQueries).containsExactly("li") + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.tagSuggestions).isEmpty() + assertThat(state.isLoadingTagSuggestions).isFalse() + } + } + + @Test + fun `suggestions are shown in the order the server returned them`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + // The live shape: ordered by count desc, case-insensitively matched, + // and free to contain spaces and punctuation. + autocompleteResponses["l"] = ApiResult.Success( + listOf( + TagSuggestion("lists", 8), + TagSuggestion("llms", 5), + TagSuggestion("Lego", 2), + TagSuggestion("life is short, o brave girl", 2), + ), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onTagQueryChange("l") + advanceUntilIdle() + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.tagSuggestions.map { it.tag }) + .containsExactly("lists", "llms", "Lego", "life is short, o brave girl").inOrder() + assertThat(state.isLoadingTagSuggestions).isFalse() + } + } + + @Test + fun `a failed lookup quietly leaves no suggestions and no feed error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + autocompleteResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onTagQueryChange("li") + advanceUntilIdle() + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.tagSuggestions).isEmpty() + assertThat(state.isLoadingTagSuggestions).isFalse() + assertThat(state.errorMessage).isNull() + } + } + + // --- autocomplete: cancellation --------------------------------------- + + @Test + fun `a keystroke cancels the request already in flight`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { gateAutocomplete("li") } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onTagQueryChange("li") + advanceTimeBy(TAG_SUGGESTION_DEBOUNCE_MS + 1) + assertThat(repo.autocompleteQueries).containsExactly("li") // in flight, gated + + vm.onTagQueryChange("lis") + advanceUntilIdle() + + assertThat(repo.cancelledAutocompleteQueries).containsExactly("li") + assertThat(repo.autocompleteQueries).containsExactly("li", "lis").inOrder() + } + + @Test + fun `closing the composer cancels the pending lookup`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.openCompose() + vm.onTagQueryChange("li") + vm.dismissCompose() + advanceUntilIdle() + + assertThat(repo.autocompleteQueries).isEmpty() + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.tagQuery).isEmpty() + assertThat(state.composeTags).isEmpty() + assertThat(state.tagSuggestions).isEmpty() + } + } + + @Test + fun `a late response for an older prefix never overwrites a newer one`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + autocompleteResponses["li"] = ApiResult.Success(listOf(TagSuggestion("lists", 8))) + autocompleteResponses["lego"] = ApiResult.Success(listOf(TagSuggestion("Lego", 2))) + // "li" is already past the point of no return: the server will answer + // it even though the caller has moved on. + gateAutocomplete("li", ignoreCancellation = true) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onTagQueryChange("li") + advanceTimeBy(TAG_SUGGESTION_DEBOUNCE_MS + 1) // "li" is now in flight + vm.onTagQueryChange("lego") + advanceUntilIdle() // "lego" answers first + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().tagSuggestions.map { it.tag }).containsExactly("Lego") + + // The overtaken "li" response finally lands... + repo.releaseAutocomplete("li") + advanceUntilIdle() + // ...and changes nothing at all: no new state was published. + expectNoEvents() + } + // It really did answer — it was discarded on arrival, not simply lost. + assertThat(repo.completedAutocompleteQueries).containsExactly("lego", "li").inOrder() + assertThat(vm.uiState.value.tagSuggestions.map { it.tag }).containsExactly("Lego") + } +}