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 @@ -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
Expand All @@ -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
Expand All @@ -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. */
Expand All @@ -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)
Expand Down Expand Up @@ -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)
},
)
}
}
Expand Down Expand Up @@ -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<Message>()
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<Message>()
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()
}
}
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.asPushedOriginal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
Expand Down Expand Up @@ -117,11 +118,23 @@ class DefaultMessagesRepository @Inject constructor(
scheduledAt: String?,
crossPost: CrossPostSelection,
visibility: MessageVisibility,
pushedMessageId: String?,
): ApiResult<CreatedMessage> = 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,
Expand All @@ -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))
Expand All @@ -150,6 +164,9 @@ class DefaultMessagesRepository @Inject constructor(
}
}

override suspend fun pushMessage(messageId: String): ApiResult<CreatedMessage> =
createMessage(content = "", pushedMessageId = messageId)

override suspend fun getDefaultVisibility(): ApiResult<MessageVisibility> =
withContext(dispatchers.io) {
when (val result = safeCall { userApi.getCurrentUser().user }) {
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -92,8 +95,19 @@ interface MessagesRepository {
scheduledAt: String? = null,
crossPost: CrossPostSelection = CrossPostSelection.NONE,
visibility: MessageVisibility = MessageVisibility.PUBLIC,
pushedMessageId: String? = null,
): ApiResult<CreatedMessage>

/**
* 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<CreatedMessage>

/**
* The account's default post visibility, read from `defaultPubliclyVisible`
* on `GET /api/user`. Seeds the composer's Public/Private toggle.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
}
Expand All @@ -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,
)
Loading
Loading