From c8e346aa096b564b2b6739c556bdc8527ca03eb1 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 12:45:44 -0700 Subject: [PATCH] feat(messages): public/private visibility toggle in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android never sent `publiclyVisible` on `POST /api/messages`, so the server default silently decided whether a post was public or private and the user had no control and no indication of what they had posted. - Add `MessageVisibility` (PUBLIC/PRIVATE) as the domain vocabulary for the wire boolean, including the documented `PUSH_OR_QUOTE` invariant: a push or quote amplifies someone else's message and is therefore always public. The push/quote UI itself is out of scope (#20) — this is the single place that rule lives, for that work to consume. - Send `publiclyVisible` explicitly on every create, plumbed through `CreateMessageRequest` -> `MessagesRepository.createMessage` -> the composer. - Seed the composer from the account preference `defaultPubliclyVisible` on `GET /api/user`, read via the existing shared current-user endpoint; the field is added to the shared `UserDto`/`User` rather than a new fetch path. Reading it is best-effort: a failure leaves the composer on public. - Let the user override the default per message (Public / Private chips plus a hint spelling out what private means). The override is dropped when the sheet is dismissed and after a successful post. - Model `publiclyVisible` on the message DTO, entity (Room v2 -> v3, destructive fallback already configured) and domain model, and render an unmistakable lock + "Private" badge on the author's own private cards. Public messages are never badged, and another user's message is never labelled. Tests: the create request carries the flag both ways; the default comes from the account preference; a per-message override wins and is reset on dismiss/post; the DTO/entity round-trips the flag; `showsPrivateBadge` is true only for the author's own private message. Compose assertions for the card badge and the composer chips are added to the (emulator-only) androidTest suite. Verified with `./gradlew :app:assembleDebug testDebugUnitTest`: BUILD SUCCESSFUL, 705 unit tests, 0 failures. Closes #18 --- .../interlinedlist/android/core/model/User.kt | 6 + .../android/core/network/dto/UserDto.kt | 3 + .../ui/feed/MessagesFeedScreenTest.kt | 55 ++++++++- .../data/DefaultMessagesRepository.kt | 17 +++ .../messages/data/MessagesRepository.kt | 14 +++ .../messages/data/local/MessageEntity.kt | 4 + .../messages/data/local/MessagesDatabase.kt | 2 +- .../messages/data/remote/dto/MessageDto.kt | 3 + .../data/remote/dto/MessagesResponse.kt | 10 +- .../feature/messages/domain/Message.kt | 16 +++ .../messages/domain/MessageVisibility.kt | 37 ++++++ .../messages/ui/components/MessageCard.kt | 38 ++++++ .../messages/ui/feed/MessagesFeedScreen.kt | 74 ++++++++++++ .../messages/ui/feed/MessagesFeedViewModel.kt | 40 ++++++- .../data/DefaultMessagesRepositoryTest.kt | 98 +++++++++++++++- .../data/remote/dto/MessageDtoMapperTest.kt | 15 +++ .../messages/domain/MessageVisibilityTest.kt | 55 +++++++++ .../messages/ui/FakeMessagesRepository.kt | 11 +- .../ui/feed/MessagesFeedViewModelTest.kt | 110 ++++++++++++++++++ 19 files changed, 601 insertions(+), 7 deletions(-) create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibility.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibilityTest.kt diff --git a/core/model/src/main/kotlin/com/interlinedlist/android/core/model/User.kt b/core/model/src/main/kotlin/com/interlinedlist/android/core/model/User.kt index 126203c..a72d9fc 100644 --- a/core/model/src/main/kotlin/com/interlinedlist/android/core/model/User.kt +++ b/core/model/src/main/kotlin/com/interlinedlist/android/core/model/User.kt @@ -15,4 +15,10 @@ data class User( val bio: String?, val emailVerified: Boolean, val customerStatus: CustomerStatus, + /** + * The account's default post visibility (Settings -> Message Settings on the + * web). Seeds the composer's Public/Private toggle; a per-message choice + * overrides it. Defaults to public, matching the server default. + */ + val defaultPubliclyVisible: Boolean = true, ) diff --git a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UserDto.kt b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UserDto.kt index d08467f..f73009e 100644 --- a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UserDto.kt +++ b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UserDto.kt @@ -15,6 +15,8 @@ data class UserDto( val bio: String? = null, val emailVerified: Boolean = false, val customerStatus: String? = null, + /** The account's default post visibility preference; public when absent. */ + val defaultPubliclyVisible: Boolean = true, ) /** Maps the wire model into the domain [User]. */ @@ -27,4 +29,5 @@ fun UserDto.toDomain(): User = User( bio = bio, emailVerified = emailVerified, customerStatus = CustomerStatus.fromApiValue(customerStatus), + defaultPubliclyVisible = defaultPubliclyVisible, ) 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 f61c7cf..d416bfd 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 @@ -13,6 +13,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme 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.ui.components.EditMessageSheetTags import com.interlinedlist.android.feature.messages.ui.components.MessageCardTags import com.interlinedlist.android.feature.messages.ui.components.MessageMediaTags @@ -34,11 +35,12 @@ class MessagesFeedScreenTest { imageUrls: List = emptyList(), mine: Boolean = false, editedAt: String? = null, + publiclyVisible: Boolean = true, ) = 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, + imageUrls = imageUrls, editedAt = editedAt, publiclyVisible = publiclyVisible, ) /** Hosts the stateless feed with a tiny in-memory state holder. */ @@ -73,6 +75,7 @@ class MessagesFeedScreenTest { } state = state.copy(selectedNetworkIds = selected) }, + onVisibilityChange = { state = state.copy(composeVisibility = it) }, onReport = onReport, onEdit = onEdit, onBlockUser = onBlockUser, @@ -248,4 +251,54 @@ class MessagesFeedScreenTest { ) composeRule.onNodeWithTag(MessagesFeedTags.DESTINATIONS_HINT).assertIsDisplayed() } + + @Test + fun privateMarker_isShown_onOwnPrivateMessage() { + setFeed( + MessagesFeedUiState( + messages = listOf(message("1", "only me", mine = true, publiclyVisible = false)), + ), + ) + composeRule.onNodeWithTag(MessageCardTags.PRIVATE).assertIsDisplayed() + composeRule.onNodeWithText("Private").assertIsDisplayed() + } + + @Test + fun privateMarker_isAbsent_onPublicMessage() { + setFeed( + MessagesFeedUiState( + messages = listOf(message("1", "for everyone", mine = true, publiclyVisible = true)), + ), + ) + composeRule.onNodeWithTag(MessageCardTags.PRIVATE).assertDoesNotExist() + } + + @Test + fun visibilityChips_areShown_inComposer_andReflectTheDefault() { + setFeed( + MessagesFeedUiState( + isComposeOpen = true, + composeText = "hi", + composeVisibility = MessageVisibility.PRIVATE, + ), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PUBLIC).assertIsDisplayed() + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PRIVATE).assertIsSelected() + } + + @Test + fun visibilityChips_overrideTheDefault_whenTapped() { + setFeed( + MessagesFeedUiState( + isComposeOpen = true, + composeText = "hi", + composeVisibility = MessageVisibility.PUBLIC, + ), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PRIVATE).performClick() + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PRIVATE).assertIsSelected() + composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_HINT).assertIsDisplayed() + } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt index b1bf7bc..f7a98c1 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 @@ -13,11 +13,14 @@ import com.interlinedlist.android.feature.messages.data.remote.dto.PaginationDto import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.toDomain +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.dto.toDomain import com.interlinedlist.android.core.network.error.safeApiCall import com.interlinedlist.android.feature.messages.domain.CreatedMessage import com.interlinedlist.android.feature.messages.domain.CrossPostSelection 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 kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -31,6 +34,8 @@ import javax.inject.Inject class DefaultMessagesRepository @Inject constructor( private val api: MessagesApi, + /** The shared current-user endpoint; supplies the default-visibility preference. */ + private val userApi: InterlinedListApi, private val messageDao: MessageDao, private val sessionStore: SessionStore, private val json: Json, @@ -87,9 +92,12 @@ class DefaultMessagesRepository @Inject constructor( videoUrls: List, scheduledAt: String?, crossPost: CrossPostSelection, + visibility: MessageVisibility, ): ApiResult = withContext(dispatchers.io) { val request = CreateMessageRequest( content = content, + // Always explicit: the composer owns this choice, not the server default. + publiclyVisible = visibility.publiclyVisible, imageUrls = imageUrls.ifEmpty { null }, videoUrls = videoUrls.ifEmpty { null }, scheduledAt = scheduledAt, @@ -118,6 +126,15 @@ class DefaultMessagesRepository @Inject constructor( } } + override suspend fun getDefaultVisibility(): ApiResult = + withContext(dispatchers.io) { + when (val result = safeCall { userApi.getCurrentUser().user }) { + is ApiResult.Success -> + ApiResult.Success(MessageVisibility.of(result.data.toDomain().defaultPubliclyVisible)) + is ApiResult.Failure -> result + } + } + override suspend fun getLinkedNetworks(): ApiResult> = withContext(dispatchers.io) { when (val result = safeCall { api.getIdentities() }) { is ApiResult.Success -> ApiResult.Success(result.data.identities.map { it.toDomain() }) 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 5d0cf76..c9014ab 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 @@ -5,6 +5,7 @@ import com.interlinedlist.android.feature.messages.domain.CreatedMessage import com.interlinedlist.android.feature.messages.domain.CrossPostSelection 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 kotlinx.coroutines.flow.Flow @@ -46,6 +47,12 @@ interface MessagesRepository { * [crossPost] (InterlinedList-only when [CrossPostSelection.NONE]). A scheduled * message does not enter the feed cache. Returns the created message plus any * per-network cross-post delivery statuses the endpoint reported. + * + * [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. */ suspend fun createMessage( content: String, @@ -53,8 +60,15 @@ interface MessagesRepository { videoUrls: List = emptyList(), scheduledAt: String? = null, crossPost: CrossPostSelection = CrossPostSelection.NONE, + visibility: MessageVisibility = MessageVisibility.PUBLIC, ): ApiResult + /** + * The account's default post visibility, read from `defaultPubliclyVisible` + * on `GET /api/user`. Seeds the composer's Public/Private toggle. + */ + suspend fun getDefaultVisibility(): ApiResult + /** * The caller's already-linked social networks, offered as cross-post * destinations in the composer. Read directly from the API (not cached); an 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 21a9d04..14987ed 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 @@ -36,6 +36,8 @@ data class MessageEntity( val scheduledAt: String? = null, /** Last-edited instant; null when the message has not been edited. */ val editedAt: String? = null, + /** False when the message is private (visible only to its author). */ + val publiclyVisible: Boolean = true, ) fun MessageEntity.toDomain(): Message = Message( @@ -56,6 +58,7 @@ fun MessageEntity.toDomain(): Message = Message( linkPreview = linkPreview, scheduledAt = scheduledAt, editedAt = editedAt, + publiclyVisible = publiclyVisible, ) fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( @@ -77,4 +80,5 @@ fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( linkPreview = linkPreview, scheduledAt = scheduledAt, editedAt = editedAt, + publiclyVisible = publiclyVisible, ) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt index a938405..bc53912 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 = 2, + version = 3, 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 bbaae53..33afa16 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 @@ -36,6 +36,8 @@ data class MessageDto( val linkMetadata: LinkMetadataDto? = null, /** Future send time for a scheduled message; null once published. */ val scheduledAt: String? = null, + /** False when the message is private (visible only to its author). */ + val publiclyVisible: Boolean = true, ) /** Author identity embedded in a message. */ @@ -87,6 +89,7 @@ fun MessageDto.toDomain(currentUserId: String?): Message { videoUrls = videoUrls, linkPreview = linkMetadata?.toDomain(), scheduledAt = scheduledAt, + publiclyVisible = publiclyVisible, ) } 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 ab22db9..a5c9c09 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 @@ -79,18 +79,26 @@ data class CrossPostStatusDto( * [imageUrls] / [videoUrls] carry media previously uploaded via the upload * endpoints, and [scheduledAt] (ISO-8601) defers publishing to a future time. * + * [publiclyVisible] decides whether the message lands on the public feed or stays + * visible only to its author. The composer always sends an explicit value (seeded + * from the account's `defaultPubliclyVisible` preference) so the server default + * never silently decides; it stays nullable for the call sites that do not offer + * the choice (e.g. replies). + * * Cross-posting targets are encoded per the create schema: [mastodonProviderIds] * lists the selected mastodon identity ids (a user may link several instances), * while [crossPostToBluesky] / [crossPostToLinkedIn] / [crossPostToTwitter] are * single boolean flags for the one-account networks. * * Only non-null fields are serialised (the shared Json uses `explicitNulls = - * false`), so a plain InterlinedList-only post still sends just `{ content }`. + * false`), so a plain InterlinedList-only post sends just + * `{ content, publiclyVisible }`. */ @Serializable data class CreateMessageRequest( val content: String, val parentId: String? = null, + val publiclyVisible: Boolean? = null, val imageUrls: List? = null, val videoUrls: List? = null, val scheduledAt: String? = 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 dbfcc1f..1ef9ef1 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 @@ -39,6 +39,12 @@ data class Message( * it was posted; null for an un-edited message. Drives the "edited" indicator. */ val editedAt: String? = null, + /** + * False when the message is private (visible only to its author). Public is + * the server default, so an older cache row or a payload that omits the + * field reads as public. + */ + val publiclyVisible: Boolean = true, ) { /** Best available display label for the author. */ val authorLabel: String get() = authorDisplayName?.takeIf { it.isNotBlank() } ?: authorUsername @@ -48,6 +54,16 @@ data class Message( /** True when the message has been edited since it was posted. */ val isEdited: Boolean get() = editedAt != null + + /** [publiclyVisible] as a domain value. */ + val visibility: MessageVisibility get() = MessageVisibility.of(publiclyVisible) + + /** + * True when the card should mark this message as private. Only the author's + * own private messages are marked — public messages are never badged, and + * another user's message is never labelled on the viewer's behalf. + */ + val showsPrivateBadge: Boolean get() = mine && !publiclyVisible } /** 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 new file mode 100644 index 0000000..f40b68e --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibility.kt @@ -0,0 +1,37 @@ +package com.interlinedlist.android.feature.messages.domain + +/** + * Who can see a message. + * + * A **public** message appears on the feed and on its author's profile; a + * **private** one is visible only to its author. On the wire this is the single + * boolean `publiclyVisible`, both on the create request and on the message + * itself, so this enum exists purely to keep call sites unambiguous (a bare + * boolean named `visible` reads badly at the ViewModel/UI layer). + * + * The composer seeds its choice from the account preference + * (`defaultPubliclyVisible` on `GET /api/user`) and the user may override it per + * message. + */ +enum class MessageVisibility(val publiclyVisible: Boolean) { + PUBLIC(true), + PRIVATE(false), + ; + + companion object { + /** Maps the wire `publiclyVisible` boolean into the enum. */ + fun of(publiclyVisible: Boolean): MessageVisibility = + if (publiclyVisible) PUBLIC else PRIVATE + + /** + * The visibility a push (repost) or quote post must be created with. + * + * 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. + */ + 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 246b6e7..e7a2d89 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 @@ -14,6 +14,7 @@ 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.Lock import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.FavoriteBorder @@ -52,6 +53,9 @@ object MessageCardTags { const val REPORT_USER = "messageReportUser" const val BODY = "messageBody" const val EDITED = "messageEdited" + + /** The "Private" marker shown on the author's own non-public messages. */ + const val PRIVATE = "messagePrivate" } /** @@ -104,6 +108,10 @@ fun MessageCard( modifier = Modifier.testTag(MessageCardTags.EDITED), ) } + if (message.showsPrivateBadge) { + Spacer(Modifier.width(6.dp)) + PrivateBadge() + } Spacer(Modifier.weight(1f)) MessageMenu( isMine = message.mine, @@ -149,6 +157,36 @@ fun MessageCard( } } +/** + * The "Private" marker: an icon plus label making it unmistakable that only the + * author can see this message. Public messages carry no badge at all, so the + * marker only ever means "not on the feed". + */ +@Composable +private fun PrivateBadge() { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp) + .testTag(MessageCardTags.PRIVATE), + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(12.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "Private", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + /** * Overflow menu. Own messages offer Edit + Delete. Everyone else's offer author * moderation — Block user, Mute user, Report user — plus the existing Report 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 523a1c5..8ef985c 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 @@ -25,6 +25,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Check @@ -63,6 +65,7 @@ import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme 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.ReportReason import com.interlinedlist.android.feature.messages.ui.components.EditMessageSheet import com.interlinedlist.android.feature.messages.ui.components.MessageCard @@ -96,6 +99,11 @@ object MessagesFeedTags { /** Post-send banner listing per-network cross-post statuses. */ const val CROSS_POST_STATUS = "messagesFeedCrossPostStatus" + /** Composer visibility chips and the private-selection hint. */ + const val VISIBILITY_PUBLIC = "messagesComposeVisibilityPublic" + const val VISIBILITY_PRIVATE = "messagesComposeVisibilityPrivate" + const val VISIBILITY_HINT = "messagesComposeVisibilityHint" + fun destinationTag(networkId: String): String = DESTINATION_PREFIX + networkId } @@ -141,6 +149,7 @@ fun MessagesRoute( }, onRemoveAttachment = viewModel::onRemoveAttachment, onScheduleChange = viewModel::onScheduleChange, + onVisibilityChange = viewModel::onVisibilityChange, onToggleNetwork = viewModel::onToggleNetwork, onDismissCrossPostStatuses = viewModel::dismissCrossPostStatuses, onDismissReport = viewModel::dismissReport, @@ -179,6 +188,7 @@ fun MessagesFeedScreen( onAttachMedia: (Uri, Boolean) -> Unit = { _, _ -> }, onRemoveAttachment: (PendingAttachment) -> Unit = {}, onScheduleChange: (String?) -> Unit = {}, + onVisibilityChange: (MessageVisibility) -> Unit = {}, onToggleNetwork: (String) -> Unit = {}, onDismissCrossPostStatuses: () -> Unit = {}, onDismissReport: () -> Unit = {}, @@ -247,6 +257,7 @@ fun MessagesFeedScreen( onAttachMedia = onAttachMedia, onRemoveAttachment = onRemoveAttachment, onScheduleChange = onScheduleChange, + onVisibilityChange = onVisibilityChange, onToggleNetwork = onToggleNetwork, ) } @@ -458,6 +469,7 @@ private fun ComposeSheet( onAttachMedia: (Uri, Boolean) -> Unit, onRemoveAttachment: (PendingAttachment) -> Unit, onScheduleChange: (String?) -> Unit, + onVisibilityChange: (MessageVisibility) -> Unit, onToggleNetwork: (String) -> Unit, ) { val imagePicker = rememberLauncherForActivityResult( @@ -518,6 +530,13 @@ private fun ComposeSheet( ) } + Spacer(Modifier.height(12.dp)) + VisibilityRow( + visibility = state.composeVisibility, + enabled = !state.isPosting, + onVisibilityChange = onVisibilityChange, + ) + Spacer(Modifier.height(12.dp)) DestinationsRow( networks = state.linkedNetworks, @@ -589,6 +608,61 @@ private fun AttachmentRow( } } +/** + * The Public / Private control. Seeded from the account's default-visibility + * preference and overridable for this message only; the selection is always sent + * 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. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VisibilityRow( + visibility: MessageVisibility, + enabled: Boolean, + onVisibilityChange: (MessageVisibility) -> Unit, +) { + Column { + Text( + text = "Visibility", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = visibility == MessageVisibility.PUBLIC, + onClick = { onVisibilityChange(MessageVisibility.PUBLIC) }, + enabled = enabled, + 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 (visibility == MessageVisibility.PRIVATE) { + Spacer(Modifier.height(4.dp)) + Text( + text = "Only you will see this message.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MessagesFeedTags.VISIBILITY_HINT), + ) + } + } +} + /** * 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 09d6af8..c679eb8 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 @@ -9,6 +9,7 @@ import com.interlinedlist.android.feature.messages.domain.CrossPostSelection 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.ReportReason import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate import com.interlinedlist.android.feature.messages.ui.toUserMessage @@ -47,6 +48,11 @@ data class MessagesFeedUiState( val attachments: List = emptyList(), /** Optional future send time (ISO-8601) for the in-progress compose. */ val scheduledAt: String? = null, + /** + * Visibility the in-progress compose will post with: the account's + * `defaultPubliclyVisible` preference unless the user overrode it here. + */ + val composeVisibility: MessageVisibility = MessageVisibility.PUBLIC, /** 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. */ @@ -107,6 +113,10 @@ private data class FeedTransientState( val isPosting: Boolean = false, val attachments: List = emptyList(), val scheduledAt: String? = null, + /** The account preference; the fallback until/unless the user overrides it. */ + val defaultVisibility: MessageVisibility = MessageVisibility.PUBLIC, + /** The user's per-message choice for the open composer; null = use the default. */ + val visibilityOverride: MessageVisibility? = null, val linkedNetworks: List = emptyList(), val selectedNetworkIds: Set = emptySet(), val crossPostStatuses: List = emptyList(), @@ -117,7 +127,10 @@ private data class FeedTransientState( val isSavingEdit: Boolean = false, val moderationTarget: ModerationTarget? = null, val isModerating: Boolean = false, -) +) { + /** A per-message override always wins over the account default. */ + val composeVisibility: MessageVisibility get() = visibilityOverride ?: defaultVisibility +} @HiltViewModel class MessagesFeedViewModel @Inject constructor( @@ -144,6 +157,7 @@ class MessagesFeedViewModel @Inject constructor( isPosting = t.isPosting, attachments = t.attachments, scheduledAt = t.scheduledAt, + composeVisibility = t.composeVisibility, linkedNetworks = t.linkedNetworks, selectedNetworkIds = t.selectedNetworkIds, crossPostStatuses = t.crossPostStatuses, @@ -164,6 +178,21 @@ class MessagesFeedViewModel @Inject constructor( init { refresh() loadLinkedNetworks() + loadDefaultVisibility() + } + + /** + * Loads the account's default post visibility so the composer opens on the + * user's preference. Best-effort: a failure leaves the default at public and + * does not surface a feed-level error. A per-message override is preserved. + */ + private fun loadDefaultVisibility() { + viewModelScope.launch { + when (val result = repository.getDefaultVisibility()) { + is ApiResult.Success -> transient.update { it.copy(defaultVisibility = result.data) } + is ApiResult.Failure -> Unit + } + } } /** @@ -249,10 +278,17 @@ class MessagesFeedViewModel @Inject constructor( composeText = "", attachments = emptyList(), scheduledAt = null, + // Drop the per-message override; the next compose starts from the default. + visibilityOverride = null, selectedNetworkIds = emptySet(), ) } + /** Overrides the account default for this message only. */ + fun onVisibilityChange(visibility: MessageVisibility) = transient.update { + it.copy(visibilityOverride = visibility) + } + fun onComposeTextChange(value: String) = transient.update { it.copy(composeText = value) } /** @@ -335,6 +371,7 @@ class MessagesFeedViewModel @Inject constructor( videoUrls = videos, scheduledAt = snapshot.scheduledAt, crossPost = crossPost, + visibility = snapshot.composeVisibility, ) ) { is ApiResult.Success -> transient.update { @@ -344,6 +381,7 @@ class MessagesFeedViewModel @Inject constructor( composeText = "", attachments = emptyList(), scheduledAt = null, + visibilityOverride = 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 75dc8c5..d6e6d98 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 @@ -3,8 +3,10 @@ package com.interlinedlist.android.feature.messages.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.feature.messages.data.remote.MessagesApi import com.interlinedlist.android.feature.messages.domain.CrossPostSelection +import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.NetworkProvider import com.interlinedlist.android.feature.messages.domain.ReportReason import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory @@ -35,6 +37,7 @@ class DefaultMessagesRepositoryTest { private lateinit var server: MockWebServer private lateinit var api: MessagesApi + private lateinit var userApi: InterlinedListApi private lateinit var dao: FakeMessageDao @Before @@ -42,11 +45,12 @@ class DefaultMessagesRepositoryTest { server = MockWebServer() server.start() val contentType = "application/json".toMediaType() - api = Retrofit.Builder() + val retrofit = Retrofit.Builder() .baseUrl(server.url("/")) .addConverterFactory(json.asConverterFactory(contentType)) .build() - .create(MessagesApi::class.java) + api = retrofit.create(MessagesApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) dao = FakeMessageDao() } @@ -55,6 +59,7 @@ class DefaultMessagesRepositoryTest { private fun repository(currentUserId: String? = "me") = DefaultMessagesRepository( api = api, + userApi = userApi, messageDao = dao, sessionStore = fakeSessionStore(currentUserId), json = json, @@ -668,4 +673,93 @@ class DefaultMessagesRepositoryTest { val cached = repo.observeMessage("m1").first() assertThat(cached?.linkPreview?.title).isEqualTo("Example") } + + // --- visibility (publiclyVisible) -------------------------------------- + + @Test + fun `createMessage sends publiclyVisible true for a public post`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "v1", "content": "public post", "publiclyVisible": true } }""", + ) + val repo = repository() + + val result = repo.createMessage(content = "public post", visibility = MessageVisibility.PUBLIC) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.message.publiclyVisible).isTrue() + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"publiclyVisible\":true") + } + + @Test + fun `createMessage sends publiclyVisible false for a private post`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "v2", "content": "just for me", "publiclyVisible": false } }""", + ) + val repo = repository() + + val result = repo.createMessage(content = "just for me", visibility = MessageVisibility.PRIVATE) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val created = (result as ApiResult.Success).data.message + assertThat(created.publiclyVisible).isFalse() + assertThat(created.visibility).isEqualTo(MessageVisibility.PRIVATE) + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"publiclyVisible\":false") + } + + @Test + fun `getDefaultVisibility reads defaultPubliclyVisible from the current user`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "user": { "id": "me", "username": "me", "defaultPubliclyVisible": false } }""", + ) + val repo = repository() + + val result = repo.getDefaultVisibility() + + assertThat((result as ApiResult.Success).data).isEqualTo(MessageVisibility.PRIVATE) + assertThat(server.takeRequest().path).contains("api/user") + } + + @Test + fun `getDefaultVisibility falls back to public when the preference is absent`() = runTest(dispatcher) { + enqueueJson(200, """{ "user": { "id": "me", "username": "me" } }""") + val repo = repository() + + val result = repo.getDefaultVisibility() + + assertThat((result as ApiResult.Success).data).isEqualTo(MessageVisibility.PUBLIC) + } + + @Test + fun `refreshFeed caches each message's visibility`() = runTest(dispatcher) { + enqueueJson( + 200, + """ + { + "data": [ + { "id": "1", "content": "private one", "publiclyVisible": false, + "author": { "id": "me", "username": "me" } }, + { "id": "2", "content": "public one", "publiclyVisible": true, + "author": { "id": "me", "username": "me" } } + ], + "pagination": { "hasMore": false } + } + """.trimIndent(), + ) + val repo = repository() + + repo.refreshFeed() + + val cached = repo.observeFeed().first() + val private = cached.first { it.id == "1" } + assertThat(private.publiclyVisible).isFalse() + assertThat(private.showsPrivateBadge).isTrue() + assertThat(cached.first { it.id == "2" }.showsPrivateBadge).isFalse() + } } 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 65c8df4..f6c763f 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 @@ -165,4 +165,19 @@ class MessageDtoMapperTest { assertThat(message.linkPreview).isNull() } + + @Test + fun `maps publiclyVisible into the domain`() { + val message = MessageDto(id = "m11", content = "shh", publiclyVisible = false) + .toDomain(currentUserId = null) + + assertThat(message.publiclyVisible).isFalse() + } + + @Test + fun `defaults to public when the payload omits publiclyVisible`() { + val message = MessageDto(id = "m12", content = "hi").toDomain(currentUserId = null) + + assertThat(message.publiclyVisible).isTrue() + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibilityTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibilityTest.kt new file mode 100644 index 0000000..1bbd53d --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/domain/MessageVisibilityTest.kt @@ -0,0 +1,55 @@ +package com.interlinedlist.android.feature.messages.domain + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.messages.ui.sampleMessage +import org.junit.Test + +/** + * Covers the visibility domain rules: the wire mapping, the card's "Private" + * marker predicate, and the push/quote always-public invariant. + */ +class MessageVisibilityTest { + + @Test + fun `maps the wire boolean both ways`() { + assertThat(MessageVisibility.of(publiclyVisible = true)).isEqualTo(MessageVisibility.PUBLIC) + assertThat(MessageVisibility.of(publiclyVisible = false)).isEqualTo(MessageVisibility.PRIVATE) + assertThat(MessageVisibility.PUBLIC.publiclyVisible).isTrue() + assertThat(MessageVisibility.PRIVATE.publiclyVisible).isFalse() + } + + @Test + fun `push and quote posts are always public`() { + assertThat(MessageVisibility.PUSH_OR_QUOTE).isEqualTo(MessageVisibility.PUBLIC) + assertThat(MessageVisibility.PUSH_OR_QUOTE.publiclyVisible).isTrue() + } + + @Test + fun `own private message is marked private on the card`() { + val message = sampleMessage(mine = true, publiclyVisible = false) + + assertThat(message.visibility).isEqualTo(MessageVisibility.PRIVATE) + assertThat(message.showsPrivateBadge).isTrue() + } + + @Test + fun `own public message is not badged`() { + val message = sampleMessage(mine = true, publiclyVisible = true) + + assertThat(message.visibility).isEqualTo(MessageVisibility.PUBLIC) + assertThat(message.showsPrivateBadge).isFalse() + } + + @Test + fun `another user's message never shows the private marker`() { + // The feed only ever returns others' public messages; defensive either way. + val message = sampleMessage(mine = false, publiclyVisible = false) + + assertThat(message.showsPrivateBadge).isFalse() + } + + @Test + fun `messages default to public`() { + assertThat(sampleMessage().publiclyVisible).isTrue() + } +} 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 c0928fb..01a2680 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 @@ -8,6 +8,7 @@ import com.interlinedlist.android.feature.messages.domain.CrossPostSelection 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.ReportReason import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,6 +32,8 @@ class FakeMessagesRepository : MessagesRepository { /** Cross-post statuses returned alongside a successful [createResult]. */ var createCrossPosts: List = emptyList() var linkedNetworksResult: ApiResult> = ApiResult.Success(emptyList()) + /** The account's default post visibility, as read from `GET /api/user`. */ + var defaultVisibilityResult: ApiResult = ApiResult.Success(MessageVisibility.PUBLIC) var fetchResult: ApiResult? = null var refreshRepliesResult: ApiResult = ApiResult.Success(Unit) var postReplyResult: ApiResult? = null @@ -71,6 +74,7 @@ class FakeMessagesRepository : MessagesRepository { val videoUrls: List, val scheduledAt: String?, val crossPost: CrossPostSelection = CrossPostSelection.NONE, + val visibility: MessageVisibility = MessageVisibility.PUBLIC, ) /** Snapshot of the arguments passed to the last [report] call. */ @@ -112,8 +116,9 @@ class FakeMessagesRepository : MessagesRepository { videoUrls: List, scheduledAt: String?, crossPost: CrossPostSelection, + visibility: MessageVisibility, ): ApiResult { - lastCreate = CreateArgs(content, imageUrls, videoUrls, scheduledAt, crossPost) + lastCreate = CreateArgs(content, imageUrls, videoUrls, scheduledAt, crossPost, visibility) return when (val result = createResult) { is ApiResult.Success -> ApiResult.Success(CreatedMessage(result.data, createCrossPosts)) is ApiResult.Failure -> result @@ -123,6 +128,8 @@ class FakeMessagesRepository : MessagesRepository { override suspend fun getLinkedNetworks(): ApiResult> = linkedNetworksResult + override suspend fun getDefaultVisibility(): ApiResult = defaultVisibilityResult + override suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult { uploadedImages++ return uploadImageResult @@ -234,6 +241,7 @@ fun sampleMessage( scheduledAt: String? = null, authorUsername: String = "adron", editedAt: String? = null, + publiclyVisible: Boolean = true, ) = Message( id = id, content = content, @@ -251,4 +259,5 @@ fun sampleMessage( videoUrls = videoUrls, scheduledAt = scheduledAt, editedAt = editedAt, + publiclyVisible = publiclyVisible, ) 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 b354d92..08d868b 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 @@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.domain.CrossPostStatus +import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository import com.interlinedlist.android.feature.messages.ui.sampleMessage @@ -571,4 +572,113 @@ class MessagesFeedViewModelTest { assertThat(repo.metadataFetchedIds).containsExactly("m1") } + + // --- compose visibility ------------------------------------------------ + + @Test + fun `compose visibility defaults to the account preference`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + defaultVisibilityResult = ApiResult.Success(MessageVisibility.PRIVATE) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + assertThat(vm.uiState.value.composeVisibility).isEqualTo(MessageVisibility.PRIVATE) + } + + @Test + fun `compose visibility falls back to public when the preference cannot be read`() = + runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + defaultVisibilityResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + assertThat(vm.uiState.value.composeVisibility).isEqualTo(MessageVisibility.PUBLIC) + // A missing preference is not a feed-level error. + assertThat(vm.uiState.value.errorMessage).isNull() + } + + @Test + fun `post sends the account default visibility when it is not overridden`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + defaultVisibilityResult = ApiResult.Success(MessageVisibility.PRIVATE) + createResult = ApiResult.Success(sampleMessage(id = "new")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("hello") + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.visibility).isEqualTo(MessageVisibility.PRIVATE) + } + + @Test + fun `a per-message override wins over the account default`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + defaultVisibilityResult = ApiResult.Success(MessageVisibility.PRIVATE) + createResult = ApiResult.Success(sampleMessage(id = "new")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("shout it") + vm.onVisibilityChange(MessageVisibility.PUBLIC) + advanceUntilIdle() + assertThat(vm.uiState.value.composeVisibility).isEqualTo(MessageVisibility.PUBLIC) + + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.visibility).isEqualTo(MessageVisibility.PUBLIC) + } + + @Test + fun `dismissing the composer drops the per-message override`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + defaultVisibilityResult = ApiResult.Success(MessageVisibility.PUBLIC) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onVisibilityChange(MessageVisibility.PRIVATE) + advanceUntilIdle() + assertThat(vm.uiState.value.composeVisibility).isEqualTo(MessageVisibility.PRIVATE) + + vm.dismissCompose() + advanceUntilIdle() + + assertThat(vm.uiState.value.composeVisibility).isEqualTo(MessageVisibility.PUBLIC) + } + + @Test + fun `a successful post resets the composer back to the account default`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + defaultVisibilityResult = ApiResult.Success(MessageVisibility.PUBLIC) + createResult = ApiResult.Success(sampleMessage(id = "new")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("private thought") + vm.onVisibilityChange(MessageVisibility.PRIVATE) + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate?.visibility).isEqualTo(MessageVisibility.PRIVATE) + assertThat(vm.uiState.value.composeVisibility).isEqualTo(MessageVisibility.PUBLIC) + } }