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 @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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]. */
Expand All @@ -27,4 +29,5 @@ fun UserDto.toDomain(): User = User(
bio = bio,
emailVerified = emailVerified,
customerStatus = CustomerStatus.fromApiValue(customerStatus),
defaultPubliclyVisible = defaultPubliclyVisible,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,11 +35,12 @@ class MessagesFeedScreenTest {
imageUrls: List<String> = 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. */
Expand Down Expand Up @@ -73,6 +75,7 @@ class MessagesFeedScreenTest {
}
state = state.copy(selectedNetworkIds = selected)
},
onVisibilityChange = { state = state.copy(composeVisibility = it) },
onReport = onReport,
onEdit = onEdit,
onBlockUser = onBlockUser,
Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -87,9 +92,12 @@ class DefaultMessagesRepository @Inject constructor(
videoUrls: List<String>,
scheduledAt: String?,
crossPost: CrossPostSelection,
visibility: MessageVisibility,
): ApiResult<CreatedMessage> = 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,
Expand Down Expand Up @@ -118,6 +126,15 @@ class DefaultMessagesRepository @Inject constructor(
}
}

override suspend fun getDefaultVisibility(): ApiResult<MessageVisibility> =
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<List<LinkedNetwork>> = withContext(dispatchers.io) {
when (val result = safeCall { api.getIdentities() }) {
is ApiResult.Success -> ApiResult.Success(result.data.identities.map { it.toDomain() })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -46,15 +47,28 @@ 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,
imageUrls: List<String> = emptyList(),
videoUrls: List<String> = emptyList(),
scheduledAt: String? = null,
crossPost: CrossPostSelection = CrossPostSelection.NONE,
visibility: MessageVisibility = MessageVisibility.PUBLIC,
): ApiResult<CreatedMessage>

/**
* The account's default post visibility, read from `defaultPubliclyVisible`
* on `GET /api/user`. Seeds the composer's Public/Private toggle.
*/
suspend fun getDefaultVisibility(): ApiResult<MessageVisibility>

/**
* The caller's already-linked social networks, offered as cross-post
* destinations in the composer. Read directly from the API (not cached); an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -56,6 +58,7 @@ fun MessageEntity.toDomain(): Message = Message(
linkPreview = linkPreview,
scheduledAt = scheduledAt,
editedAt = editedAt,
publiclyVisible = publiclyVisible,
)

fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity(
Expand All @@ -77,4 +80,5 @@ fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity(
linkPreview = linkPreview,
scheduledAt = scheduledAt,
editedAt = editedAt,
publiclyVisible = publiclyVisible,
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import androidx.room.TypeConverters
*/
@Database(
entities = [MessageEntity::class],
version = 2,
version = 3,
exportSchema = false,
)
@TypeConverters(MessageConverters::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -87,6 +89,7 @@ fun MessageDto.toDomain(currentUserId: String?): Message {
videoUrls = videoUrls,
linkPreview = linkMetadata?.toDomain(),
scheduledAt = scheduledAt,
publiclyVisible = publiclyVisible,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = null,
val videoUrls: List<String>? = null,
val scheduledAt: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading