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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -44,6 +46,7 @@ class MessagesFeedScreenTest {
publiclyVisible: Boolean = true,
pushCount: Int = 0,
pushedMessage: PushedMessage? = null,
tags: List<String> = emptyList(),
) = Message(
id = id, content = body, authorId = "u1", authorUsername = "adron",
authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null,
Expand All @@ -52,6 +55,7 @@ class MessagesFeedScreenTest {
pushCount = pushCount,
pushedMessageId = pushedMessage?.id,
pushedMessage = pushedMessage,
tags = tags,
)

private fun original(
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,6 +120,7 @@ class DefaultMessagesRepository @Inject constructor(
crossPost: CrossPostSelection,
visibility: MessageVisibility,
pushedMessageId: String?,
tags: List<String>,
): ApiResult<CreatedMessage> = withContext(dispatchers.io) {
val isReshare = pushedMessageId != null
val request = CreateMessageRequest(
Expand All @@ -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 },
Expand Down Expand Up @@ -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<List<TagSuggestion>> = 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<List<Message>> = withContext(dispatchers.io) {
when (val result = safeCall {
api.search(query = query, limit = PaginationDto.DEFAULT_LIMIT, offset = 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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,
Expand All @@ -96,8 +100,20 @@ interface MessagesRepository {
crossPost: CrossPostSelection = CrossPostSelection.NONE,
visibility: MessageVisibility = MessageVisibility.PUBLIC,
pushedMessageId: String? = null,
tags: List<String> = emptyList(),
): ApiResult<CreatedMessage>

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

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

companion object {
/** How many tag suggestions to ask for (server default 10, max 50). */
const val TAG_SUGGESTION_LIMIT = 10
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ data class MessageEntity(
val videoUrls: List<String> = 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<String> = 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. */
Expand Down Expand Up @@ -66,6 +68,7 @@ fun MessageEntity.toDomain(): Message = Message(
imageUrls = imageUrls,
videoUrls = videoUrls,
linkPreview = linkPreview,
tags = tags,
scheduledAt = scheduledAt,
editedAt = editedAt,
publiclyVisible = publiclyVisible,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import androidx.room.TypeConverters
*/
@Database(
entities = [MessageEntity::class],
version = 4,
version = 5,
exportSchema = false,
)
@TypeConverters(MessageConverters::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ data class MessageDto(
val videoUrls: List<String> = 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<String> = 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). */
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -129,6 +133,7 @@ data class CreateMessageRequest(
val imageUrls: List<String>? = null,
val videoUrls: List<String>? = null,
val scheduledAt: String? = null,
val tags: List<String>? = null,
val mastodonProviderIds: List<String>? = null,
val crossPostToBluesky: Boolean? = null,
val crossPostToLinkedIn: Boolean? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TagSuggestionDto> = emptyList(),
) {
/** The suggestions as domain values, dropping any entry with no tag. */
fun toDomain(): List<TagSuggestion> = 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ data class Message(
val videoUrls: List<String> = 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<String> = 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.
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading