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") + } +}