From 4b95b1094a3f111937b7ee029e59cc996f79923f Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:36:26 -0700 Subject: [PATCH] feat(messages): push and quote another user's message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two ways to re-share a message that the web has and Android lacked, both posting `pushedMessageId` on POST /api/messages: - Push (repost) posts immediately with no content at all — the one case the API allows an absent `content` ("required unless pushing with no comment"). - Quote reuses the normal composer with the original attached and sends both the note and `pushedMessageId`. The feed renders a push and a quote from the nested `pushedMessage` the server already embeds, so the original needs no second fetch: it is modelled on the DTO, the Room entity (JSON-converted, db v4) and the domain type. A push shows a " pushed" header with the original in place of a body; a quote shows the author's note with the original inset beneath it. Tapping the inset opens the original's own page. The always-public rule is consumed from #18's MessageVisibility.PUSH_OR_QUOTE rather than restated: the repository posts it instead of the caller's selection whenever a `pushedMessageId` is present, and the composer both hides the Private chip and shows the "pushes and quotes are always public" banner before sending. Push and Quote are withheld where the docs say a message cannot be re-shared — your own message, a private one, or a re-share of a re-share (a quote is also never scheduled, since the API rejects `scheduledAt` with `pushedMessageId`) — and a server rejection is surfaced with the server's own wording. Tests: a push sends `pushedMessageId` with no content; a quote sends both; a push is public even when private was asked for; the feed caches and renders the embedded original for each; the visibility control cannot select Private for a quote; a rejected push surfaces the server's message. Closes #20 --- .../ui/feed/MessagesFeedScreenTest.kt | 170 ++++++++++ .../data/DefaultMessagesRepository.kt | 37 ++- .../messages/data/MessagesRepository.kt | 20 +- .../messages/data/local/MessageConverters.kt | 31 ++ .../messages/data/local/MessageEntity.kt | 16 + .../messages/data/local/MessagesDatabase.kt | 2 +- .../messages/data/remote/dto/MessageDto.kt | 46 +++ .../data/remote/dto/MessagesResponse.kt | 10 +- .../feature/messages/domain/Message.kt | 64 ++++ .../messages/domain/MessageVisibility.kt | 7 +- .../messages/ui/components/MessageCard.kt | 293 ++++++++++++++---- .../messages/ui/detail/MessageDetailScreen.kt | 3 + .../messages/ui/feed/MessagesFeedScreen.kt | 149 +++++++-- .../messages/ui/feed/MessagesFeedViewModel.kt | 95 +++++- .../data/DefaultMessagesRepositoryTest.kt | 135 ++++++++ .../data/local/MessageConvertersTest.kt | 43 +++ .../data/remote/dto/MessageDtoMapperTest.kt | 84 +++++ .../messages/domain/MessagePushTest.kt | 113 +++++++ .../messages/ui/FakeMessagesRepository.kt | 41 ++- .../ui/feed/MessagesFeedViewModelTest.kt | 228 ++++++++++++++ 20 files changed, 1485 insertions(+), 102 deletions(-) create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConvertersTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/domain/MessagePushTest.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 1071507..9d8bf69 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 @@ -3,7 +3,10 @@ package com.interlinedlist.android.feature.messages.ui.feed import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -16,6 +19,7 @@ import com.interlinedlist.android.core.model.ViewingPreference 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.ui.components.EditMessageSheetTags import com.interlinedlist.android.feature.messages.ui.components.MessageCardTags import com.interlinedlist.android.feature.messages.ui.components.MessageMediaTags @@ -38,11 +42,24 @@ class MessagesFeedScreenTest { mine: Boolean = false, editedAt: String? = null, publiclyVisible: Boolean = true, + pushCount: Int = 0, + pushedMessage: PushedMessage? = null, ) = Message( id = id, content = body, authorId = "u1", authorUsername = "adron", authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = mine, imageUrls = imageUrls, editedAt = editedAt, publiclyVisible = publiclyVisible, + pushCount = pushCount, + pushedMessageId = pushedMessage?.id, + pushedMessage = pushedMessage, + ) + + private fun original( + id: String = "orig", + body: String = "the original post", + ) = PushedMessage( + id = id, content = body, authorUsername = "quinn", authorDisplayName = "Quinn", + authorAvatarUrl = null, createdAt = null, ) /** Hosts the stateless feed with a tiny in-memory state holder. */ @@ -55,6 +72,8 @@ class MessagesFeedScreenTest { onMuteUser: (Message) -> Unit = {}, onReportUser: (Message) -> Unit = {}, onViewingPreferenceChange: ((ViewingPreference) -> Unit)? = null, + onPush: (Message) -> Unit = {}, + onQuote: (Message) -> Unit = {}, ) { composeRule.setContent { var state by mutableStateOf(initial) @@ -88,6 +107,11 @@ class MessagesFeedScreenTest { onBlockUser = onBlockUser, onMuteUser = onMuteUser, onReportUser = onReportUser, + onPush = onPush, + onQuote = { quoted -> + state = state.copy(isComposeOpen = true, quoteTarget = quoted) + onQuote(quoted) + }, ) } } @@ -372,4 +396,150 @@ class MessagesFeedScreenTest { composeRule.onNodeWithTag(MessagesFeedTags.VIEW_PREFERENCES).assertIsDisplayed() composeRule.onNodeWithTag(MessagesFeedTags.LOCKED).assertIsDisplayed() } + + // --- push / quote ------------------------------------------------------ + + @Test + fun push_rendersTheEmbeddedOriginal_insteadOfAnEmptyBody() { + setFeed( + MessagesFeedUiState(messages = listOf(message("p1", "", pushedMessage = original()))), + ) + + composeRule.onNodeWithTag(MessageCardTags.PUSH_HEADER).assertIsDisplayed() + composeRule.onNodeWithText("Adron pushed").assertIsDisplayed() + composeRule.onNodeWithTag(MessageCardTags.PUSHED_ORIGINAL).assertIsDisplayed() + composeRule.onNodeWithText("the original post").assertIsDisplayed() + composeRule.onNodeWithText("Quinn").assertIsDisplayed() + } + + @Test + fun quote_rendersTheOwnNote_andTheEmbeddedOriginal() { + setFeed( + MessagesFeedUiState( + messages = listOf(message("q1", "worth reading", pushedMessage = original())), + ), + ) + + // A quote has words of its own, so it carries no "pushed" header. + composeRule.onNodeWithTag(MessageCardTags.PUSH_HEADER).assertDoesNotExist() + composeRule.onNodeWithText("worth reading").assertIsDisplayed() + composeRule.onNodeWithTag(MessageCardTags.PUSHED_ORIGINAL).assertIsDisplayed() + composeRule.onNodeWithText("the original post").assertIsDisplayed() + } + + @Test + fun tappingTheEmbeddedOriginal_opensTheOriginalMessage() { + var opened: String? = null + setFeed( + MessagesFeedUiState(messages = listOf(message("p1", "", pushedMessage = original()))), + onOpenMessage = { opened = it }, + ) + + composeRule.onNodeWithTag(MessageCardTags.PUSHED_ORIGINAL).performClick() + + assertThat(opened).isEqualTo("orig") + } + + @Test + fun pushAndQuote_areOffered_onSomeoneElsesPublicMessage() { + val pushed = mutableListOf() + setFeed( + MessagesFeedUiState(messages = listOf(message("1", "hello"))), + onPush = { pushed += it }, + ) + + composeRule.onNodeWithTag(MessageCardTags.QUOTE).assertIsDisplayed() + composeRule.onNodeWithTag(MessageCardTags.PUSH).performClick() + + assertThat(pushed.map { it.id }).containsExactly("1") + } + + @Test + fun push_isNotOffered_onYourOwnMessage() { + setFeed(MessagesFeedUiState(messages = listOf(message("1", "hello", mine = true)))) + + composeRule.onNodeWithTag(MessageCardTags.PUSH).assertDoesNotExist() + composeRule.onNodeWithTag(MessageCardTags.QUOTE).assertDoesNotExist() + } + + @Test + fun push_isNotOffered_onAPushOfAPush() { + setFeed( + MessagesFeedUiState(messages = listOf(message("p1", "", pushedMessage = original()))), + ) + + composeRule.onNodeWithTag(MessageCardTags.QUOTE).assertDoesNotExist() + // The count still shows, but never as a control that would fail on tap. + composeRule.onNodeWithTag(MessageCardTags.PUSH).assertDoesNotExist() + } + + @Test + fun pushCount_isShownWithoutTheAction_whenTheMessageCannotBePushed() { + setFeed( + MessagesFeedUiState( + messages = listOf(message("1", "popular", mine = true, pushCount = 3)), + ), + ) + + composeRule.onNodeWithTag(MessageCardTags.PUSH).assertHasNoClickAction() + composeRule.onNodeWithText("3").assertIsDisplayed() + } + + @Test + fun quote_asksToComposeAQuoteOfThatMessage() { + val quoted = mutableListOf() + setFeed( + MessagesFeedUiState(messages = listOf(message("1", "the original post"))), + onQuote = { quoted += it }, + ) + + composeRule.onNodeWithTag(MessageCardTags.QUOTE).performClick() + + assertThat(quoted.map { it.id }).containsExactly("1") + } + + @Test + fun composer_showsTheQuotedMessage_andItsAlwaysPublicBanner() { + setFeed( + MessagesFeedUiState( + isComposeOpen = true, + quoteTarget = message("orig", "the original post"), + ), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.COMPOSE_INPUT).assertIsDisplayed() + composeRule.onNodeWithTag(MessagesFeedTags.QUOTE_ATTACHED).assertIsDisplayed() + composeRule.onNodeWithTag(MessagesFeedTags.QUOTE_PUBLIC_BANNER).assertIsDisplayed() + composeRule.onNodeWithText("Pushes and quotes are always public.").assertIsDisplayed() + } + + @Test + fun composer_doesNotOfferPrivate_forAQuote() { + setFeed( + MessagesFeedUiState( + isComposeOpen = true, + composeText = "worth reading", + quoteTarget = message("orig", "the original post"), + composeVisibility = MessageVisibility.PUBLIC, + ), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PRIVATE).assertDoesNotExist() + // Public is shown, selected and locked. + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PUBLIC).assertIsSelected() + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PUBLIC).assertIsNotEnabled() + } + + @Test + fun composer_stillOffersPrivate_forAnOrdinaryMessage() { + setFeed( + MessagesFeedUiState( + isComposeOpen = true, + composeText = "hi", + composeVisibility = MessageVisibility.PUBLIC, + ), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PRIVATE).assertHasClickAction() + } } 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 d370e16..5f3b49b 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.asPushedOriginal import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -117,11 +118,23 @@ class DefaultMessagesRepository @Inject constructor( scheduledAt: String?, crossPost: CrossPostSelection, visibility: MessageVisibility, + pushedMessageId: String?, ): ApiResult = withContext(dispatchers.io) { + val isReshare = pushedMessageId != null val request = CreateMessageRequest( - content = content, - // Always explicit: the composer owns this choice, not the server default. - publiclyVisible = visibility.publiclyVisible, + // A push with no comment is the one post that carries no content at + // all ("required unless pushing with no comment"), so the field is + // dropped entirely. Everything else sends its content verbatim — a + // media-only post legitimately posts an empty body. + content = content.takeIf { !isReshare || it.isNotBlank() }, + pushedMessageId = pushedMessageId, + // Always explicit: the composer owns this choice, not the server + // default. A push/quote is the exception — it is always public. + publiclyVisible = if (isReshare) { + MessageVisibility.PUSH_OR_QUOTE.publiclyVisible + } else { + visibility.publiclyVisible + }, imageUrls = imageUrls.ifEmpty { null }, videoUrls = videoUrls.ifEmpty { null }, scheduledAt = scheduledAt, @@ -135,6 +148,7 @@ class DefaultMessagesRepository @Inject constructor( when (val result = safeCall { api.createMessage(request) }) { is ApiResult.Success -> { val message = result.data.data.toDomain(currentUserId()) + .withEmbeddedOriginal(pushedMessageId) if (message.scheduledAt != null) { // Scheduled messages are cached in the scheduled view, not the feed. messageDao.upsert(message.toEntity(feedOrder = 0L)) @@ -150,6 +164,9 @@ class DefaultMessagesRepository @Inject constructor( } } + override suspend fun pushMessage(messageId: String): ApiResult = + createMessage(content = "", pushedMessageId = messageId) + override suspend fun getDefaultVisibility(): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { userApi.getCurrentUser().user }) { @@ -458,6 +475,20 @@ class DefaultMessagesRepository @Inject constructor( } } + /** + * Fills in the embedded original for a freshly created push/quote. The create + * response echoes back only the new message, so the nested `pushedMessage` + * the feed payload carries is missing — without this the new row would render + * as an empty card until the next refresh. The cached original supplies it. + */ + private suspend fun Message.withEmbeddedOriginal(pushedMessageId: String?): Message { + if (pushedMessageId == null || pushedMessage != null) return this + return copy( + pushedMessageId = pushedMessageId, + pushedMessage = currentEntity(pushedMessageId)?.toDomain()?.asPushedOriginal(), + ) + } + /** Current cached row for [id], or null. Snapshots the observe Flow. */ private suspend fun currentEntity(id: String) = messageDao.observeMessage(id).first() 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 68a03af..95b1085 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 @@ -81,9 +81,12 @@ interface MessagesRepository { * * [visibility] is always sent explicitly so the server default never silently * decides; callers seed it from [getDefaultVisibility] and let the user - * override it per message. A push/quote post must instead pass - * [MessageVisibility.PUSH_OR_QUOTE] — amplifying someone else's message is - * always public. + * override it per message. + * + * [pushedMessageId] re-shares another message: with [content] this is a + * **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]. */ suspend fun createMessage( content: String, @@ -92,8 +95,19 @@ interface MessagesRepository { scheduledAt: String? = null, crossPost: CrossPostSelection = CrossPostSelection.NONE, visibility: MessageVisibility = MessageVisibility.PUBLIC, + pushedMessageId: String? = null, ): ApiResult + /** + * Pushes (reposts) [messageId] as-is: posts `pushedMessageId` with **no** + * content, always publicly. A quote — the same repost with the user's own + * note — goes through [createMessage] with a `pushedMessageId` instead. + * + * Callers must only offer this for a message whose [Message.canBePushed] is + * true; the server rejects the rest and the failure is surfaced verbatim. + */ + suspend fun pushMessage(messageId: String): ApiResult + /** * The account's default post visibility, read from `defaultPubliclyVisible` * on `GET /api/user`. Seeds the composer's Public/Private toggle. diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConverters.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConverters.kt index c432702..9e61780 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConverters.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConverters.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.messages.data.local import androidx.room.TypeConverter import com.interlinedlist.android.feature.messages.domain.LinkPreview +import com.interlinedlist.android.feature.messages.domain.PushedMessage import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer @@ -35,6 +36,17 @@ class MessageConverters { json.decodeFromString(LinkPreviewSurrogate.serializer(), value).toDomain() }.getOrNull() + @TypeConverter + fun pushedMessageToJson(value: PushedMessage?): String? = + value?.let { json.encodeToString(PushedMessageSurrogate.serializer(), it.toSurrogate()) } + + @TypeConverter + fun jsonToPushedMessage(value: String?): PushedMessage? = + if (value.isNullOrBlank()) null + else runCatching { + json.decodeFromString(PushedMessageSurrogate.serializer(), value).toDomain() + }.getOrNull() + private companion object { val json = Json { ignoreUnknownKeys = true } } @@ -55,3 +67,22 @@ private data class LinkPreviewSurrogate( private fun LinkPreview.toSurrogate() = LinkPreviewSurrogate(url, title, description, imageUrl, siteName) private fun LinkPreviewSurrogate.toDomain() = LinkPreview(url, title, description, imageUrl, siteName) + +/** Serializable mirror of the domain [PushedMessage], for the same reason. */ +@Serializable +private data class PushedMessageSurrogate( + val id: String, + val content: String = "", + val authorUsername: String = "", + val authorDisplayName: String? = null, + val authorAvatarUrl: String? = null, + val createdAt: String? = null, +) + +private fun PushedMessage.toSurrogate() = PushedMessageSurrogate( + id, content, authorUsername, authorDisplayName, authorAvatarUrl, createdAt, +) + +private fun PushedMessageSurrogate.toDomain() = PushedMessage( + id, content, authorUsername, authorDisplayName, authorAvatarUrl, createdAt, +) 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 14987ed..1999f0c 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 @@ -4,6 +4,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import com.interlinedlist.android.feature.messages.domain.LinkPreview import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.PushedMessage /** * Locally cached message row — this module's own offline-first source of truth @@ -38,6 +39,15 @@ data class MessageEntity( val editedAt: String? = null, /** False when the message is private (visible only to its author). */ val publiclyVisible: Boolean = true, + /** How many times this message has been pushed (reposted). */ + val pushCount: Int = 0, + /** Id of the message this one re-shares (push or quote); null otherwise. */ + val pushedMessageId: String? = null, + /** + * The re-shared original, stored as JSON via [MessageConverters] so the feed + * renders a cached push/quote offline, exactly as it came from the server. + */ + val pushedMessage: PushedMessage? = null, ) fun MessageEntity.toDomain(): Message = Message( @@ -59,6 +69,9 @@ fun MessageEntity.toDomain(): Message = Message( scheduledAt = scheduledAt, editedAt = editedAt, publiclyVisible = publiclyVisible, + pushCount = pushCount, + pushedMessageId = pushedMessageId, + pushedMessage = pushedMessage, ) fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( @@ -81,4 +94,7 @@ fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( scheduledAt = scheduledAt, editedAt = editedAt, publiclyVisible = publiclyVisible, + pushCount = pushCount, + pushedMessageId = pushedMessageId, + pushedMessage = pushedMessage, ) 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 bc53912..c1c1c49 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 = 3, + version = 4, exportSchema = false, ) @TypeConverters(MessageConverters::class) 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 33afa16..e5dd12a 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 @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.messages.data.remote.dto import com.interlinedlist.android.feature.messages.domain.LinkPreview import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.PushedMessage import kotlinx.serialization.Serializable /** @@ -38,6 +39,15 @@ data class MessageDto( val scheduledAt: String? = null, /** False when the message is private (visible only to its author). */ val publiclyVisible: Boolean = true, + /** How many times this message has been pushed (reposted). */ + val pushCount: Int = 0, + /** Id of the message this one re-shares (push or quote); null otherwise. */ + val pushedMessageId: String? = null, + /** + * The re-shared original, embedded by the server. Null when this message is + * not a push/quote — so the feed renders the original without a second fetch. + */ + val pushedMessage: PushedMessageDto? = null, ) /** Author identity embedded in a message. */ @@ -49,6 +59,22 @@ data class MessageAuthorDto( val avatar: String? = null, ) +/** + * The original message nested under `pushedMessage` on a push or a quote. + * + * Only the fields the inset "original" card renders are modelled: the server + * sends a full message object here, and `ignoreUnknownKeys` drops the rest. Like + * the outer message, the author arrives as either `user` or `author`. + */ +@Serializable +data class PushedMessageDto( + val id: String = "", + val content: String = "", + val user: MessageAuthorDto? = null, + val author: MessageAuthorDto? = null, + val createdAt: String? = null, +) + /** * Link-preview metadata attached to a message. Populated by the metadata endpoint; * mirrors the OpenGraph-style fields the web feed renders in its preview card. @@ -90,6 +116,26 @@ fun MessageDto.toDomain(currentUserId: String?): Message { linkPreview = linkMetadata?.toDomain(), scheduledAt = scheduledAt, publiclyVisible = publiclyVisible, + pushCount = pushCount, + // The embedded original is authoritative for the id when the flat field + // is absent: either one makes this a push/quote. + pushedMessageId = pushedMessageId?.takeIf { it.isNotBlank() } + ?: pushedMessage?.id?.takeIf { it.isNotBlank() }, + pushedMessage = pushedMessage?.toDomainOrNull(), + ) +} + +/** Maps the embedded original, dropping an entry the server sent without an id. */ +fun PushedMessageDto.toDomainOrNull(): PushedMessage? { + val originalId = id.takeIf { it.isNotBlank() } ?: return null + val person = author ?: user + return PushedMessage( + id = originalId, + content = content, + authorUsername = person?.username.orEmpty(), + authorDisplayName = person?.displayName, + authorAvatarUrl = person?.avatar, + createdAt = createdAt, ) } 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 dc1e81c..dcfc9bc 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 @@ -111,14 +111,20 @@ data class CrossPostStatusDto( * while [crossPostToBluesky] / [crossPostToLinkedIn] / [crossPostToTwitter] are * single boolean flags for the one-account networks. * + * [pushedMessageId] re-shares another message: with no [content] it is a plain + * push (repost), and with content it is a quote. It is mutually exclusive with + * [parentId] and [scheduledAt], and a push/quote is always public. + * * Only non-null fields are serialised (the shared Json uses `explicitNulls = * false`), so a plain InterlinedList-only post sends just - * `{ content, publiclyVisible }`. + * `{ content, publiclyVisible }` and a bare push sends no `content` at all. */ @Serializable data class CreateMessageRequest( - val content: String, + /** Null only for a push with no comment — the one case the API allows it. */ + val content: String? = null, val parentId: String? = null, + val pushedMessageId: String? = null, val publiclyVisible: Boolean? = null, val imageUrls: List? = null, val videoUrls: List? = null, 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 1ef9ef1..8a8d183 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 @@ -45,6 +45,19 @@ data class Message( * field reads as public. */ val publiclyVisible: Boolean = true, + /** How many times this message has been pushed (reposted) by anyone. */ + val pushCount: Int = 0, + /** + * Id of the message this one re-shares, when it is a push or a quote; null + * for an ordinary message. Sent as `pushedMessageId` on create. + */ + val pushedMessageId: String? = null, + /** + * The re-shared original, embedded by the server on the message payload + * (`pushedMessage`). Null when this message re-shares nothing — or, rarely, + * when the server named [pushedMessageId] without embedding the original. + */ + val pushedMessage: PushedMessage? = null, ) { /** Best available display label for the author. */ val authorLabel: String get() = authorDisplayName?.takeIf { it.isNotBlank() } ?: authorUsername @@ -64,8 +77,59 @@ data class Message( * another user's message is never labelled on the viewer's behalf. */ val showsPrivateBadge: Boolean get() = mine && !publiclyVisible + + /** True when this message re-shares another one: a push or a quote. */ + val isReshare: Boolean get() = pushedMessageId != null || pushedMessage != null + + /** A **push** (repost): re-shares the original as-is, with no comment added. */ + val isPush: Boolean get() = isReshare && content.isBlank() + + /** A **quote**: re-shares the original with the author's own note attached. */ + val isQuote: Boolean get() = isReshare && content.isNotBlank() + + /** + * Whether Push and Quote may be offered for this message. Both actions post a + * `pushedMessageId`, so they share one rule, drawn from the docs: + * + * - `/help/messages` describes both as re-sharing **someone else's** message, + * so your own message is not pushable; + * - `/help/api/messages` defines `pushedMessageId` as "repost this **public** + * message ID", so a private message is not pushable; + * - neither page defines a push-of-a-push, so re-shares are not re-shared — + * the original is what deserves the amplification, not a wrapper around it. + */ + val canBePushed: Boolean get() = !mine && publiclyVisible && !isReshare +} + +/** + * The original message embedded inside a push or a quote. The feed payload nests + * it as `pushedMessage`, so the card can render the re-shared post without a + * second fetch. Deliberately narrower than [Message]: only what the inset card + * shows. Tap it to open the original's own page. + */ +data class PushedMessage( + val id: String, + val content: String, + val authorUsername: String, + val authorDisplayName: String?, + val authorAvatarUrl: String?, + /** ISO-8601 creation instant of the original. */ + val createdAt: String?, +) { + /** Best available display label for the original's author. */ + val authorLabel: String get() = authorDisplayName?.takeIf { it.isNotBlank() } ?: authorUsername } +/** Narrows a full [Message] to the compact form embedded in a push or quote. */ +fun Message.asPushedOriginal(): PushedMessage = PushedMessage( + id = id, + content = content, + authorUsername = authorUsername, + authorDisplayName = authorDisplayName, + authorAvatarUrl = authorAvatarUrl, + createdAt = createdAt, +) + /** * Link-preview metadata for the first URL found in a message, fetched via the * metadata endpoint and rendered as a card. All fields are best-effort; a preview diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibility.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibility.kt index f40b68e..f80dcb8 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibility.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibility.kt @@ -28,9 +28,10 @@ enum class MessageVisibility(val publiclyVisible: Boolean) { * * A push/quote amplifies another user's message, so it is always public — * there is no private amplification and the composer's per-message choice - * does not apply. Push/quote composition is not built yet (issue #20); - * when it lands it must pass this rather than the user's selection, and - * this is the one place that invariant is expressed. + * does not apply. This is the one place that invariant is expressed: the + * repository posts it instead of the caller's selection whenever a + * `pushedMessageId` is present, and the composer reads it to lock the + * visibility control and show its "this will be public" banner. */ val PUSH_OR_QUOTE: MessageVisibility = PUBLIC } 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 e7a2d89..d9241c2 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 @@ -1,6 +1,7 @@ package com.interlinedlist.android.feature.messages.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -14,8 +15,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FormatQuote import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material3.DropdownMenu @@ -35,9 +38,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.PushedMessage import com.interlinedlist.android.feature.messages.ui.relativeTime /** Stable test tags for the message card controls. */ @@ -56,12 +61,28 @@ object MessageCardTags { /** The "Private" marker shown on the author's own non-public messages. */ const val PRIVATE = "messagePrivate" + + /** Push (repost) action, with the message's push count beside it. */ + const val PUSH = "messagePush" + /** Quote action: reposts with the user's own note. */ + const val QUOTE = "messageQuote" + /** The " pushed" header shown above a bare push. */ + const val PUSH_HEADER = "messagePushHeader" + /** The embedded original rendered inside a push or a quote. */ + const val PUSHED_ORIGINAL = "messagePushedOriginal" } /** * One message in a feed or reply list: avatar, author + relative time, body, - * attached media / link preview, and the dig / reply engagement row. An overflow - * menu exposes delete for own messages and report for everyone else's. + * attached media / link preview, and the dig / reply / push engagement row. An + * overflow menu exposes delete for own messages and report for everyone else's. + * + * A **push** (repost of someone else's message with no comment) is drawn with a + * "pushed" header and the embedded original in place of a body; a **quote** + * shows the author's own note with the original inset beneath it. + * + * [onPush] / [onQuote] are null where the host screen does not wire the actions; + * they are also withheld for a message [Message.canBePushed] rules out. */ @Composable fun MessageCard( @@ -76,84 +97,215 @@ fun MessageCard( onMuteUser: () -> Unit = {}, onReportUser: () -> Unit = {}, onOpenLink: (String) -> Unit = {}, + onPush: (() -> Unit)? = null, + onQuote: (() -> Unit)? = null, + onOpenPushedMessage: (String) -> Unit = {}, ) { - Row( + Column( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), ) { - Avatar(url = message.authorAvatarUrl, label = message.authorLabel) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = message.authorLabel, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - val time = relativeTime(message.createdAt) - if (time.isNotEmpty()) { + // A bare push carries no words of its own, so say whose push this is. + if (message.isPush) { + PushedHeader(label = message.authorLabel) + Spacer(Modifier.size(6.dp)) + } + Row(Modifier.fillMaxWidth()) { + Avatar(url = message.authorAvatarUrl, label = message.authorLabel) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = " · $time", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = message.authorLabel, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + val time = relativeTime(message.createdAt) + if (time.isNotEmpty()) { + Text( + text = " · $time", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (message.isEdited) { + Text( + text = " · edited", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MessageCardTags.EDITED), + ) + } + if (message.showsPrivateBadge) { + Spacer(Modifier.width(6.dp)) + PrivateBadge() + } + Spacer(Modifier.weight(1f)) + MessageMenu( + isMine = message.mine, + onEdit = onEdit, + onDelete = onDelete, + onReport = onReport, + onBlockUser = onBlockUser, + onMuteUser = onMuteUser, + onReportUser = onReportUser, ) } - if (message.isEdited) { + Spacer(Modifier.size(4.dp)) + // A push has no body of its own; the original below is the post. + if (message.content.isNotBlank()) { Text( - text = " · edited", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.testTag(MessageCardTags.EDITED), + text = message.content, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(MessageCardTags.BODY), + ) + } + message.pushedMessage?.let { original -> + Spacer(Modifier.size(8.dp)) + PushedOriginal( + original = original, + onClick = { onOpenPushedMessage(original.id) }, ) } - if (message.showsPrivateBadge) { - Spacer(Modifier.width(6.dp)) - PrivateBadge() + if (message.hasMedia || message.linkPreview != null) { + Spacer(Modifier.size(8.dp)) + MessageMedia(message = message, onOpenLink = onOpenLink) } - Spacer(Modifier.weight(1f)) - MessageMenu( - isMine = message.mine, - onEdit = onEdit, - onDelete = onDelete, - onReport = onReport, - onBlockUser = onBlockUser, - onMuteUser = onMuteUser, - onReportUser = onReportUser, + Spacer(Modifier.size(8.dp)) + EngagementRow( + message = message, + onDig = onDig, + onReply = onClick, + onPush = onPush, + onQuote = onQuote, ) } - Spacer(Modifier.size(4.dp)) + } + } +} + +/** Dig, reply, and — where the rules allow it — push and quote. */ +@Composable +private fun EngagementRow( + message: Message, + onDig: () -> Unit, + onReply: () -> Unit, + onPush: (() -> Unit)?, + onQuote: (() -> Unit)?, +) { + // Push and Quote are offered on the same terms: both post a pushedMessageId. + val canPush = message.canBePushed && onPush != null + val canQuote = message.canBePushed && onQuote != null + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Engagement( + icon = if (message.dugByMe) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + tint = if (message.dugByMe) MaterialTheme.colorScheme.secondary + else MaterialTheme.colorScheme.onSurfaceVariant, + count = message.digCount, + contentDescription = "Dig", + onClick = onDig, + tag = MessageCardTags.DIG, + ) + Engagement( + icon = Icons.Outlined.ChatBubbleOutline, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + count = message.replyCount, + contentDescription = "Replies", + onClick = onReply, + tag = MessageCardTags.REPLY, + ) + // Where a push cannot be offered, the count is still worth showing — but + // only as a count, never as a control that would fail on tap. + if (canPush || message.pushCount > 0) { + Engagement( + icon = Icons.Filled.Repeat, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + count = message.pushCount, + contentDescription = "Push", + onClick = onPush.takeIf { canPush }, + tag = MessageCardTags.PUSH, + ) + } + if (canQuote) { + Engagement( + icon = Icons.Filled.FormatQuote, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + count = 0, + contentDescription = "Quote", + onClick = onQuote, + tag = MessageCardTags.QUOTE, + ) + } + } +} + +/** The " pushed" line that introduces a bare repost. */ +@Composable +private fun PushedHeader(label: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag(MessageCardTags.PUSH_HEADER), + ) { + Icon( + imageVector = Icons.Filled.Repeat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "$label pushed", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * The re-shared original, inset in an outlined card so it reads as somebody + * else's post rather than part of this one. Comes straight from the feed + * payload's nested `pushedMessage`, so no extra fetch is involved. Tapping it + * opens the original's own page. + */ +@Composable +private fun PushedOriginal(original: PushedMessage, onClick: () -> Unit) { + val shape = MaterialTheme.shapes.medium + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + .clickable(onClick = onClick) + .padding(12.dp) + .testTag(MessageCardTags.PUSHED_ORIGINAL), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Avatar(url = original.authorAvatarUrl, label = original.authorLabel, size = 20.dp) + Spacer(Modifier.width(8.dp)) Text( - text = message.content, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.testTag(MessageCardTags.BODY), + text = original.authorLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, ) - if (message.hasMedia || message.linkPreview != null) { - Spacer(Modifier.size(8.dp)) - MessageMedia(message = message, onOpenLink = onOpenLink) - } - Spacer(Modifier.size(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { - Engagement( - icon = if (message.dugByMe) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, - tint = if (message.dugByMe) MaterialTheme.colorScheme.secondary - else MaterialTheme.colorScheme.onSurfaceVariant, - count = message.digCount, - contentDescription = "Dig", - onClick = onDig, - tag = MessageCardTags.DIG, - ) - Engagement( - icon = Icons.Outlined.ChatBubbleOutline, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - count = message.replyCount, - contentDescription = "Replies", - onClick = onClick, - tag = MessageCardTags.REPLY, + val time = relativeTime(original.createdAt) + if (time.isNotEmpty()) { + Text( + text = " · $time", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + if (original.content.isNotBlank()) { + Spacer(Modifier.size(4.dp)) + Text( + text = original.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -266,19 +418,23 @@ private fun MessageMenu( } } +/** + * One engagement control. A null [onClick] renders the icon and count as plain + * information — used where an action is deliberately not on offer. + */ @Composable private fun Engagement( icon: androidx.compose.ui.graphics.vector.ImageVector, tint: Color, count: Int, contentDescription: String, - onClick: () -> Unit, + onClick: (() -> Unit)?, tag: String, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .clickable(onClick = onClick) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .testTag(tag), ) { Icon(icon, contentDescription = contentDescription, tint = tint, modifier = Modifier.size(18.dp)) @@ -294,13 +450,13 @@ private fun Engagement( } @Composable -private fun Avatar(url: String?, label: String) { +private fun Avatar(url: String?, label: String, size: Dp = 40.dp) { val shape = CircleShape if (url.isNullOrBlank()) { // Fallback initial monogram when the author has no avatar. Box( modifier = Modifier - .size(40.dp) + .size(size) .clip(shape) .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, @@ -308,7 +464,12 @@ private fun Avatar(url: String?, label: String) { Text( text = label.take(1).uppercase(), color = MaterialTheme.colorScheme.onPrimary, - style = MaterialTheme.typography.titleMedium, + // The inset "original" card uses a much smaller avatar. + style = if (size < 32.dp) { + MaterialTheme.typography.labelSmall + } else { + MaterialTheme.typography.titleMedium + }, ) } } else { @@ -316,7 +477,7 @@ private fun Avatar(url: String?, label: String) { model = url, contentDescription = "$label avatar", modifier = Modifier - .size(40.dp) + .size(size) .clip(shape), ) } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt index 3c9b6a0..9ad4177 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt @@ -226,6 +226,8 @@ private fun Content( onMuteUser = { onMuteUser(message) }, onReportUser = { onReportUser(message) }, onOpenLink = { onFetchMetadata(message) }, + // A push/quote here still opens the original it re-shares. + onOpenPushedMessage = onOpenMessage, ) HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.outlineVariant) Text( @@ -247,6 +249,7 @@ private fun Content( onMuteUser = { onMuteUser(reply) }, onReportUser = { onReportUser(reply) }, onOpenLink = { onFetchMetadata(reply) }, + onOpenPushedMessage = onOpenMessage, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } 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 7924864..c945fd2 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 @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.messages.ui.feed import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.lazy.LazyColumn @@ -55,9 +57,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment 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.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -111,6 +115,10 @@ object MessagesFeedTags { const val VISIBILITY_PRIVATE = "messagesComposeVisibilityPrivate" const val VISIBILITY_HINT = "messagesComposeVisibilityHint" + /** 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 viewPreferenceTag(preference: ViewingPreference): String = @@ -141,6 +149,8 @@ fun MessagesRoute( onOpenScheduled = onOpenScheduled, onDig = viewModel::onDig, onDelete = viewModel::onDelete, + onPush = viewModel::onPush, + onQuote = viewModel::openQuote, onReport = viewModel::openReport, onEdit = viewModel::openEdit, onBlockUser = { viewModel.openModeration(it, ModerationAction.BLOCK) }, @@ -191,6 +201,8 @@ fun MessagesFeedScreen( modifier: Modifier = Modifier, onViewingPreferenceChange: (ViewingPreference) -> Unit = {}, onOpenScheduled: () -> Unit = {}, + onPush: (Message) -> Unit = {}, + onQuote: (Message) -> Unit = {}, onReport: (Message) -> Unit = {}, onEdit: (Message) -> Unit = {}, onBlockUser: (Message) -> Unit = {}, @@ -252,6 +264,8 @@ fun MessagesFeedScreen( onOpenMessage = onOpenMessage, onDig = onDig, onDelete = onDelete, + onPush = onPush, + onQuote = onQuote, onReport = onReport, onEdit = onEdit, onBlockUser = onBlockUser, @@ -363,6 +377,8 @@ private fun FeedContent( onOpenMessage: (String) -> Unit, onDig: (Message) -> Unit, onDelete: (Message) -> Unit, + onPush: (Message) -> Unit, + onQuote: (Message) -> Unit, onReport: (Message) -> Unit, onEdit: (Message) -> Unit, onBlockUser: (Message) -> Unit, @@ -385,6 +401,8 @@ private fun FeedContent( onOpenMessage = onOpenMessage, onDig = onDig, onDelete = onDelete, + onPush = onPush, + onQuote = onQuote, onReport = onReport, onEdit = onEdit, onBlockUser = onBlockUser, @@ -403,6 +421,8 @@ private fun FeedList( onOpenMessage: (String) -> Unit, onDig: (Message) -> Unit, onDelete: (Message) -> Unit, + onPush: (Message) -> Unit, + onQuote: (Message) -> Unit, onReport: (Message) -> Unit, onEdit: (Message) -> Unit, onBlockUser: (Message) -> Unit, @@ -438,6 +458,10 @@ private fun FeedList( onMuteUser = { onMuteUser(message) }, onReportUser = { onReportUser(message) }, onOpenLink = { onFetchMetadata(message) }, + onPush = { onPush(message) }, + onQuote = { onQuote(message) }, + // The embedded original opens on its own page. + onOpenPushedMessage = onOpenMessage, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @@ -540,14 +564,20 @@ private fun ComposeSheet( .padding(horizontal = 20.dp, vertical = 12.dp), ) { Text( - text = if (state.isScheduled) "Schedule message" else "New message", + text = when { + state.isQuoting -> "Quote message" + state.isScheduled -> "Schedule message" + else -> "New message" + }, style = MaterialTheme.typography.titleMedium, ) Spacer(Modifier.height(12.dp)) OutlinedTextField( value = state.composeText, onValueChange = onTextChange, - placeholder = { Text("What's on your mind?") }, + placeholder = { + Text(if (state.isQuoting) "Add a comment" else "What's on your mind?") + }, enabled = !state.isPosting, minLines = 3, modifier = Modifier @@ -555,6 +585,13 @@ private fun ComposeSheet( .testTag(MessagesFeedTags.COMPOSE_INPUT), ) + state.quoteTarget?.let { quoted -> + Spacer(Modifier.height(8.dp)) + QuotedMessage(quoted) + Spacer(Modifier.height(8.dp)) + AlwaysPublicBanner() + } + if (state.hasAttachments) { Spacer(Modifier.height(8.dp)) AttachmentRow(state.attachments, onRemoveAttachment) @@ -576,17 +613,22 @@ private fun ComposeSheet( ) { Icon(Icons.Filled.Videocam, contentDescription = "Attach video") } - ScheduleChip( - scheduledAt = state.scheduledAt, - enabled = !state.isPosting, - onSchedule = onScheduleChange, - ) + // Scheduling and pushedMessageId are mutually exclusive on the + // create endpoint, so a quote is not offered a send time. + if (!state.isQuoting) { + ScheduleChip( + scheduledAt = state.scheduledAt, + enabled = !state.isPosting, + onSchedule = onScheduleChange, + ) + } } Spacer(Modifier.height(12.dp)) VisibilityRow( visibility = state.composeVisibility, enabled = !state.isPosting, + canChangeVisibility = state.canChangeVisibility, onVisibilityChange = onVisibilityChange, ) @@ -667,12 +709,17 @@ private fun AttachmentRow( * explicitly so the server default never silently decides. A short hint spells out * what "Private" means, since the consequence (nobody else sees the post) is not * recoverable from the chip alone. + * + * A push/quote is always public ([MessageVisibility.PUSH_OR_QUOTE]), so when + * [canChangeVisibility] is false the Private chip is not offered at all and + * Public is shown locked — the banner above the control explains why. */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun VisibilityRow( visibility: MessageVisibility, enabled: Boolean, + canChangeVisibility: Boolean, onVisibilityChange: (MessageVisibility) -> Unit, ) { Column { @@ -686,23 +733,25 @@ private fun VisibilityRow( FilterChip( selected = visibility == MessageVisibility.PUBLIC, onClick = { onVisibilityChange(MessageVisibility.PUBLIC) }, - enabled = enabled, + enabled = enabled && canChangeVisibility, label = { Text("Public") }, leadingIcon = { Icon(Icons.Filled.Public, contentDescription = null, modifier = Modifier.size(16.dp)) }, modifier = Modifier.testTag(MessagesFeedTags.VISIBILITY_PUBLIC), ) - FilterChip( - selected = visibility == MessageVisibility.PRIVATE, - onClick = { onVisibilityChange(MessageVisibility.PRIVATE) }, - enabled = enabled, - label = { Text("Private") }, - leadingIcon = { - Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.size(16.dp)) - }, - modifier = Modifier.testTag(MessagesFeedTags.VISIBILITY_PRIVATE), - ) + if (canChangeVisibility) { + FilterChip( + selected = visibility == MessageVisibility.PRIVATE, + onClick = { onVisibilityChange(MessageVisibility.PRIVATE) }, + enabled = enabled, + label = { Text("Private") }, + leadingIcon = { + Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.size(16.dp)) + }, + modifier = Modifier.testTag(MessagesFeedTags.VISIBILITY_PRIVATE), + ) + } } if (visibility == MessageVisibility.PRIVATE) { Spacer(Modifier.height(4.dp)) @@ -716,6 +765,70 @@ private fun VisibilityRow( } } +/** + * The message this compose will quote, shown inset so the user can see exactly + * what they are re-sharing before they send it. + */ +@Composable +private fun QuotedMessage(quoted: Message) { + val shape = MaterialTheme.shapes.medium + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + .padding(12.dp) + .testTag(MessagesFeedTags.QUOTE_ATTACHED), + ) { + Text( + text = quoted.authorLabel, + style = MaterialTheme.typography.labelLarge, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = quoted.content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The banner that confirms, before sending, that a quote is public — matching the + * web. It states the rule the app enforces via [MessageVisibility.PUSH_OR_QUOTE], + * so the user is never surprised by where their post ends up. + */ +@Composable +private fun AlwaysPublicBanner() { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .testTag(MessagesFeedTags.QUOTE_PUBLIC_BANNER), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + Icons.Filled.Public, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Pushes and quotes are always public.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + /** * The cross-post destinations row: InterlinedList is always-on (rendered as a * disabled, always-selected chip), followed by a toggle chip per already-linked 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 c78f82a..cd1c8ab 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 @@ -55,9 +55,15 @@ data class MessagesFeedUiState( val scheduledAt: String? = null, /** * Visibility the in-progress compose will post with: the account's - * `defaultPubliclyVisible` preference unless the user overrode it here. + * `defaultPubliclyVisible` preference unless the user overrode it here — or + * [MessageVisibility.PUSH_OR_QUOTE] when a quote is attached. */ val composeVisibility: MessageVisibility = MessageVisibility.PUBLIC, + /** + * The message the open composer will quote, if any. Its presence turns the + * compose into a quote post: it sends `pushedMessageId` alongside the note. + */ + val quoteTarget: Message? = null, /** The caller's already-linked networks, offered as cross-post destinations. */ val linkedNetworks: List = emptyList(), /** Ids of the linked networks currently selected as cross-post targets. */ @@ -77,6 +83,16 @@ data class MessagesFeedUiState( val isModerating: Boolean = false, ) { val isEmpty: Boolean get() = messages.isEmpty() + + /** True while the composer is writing a quote of [quoteTarget]. */ + val isQuoting: Boolean get() = quoteTarget != null + + /** + * False while quoting: a push/quote is always public, so the composer locks + * the visibility control instead of offering Private. + */ + val canChangeVisibility: Boolean get() = !isQuoting + val hasAttachments: Boolean get() = attachments.isNotEmpty() val isUploading: Boolean get() = attachments.any { it.isUploading } val isScheduled: Boolean get() = scheduledAt != null @@ -133,6 +149,13 @@ private data class FeedTransientState( val defaultVisibility: MessageVisibility = MessageVisibility.PUBLIC, /** The user's per-message choice for the open composer; null = use the default. */ val visibilityOverride: MessageVisibility? = null, + /** The message the open composer is quoting, if any. */ + val quoteTarget: Message? = null, + /** + * Ids of pushes currently in flight, so a double tap cannot post the same + * repost twice. Purely a guard: the count itself comes from the server. + */ + val pushesInFlight: Set = emptySet(), val linkedNetworks: List = emptyList(), val selectedNetworkIds: Set = emptySet(), val crossPostStatuses: List = emptyList(), @@ -144,8 +167,17 @@ private data class FeedTransientState( val moderationTarget: ModerationTarget? = null, val isModerating: Boolean = false, ) { - /** A per-message override always wins over the account default. */ - val composeVisibility: MessageVisibility get() = visibilityOverride ?: defaultVisibility + /** + * A quote is always public — [MessageVisibility.PUSH_OR_QUOTE] outranks both + * the per-message override and the account default. Otherwise an override + * wins over the default. + */ + val composeVisibility: MessageVisibility + get() = if (quoteTarget != null) { + MessageVisibility.PUSH_OR_QUOTE + } else { + visibilityOverride ?: defaultVisibility + } /** More pages remain exactly while the server handed back a cursor. */ val canLoadMore: Boolean get() = nextCursor != null @@ -179,6 +211,7 @@ class MessagesFeedViewModel @Inject constructor( attachments = t.attachments, scheduledAt = t.scheduledAt, composeVisibility = t.composeVisibility, + quoteTarget = t.quoteTarget, linkedNetworks = t.linkedNetworks, selectedNetworkIds = t.selectedNetworkIds, crossPostStatuses = t.crossPostStatuses, @@ -356,6 +389,51 @@ class MessagesFeedViewModel @Inject constructor( } } + // --- push / quote ------------------------------------------------------ + + /** + * Pushes (reposts) [message] straight away: no composer, no comment, always + * public. Ignored for a message the rules say cannot be pushed (your own, a + * private one, or a re-share) — the card does not offer the action there + * either — and while an earlier push of the same message is still in flight. + * A server rejection is surfaced verbatim. + */ + fun onPush(message: Message) { + if (!message.canBePushed) return + if (message.id in transient.value.pushesInFlight) return + transient.update { + it.copy(pushesInFlight = it.pushesInFlight + message.id, errorMessage = null) + } + viewModelScope.launch { + val result = repository.pushMessage(message.id) + transient.update { state -> + val cleared = state.copy(pushesInFlight = state.pushesInFlight - message.id) + if (result is ApiResult.Failure) cleared.withError(result.error) else cleared + } + } + } + + /** + * Opens the normal composer with [message] attached as a quote. The post then + * carries both the user's note and `pushedMessageId`, and is always public. + */ + fun openQuote(message: Message) { + if (!message.canBePushed) return + transient.update { + it.copy( + isComposeOpen = true, + quoteTarget = message, + // A quote's visibility is fixed; any earlier override is moot. + visibilityOverride = null, + // The API rejects scheduledAt together with pushedMessageId, so a + // quote is never scheduled — and the composer hides the chip. + scheduledAt = null, + errorMessage = null, + crossPostStatuses = emptyList(), + ) + } + } + // --- compose sheet ----------------------------------------------------- fun openCompose() = transient.update { @@ -370,13 +448,17 @@ class MessagesFeedViewModel @Inject constructor( scheduledAt = null, // Drop the per-message override; the next compose starts from the default. visibilityOverride = null, + quoteTarget = null, selectedNetworkIds = emptySet(), ) } - /** Overrides the account default for this message only. */ + /** + * Overrides the account default for this message only. Ignored while quoting: + * a push/quote is always public, and the composer offers no other choice. + */ fun onVisibilityChange(visibility: MessageVisibility) = transient.update { - it.copy(visibilityOverride = visibility) + if (it.quoteTarget != null) it else it.copy(visibilityOverride = visibility) } fun onComposeTextChange(value: String) = transient.update { it.copy(composeText = value) } @@ -462,6 +544,8 @@ class MessagesFeedViewModel @Inject constructor( scheduledAt = snapshot.scheduledAt, crossPost = crossPost, visibility = snapshot.composeVisibility, + // Present only for a quote; the repository forces it public. + pushedMessageId = snapshot.quoteTarget?.id, ) ) { is ApiResult.Success -> transient.update { @@ -472,6 +556,7 @@ class MessagesFeedViewModel @Inject constructor( attachments = emptyList(), scheduledAt = null, visibilityOverride = null, + quoteTarget = null, selectedNetworkIds = emptySet(), crossPostStatuses = result.data.crossPosts, ) 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 4f470dd..250191c 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 @@ -765,4 +765,139 @@ class DefaultMessagesRepositoryTest { assertThat(private.showsPrivateBadge).isTrue() assertThat(cached.first { it.id == "2" }.showsPrivateBadge).isFalse() } + + // --- push / quote (pushedMessageId) ------------------------------------ + + @Test + fun `pushMessage sends pushedMessageId and no content at all`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "push1", "content": "", "pushedMessageId": "orig" } }""", + ) + val repo = repository() + + val result = repo.pushMessage("orig") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"pushedMessageId\":\"orig\"") + // "Required unless pushing with no comment" - so the field is omitted. + assertThat(body).doesNotContain("content") + } + + @Test + fun `a quote sends both the note and pushedMessageId`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "quote1", "content": "worth reading", + "pushedMessageId": "orig" } }""", + ) + val repo = repository() + + val result = repo.createMessage(content = "worth reading", pushedMessageId = "orig") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"content\":\"worth reading\"") + assertThat(body).contains("\"pushedMessageId\":\"orig\"") + } + + @Test + fun `a push is public even when the caller asks for private`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "push2", "content": "", "publiclyVisible": true } }""", + ) + val repo = repository() + + repo.createMessage( + content = "", + visibility = MessageVisibility.PRIVATE, + pushedMessageId = "orig", + ) + + // MessageVisibility.PUSH_OR_QUOTE overrides the caller's selection. + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"publiclyVisible\":true") + } + + @Test + fun `refreshFeed caches a push with the embedded original from the payload`() = + runTest(dispatcher) { + enqueueJson( + 200, + """ + { + "messages": [ + { "id": "p1", "content": "", "pushCount": 2, "pushedMessageId": "orig", + "user": { "id": "u2", "username": "pusher" }, + "pushedMessage": { "id": "orig", "content": "the original post", + "user": { "id": "u9", "username": "quinn", "displayName": "Quinn" } } }, + { "id": "q1", "content": "worth reading", "pushedMessageId": "orig", + "pushedMessage": { "id": "orig", "content": "the original post", + "user": { "id": "u9", "username": "quinn" } } } + ], + "pagination": { "hasMore": false } + } + """.trimIndent(), + ) + val repo = repository() + + repo.refreshFeed() + + val cached = repo.observeFeed().first() + val push = cached.first { it.id == "p1" } + assertThat(push.isPush).isTrue() + assertThat(push.pushCount).isEqualTo(2) + assertThat(push.pushedMessage?.content).isEqualTo("the original post") + assertThat(push.pushedMessage?.authorLabel).isEqualTo("Quinn") + val quote = cached.first { it.id == "q1" } + assertThat(quote.isQuote).isTrue() + assertThat(quote.pushedMessage?.content).isEqualTo("the original post") + } + + @Test + fun `a new push renders the original even though the create response omits it`() = + runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "orig", "content": "the original post", + "author": { "id": "u9", "username": "quinn", "displayName": "Quinn" } } ], + "pagination": { "hasMore": false } }""", + ) + // The create endpoint echoes back only the new message. + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "push3", "content": "" } }""", + ) + val repo = repository() + repo.refreshFeed() + + val result = repo.pushMessage("orig") + + val created = (result as ApiResult.Success).data.message + assertThat(created.isPush).isTrue() + assertThat(created.pushedMessageId).isEqualTo("orig") + assertThat(created.pushedMessage?.content).isEqualTo("the original post") + // And the feed row it cached carries the original too. + val cached = repo.observeMessage("push3").first() + assertThat(cached?.pushedMessage?.authorLabel).isEqualTo("Quinn") + } + + @Test + fun `a rejected push carries the server's own message`() = runTest(dispatcher) { + enqueueJson(403, """{ "error": "You cannot push your own message", "code": "forbidden" }""") + val repo = repository() + + val result = repo.pushMessage("mine") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + val error = (result as ApiResult.Failure).error + assertThat(error).isInstanceOf(AppError.Forbidden::class.java) + assertThat(error.message).isEqualTo("You cannot push your own message") + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConvertersTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConvertersTest.kt new file mode 100644 index 0000000..02cc0f5 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConvertersTest.kt @@ -0,0 +1,43 @@ +package com.interlinedlist.android.feature.messages.data.local + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.messages.domain.PushedMessage +import org.junit.Test + +/** + * The composite columns are stored as JSON, so the embedded original of a cached + * push/quote has to survive the round trip — otherwise an offline feed would + * render a push as an empty card. + */ +class MessageConvertersTest { + + private val converters = MessageConverters() + + @Test + fun `round-trips the embedded original`() { + val original = PushedMessage( + id = "orig", + content = "the original post", + authorUsername = "quinn", + authorDisplayName = "Quinn", + authorAvatarUrl = "https://cdn/q.png", + createdAt = "2026-07-18T09:00:00Z", + ) + + val restored = converters.jsonToPushedMessage(converters.pushedMessageToJson(original)) + + assertThat(restored).isEqualTo(original) + } + + @Test + fun `stores no embedded original for an ordinary message`() { + assertThat(converters.pushedMessageToJson(null)).isNull() + assertThat(converters.jsonToPushedMessage(null)).isNull() + assertThat(converters.jsonToPushedMessage("")).isNull() + } + + @Test + fun `an unreadable stored value degrades to no original rather than crashing`() { + assertThat(converters.jsonToPushedMessage("not json")).isNull() + } +} 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 f6c763f..2ab37a7 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 @@ -180,4 +180,88 @@ class MessageDtoMapperTest { assertThat(message.publiclyVisible).isTrue() } + + // --- push / quote ------------------------------------------------------ + + @Test + fun `maps a push with its embedded original`() { + val message = MessageDto( + id = "p1", + content = "", + user = MessageAuthorDto(id = "u2", username = "pusher"), + pushCount = 0, + pushedMessageId = "orig", + pushedMessage = PushedMessageDto( + id = "orig", + content = "the original post", + user = MessageAuthorDto(id = "u9", username = "quinn", displayName = "Quinn"), + createdAt = "2026-07-18T09:00:00Z", + ), + ).toDomain(currentUserId = null) + + assertThat(message.isPush).isTrue() + assertThat(message.pushedMessageId).isEqualTo("orig") + assertThat(message.pushedMessage?.content).isEqualTo("the original post") + assertThat(message.pushedMessage?.authorLabel).isEqualTo("Quinn") + assertThat(message.pushedMessage?.createdAt).isEqualTo("2026-07-18T09:00:00Z") + } + + @Test + fun `maps a quote with both the note and the embedded original`() { + val message = MessageDto( + id = "q1", + content = "worth reading", + pushedMessageId = "orig", + pushedMessage = PushedMessageDto( + id = "orig", + content = "the original post", + author = MessageAuthorDto(id = "u9", username = "quinn"), + ), + ).toDomain(currentUserId = null) + + assertThat(message.isQuote).isTrue() + assertThat(message.content).isEqualTo("worth reading") + // The embedded original keys its author as `author` here, `user` above. + assertThat(message.pushedMessage?.authorUsername).isEqualTo("quinn") + } + + @Test + fun `maps the push count`() { + val message = MessageDto(id = "m13", content = "popular", pushCount = 7) + .toDomain(currentUserId = null) + + assertThat(message.pushCount).isEqualTo(7) + } + + @Test + fun `an ordinary message carries no pushed original`() { + val message = MessageDto(id = "m14", content = "plain").toDomain(currentUserId = null) + + assertThat(message.isReshare).isFalse() + assertThat(message.pushedMessage).isNull() + assertThat(message.pushedMessageId).isNull() + } + + @Test + fun `takes the pushed id from the embedded original when the flat field is missing`() { + val message = MessageDto( + id = "p2", + content = "", + pushedMessage = PushedMessageDto(id = "orig", content = "x"), + ).toDomain(currentUserId = null) + + assertThat(message.pushedMessageId).isEqualTo("orig") + } + + @Test + fun `drops an embedded original the server sent without an id`() { + val message = MessageDto( + id = "p3", + content = "hm", + pushedMessage = PushedMessageDto(content = "no id"), + ).toDomain(currentUserId = null) + + assertThat(message.pushedMessage).isNull() + assertThat(message.isReshare).isFalse() + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/domain/MessagePushTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/domain/MessagePushTest.kt new file mode 100644 index 0000000..02fb5e4 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/domain/MessagePushTest.kt @@ -0,0 +1,113 @@ +package com.interlinedlist.android.feature.messages.domain + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.messages.ui.sampleMessage +import com.interlinedlist.android.feature.messages.ui.samplePushedMessage +import org.junit.Test + +/** + * The push (repost) / quote rules: how a re-share is recognised in the feed, and + * which messages may be re-shared at all. Both actions post a `pushedMessageId`, + * so one predicate governs them. + */ +class MessagePushTest { + + @Test + fun `a push is a re-share with no comment of its own`() { + val push = sampleMessage( + content = "", + pushedMessageId = "orig", + pushedMessage = samplePushedMessage(), + ) + + assertThat(push.isReshare).isTrue() + assertThat(push.isPush).isTrue() + assertThat(push.isQuote).isFalse() + // The embedded original arrives with the feed payload — no second fetch. + assertThat(push.pushedMessage?.content).isEqualTo("the original post") + } + + @Test + fun `a quote is a re-share the author added a note to`() { + val quote = sampleMessage( + content = "worth reading", + pushedMessageId = "orig", + pushedMessage = samplePushedMessage(), + ) + + assertThat(quote.isReshare).isTrue() + assertThat(quote.isQuote).isTrue() + assertThat(quote.isPush).isFalse() + } + + @Test + fun `an ordinary message is neither a push nor a quote`() { + val message = sampleMessage(content = "just a post") + + assertThat(message.isReshare).isFalse() + assertThat(message.isPush).isFalse() + assertThat(message.isQuote).isFalse() + assertThat(message.pushCount).isEqualTo(0) + } + + @Test + fun `a re-share is still recognised when the server omits the embedded original`() { + val push = sampleMessage(content = "", pushedMessageId = "orig", pushedMessage = null) + + assertThat(push.isReshare).isTrue() + assertThat(push.isPush).isTrue() + } + + @Test + fun `someone else's public message can be pushed`() { + assertThat(sampleMessage(mine = false, publiclyVisible = true).canBePushed).isTrue() + } + + @Test + fun `your own message cannot be pushed`() { + // /help/messages: push and quote re-share "someone else's" message. + assertThat(sampleMessage(mine = true, publiclyVisible = true).canBePushed).isFalse() + } + + @Test + fun `a private message cannot be pushed`() { + // /help/api/messages: pushedMessageId reposts "this public message ID". + assertThat(sampleMessage(mine = false, publiclyVisible = false).canBePushed).isFalse() + } + + @Test + fun `a push cannot itself be pushed`() { + val push = sampleMessage( + mine = false, + content = "", + pushedMessageId = "orig", + pushedMessage = samplePushedMessage(), + ) + + assertThat(push.canBePushed).isFalse() + } + + @Test + fun `a quote cannot be pushed either`() { + val quote = sampleMessage( + mine = false, + content = "worth reading", + pushedMessageId = "orig", + pushedMessage = samplePushedMessage(), + ) + + assertThat(quote.canBePushed).isFalse() + } + + @Test + fun `narrowing a message to an embedded original keeps what the inset card shows`() { + val original = sampleMessage(id = "orig", content = "hello", authorUsername = "quinn") + + val embedded = original.asPushedOriginal() + + assertThat(embedded.id).isEqualTo("orig") + assertThat(embedded.content).isEqualTo("hello") + assertThat(embedded.authorUsername).isEqualTo("quinn") + assertThat(embedded.authorLabel).isEqualTo("Adron") + } +} 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 bdefebe..5a37f8f 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 @@ -10,6 +10,7 @@ import com.interlinedlist.android.feature.messages.domain.CrossPostStatus 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.ReportReason import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,6 +33,8 @@ class FakeMessagesRepository : MessagesRepository { /** The cursor a page-append hands back; null means "end of the feed". */ var loadMoreResult: ApiResult = ApiResult.Success(null) var createResult: ApiResult? = null + /** What [pushMessage] answers with; falls back to [createResult]'s message. */ + var pushResult: ApiResult? = null /** Cross-post statuses returned alongside a successful [createResult]. */ var createCrossPosts: List = emptyList() var linkedNetworksResult: ApiResult> = ApiResult.Success(emptyList()) @@ -71,6 +74,8 @@ class FakeMessagesRepository : MessagesRepository { var lastSetDug: Pair? = null var deletedIds = mutableListOf() var lastCreate: CreateArgs? = null + /** Every message id handed to [pushMessage], in order. */ + val pushedMessageIds = mutableListOf() var uploadedImages = 0 var uploadedVideos = 0 var refreshScheduledCount = 0 @@ -90,6 +95,7 @@ class FakeMessagesRepository : MessagesRepository { val scheduledAt: String?, val crossPost: CrossPostSelection = CrossPostSelection.NONE, val visibility: MessageVisibility = MessageVisibility.PUBLIC, + val pushedMessageId: String? = null, ) /** Snapshot of the arguments passed to the last [report] call. */ @@ -144,8 +150,11 @@ class FakeMessagesRepository : MessagesRepository { scheduledAt: String?, crossPost: CrossPostSelection, visibility: MessageVisibility, + pushedMessageId: String?, ): ApiResult { - lastCreate = CreateArgs(content, imageUrls, videoUrls, scheduledAt, crossPost, visibility) + lastCreate = CreateArgs( + content, imageUrls, videoUrls, scheduledAt, crossPost, visibility, pushedMessageId, + ) return when (val result = createResult) { is ApiResult.Success -> ApiResult.Success(CreatedMessage(result.data, createCrossPosts)) is ApiResult.Failure -> result @@ -153,6 +162,15 @@ class FakeMessagesRepository : MessagesRepository { } } + override suspend fun pushMessage(messageId: String): ApiResult { + pushedMessageIds += messageId + return when (val result = pushResult ?: createResult) { + is ApiResult.Success -> ApiResult.Success(CreatedMessage(result.data, emptyList())) + is ApiResult.Failure -> result + null -> ApiResult.Failure(AppError.Unknown("pushResult not set")) + } + } + override suspend fun getLinkedNetworks(): ApiResult> = linkedNetworksResult override suspend fun getDefaultVisibility(): ApiResult = defaultVisibilityResult @@ -269,6 +287,9 @@ fun sampleMessage( authorUsername: String = "adron", editedAt: String? = null, publiclyVisible: Boolean = true, + pushCount: Int = 0, + pushedMessageId: String? = null, + pushedMessage: PushedMessage? = null, ) = Message( id = id, content = content, @@ -287,4 +308,22 @@ fun sampleMessage( scheduledAt = scheduledAt, editedAt = editedAt, publiclyVisible = publiclyVisible, + pushCount = pushCount, + pushedMessageId = pushedMessageId, + pushedMessage = pushedMessage, +) + +/** Builds a sample embedded original (the `pushedMessage` on a push/quote). */ +fun samplePushedMessage( + id: String = "orig", + content: String = "the original post", + authorUsername: String = "quinn", + authorDisplayName: String? = "Quinn", +) = PushedMessage( + id = id, + content = content, + authorUsername = authorUsername, + authorDisplayName = authorDisplayName, + authorAvatarUrl = null, + createdAt = null, ) diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt index 6fd04c6..62519bc 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt @@ -11,6 +11,7 @@ import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository import com.interlinedlist.android.feature.messages.ui.sampleMessage import com.interlinedlist.android.feature.messages.ui.sampleNetwork +import com.interlinedlist.android.feature.messages.ui.samplePushedMessage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -876,4 +877,231 @@ class MessagesFeedViewModelTest { assertThat(repo.refreshPreferences) .containsExactly(ViewingPreference.FOLLOWERS, ViewingPreference.FOLLOWERS) } + + // --- push / quote ------------------------------------------------------ + + @Test + fun `push posts the repost straight away, with no composer and no content`() = + runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + pushResult = ApiResult.Success(sampleMessage(id = "push1", content = "")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onPush(sampleMessage(id = "orig", mine = false)) + advanceUntilIdle() + + assertThat(repo.pushedMessageIds).containsExactly("orig") + // A push never opens the composer, and posts nothing of its own. + assertThat(vm.uiState.value.isComposeOpen).isFalse() + assertThat(repo.lastCreate).isNull() + assertThat(vm.uiState.value.errorMessage).isNull() + } + + @Test + fun `push is not sent for a message that cannot be pushed`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onPush(sampleMessage(id = "mine", mine = true)) + vm.onPush(sampleMessage(id = "secret", publiclyVisible = false)) + vm.onPush( + sampleMessage( + id = "already", + pushedMessageId = "orig", + pushedMessage = samplePushedMessage(), + ), + ) + advanceUntilIdle() + + assertThat(repo.pushedMessageIds).isEmpty() + } + + @Test + fun `a repeated tap does not push the same message twice`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + pushResult = ApiResult.Success(sampleMessage(id = "push1", content = "")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + val message = sampleMessage(id = "orig") + vm.onPush(message) + vm.onPush(message) + advanceUntilIdle() + + assertThat(repo.pushedMessageIds).containsExactly("orig") + } + + @Test + fun `a rejected push surfaces the server's own message`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + pushResult = ApiResult.Failure(AppError.Forbidden("You cannot push your own message")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onPush(sampleMessage(id = "orig")) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("You cannot push your own message") + } + + @Test + fun `a push that was rejected can be retried`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + pushResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + val message = sampleMessage(id = "orig") + vm.onPush(message) + advanceUntilIdle() + vm.onPush(message) + advanceUntilIdle() + + assertThat(repo.pushedMessageIds).containsExactly("orig", "orig") + } + + @Test + fun `quote opens the composer with the quoted message attached`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openQuote(sampleMessage(id = "orig", content = "the original post")) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isComposeOpen).isTrue() + assertThat(state.isQuoting).isTrue() + assertThat(state.quoteTarget?.id).isEqualTo("orig") + } + + @Test + fun `quote is not offered for a message that cannot be pushed`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openQuote(sampleMessage(id = "mine", mine = true)) + advanceUntilIdle() + + assertThat(vm.uiState.value.isComposeOpen).isFalse() + assertThat(vm.uiState.value.quoteTarget).isNull() + } + + @Test + fun `quote sends both the note and the pushed message id`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + createResult = ApiResult.Success(sampleMessage(id = "quote1")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openQuote(sampleMessage(id = "orig")) + vm.onComposeTextChange("worth reading") + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.content).isEqualTo("worth reading") + assertThat(repo.lastCreate?.pushedMessageId).isEqualTo("orig") + } + + @Test + fun `the visibility control cannot select private for a quote`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + // Even an account that defaults to private posts a quote publicly. + defaultVisibilityResult = ApiResult.Success(MessageVisibility.PRIVATE) + createResult = ApiResult.Success(sampleMessage(id = "quote1")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openQuote(sampleMessage(id = "orig")) + vm.onVisibilityChange(MessageVisibility.PRIVATE) + advanceUntilIdle() + + assertThat(vm.uiState.value.composeVisibility).isEqualTo(MessageVisibility.PUSH_OR_QUOTE) + assertThat(vm.uiState.value.canChangeVisibility).isFalse() + + vm.onComposeTextChange("worth reading") + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.visibility).isEqualTo(MessageVisibility.PUBLIC) + } + + @Test + fun `a quote is never scheduled`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + createResult = ApiResult.Success(sampleMessage(id = "quote1")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onScheduleChange("2026-09-20T09:00:00Z") + vm.openQuote(sampleMessage(id = "orig")) + vm.onComposeTextChange("worth reading") + vm.post() + advanceUntilIdle() + + // The create endpoint rejects scheduledAt together with pushedMessageId. + assertThat(repo.lastCreate?.scheduledAt).isNull() + assertThat(repo.lastCreate?.pushedMessageId).isEqualTo("orig") + } + + @Test + fun `dismissing the composer drops the attached quote`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openQuote(sampleMessage(id = "orig")) + vm.dismissCompose() + advanceUntilIdle() + + assertThat(vm.uiState.value.quoteTarget).isNull() + assertThat(vm.uiState.value.canChangeVisibility).isTrue() + } + + @Test + fun `a posted quote leaves the composer ready for an ordinary message`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + createResult = ApiResult.Success(sampleMessage(id = "quote1")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openQuote(sampleMessage(id = "orig")) + vm.onComposeTextChange("worth reading") + vm.post() + advanceUntilIdle() + + assertThat(vm.uiState.value.quoteTarget).isNull() + assertThat(vm.uiState.value.isQuoting).isFalse() + + vm.openCompose() + vm.onComposeTextChange("plain post") + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.pushedMessageId).isNull() + } }