diff --git a/app/src/main/graphql/pub/hackers/android/operations.graphql b/app/src/main/graphql/pub/hackers/android/operations.graphql index a30fe471..a96fa2a2 100644 --- a/app/src/main/graphql/pub/hackers/android/operations.graphql +++ b/app/src/main/graphql/pub/hackers/android/operations.graphql @@ -34,6 +34,8 @@ fragment PostFields on Post { iri viewerHasShared viewerHasBookmarked + viewerCanQuote + quotePolicy actor { ...ActorFields } @@ -104,6 +106,8 @@ fragment SharedPostFields on Post { iri viewerHasShared viewerHasBookmarked + viewerCanQuote + quotePolicy actor { ...ActorFields } @@ -592,8 +596,8 @@ query ViewerPasskeys { } } -mutation CreateNote($content: Markdown!, $language: Locale!, $visibility: PostVisibility!, $replyTargetId: ID, $quotedPostId: ID) { - createNote(input: { content: $content, language: $language, visibility: $visibility, replyTargetId: $replyTargetId, quotedPostId: $quotedPostId }) { +mutation CreateNote($content: Markdown!, $language: Locale!, $visibility: PostVisibility!, $quotePolicy: QuotePolicy, $replyTargetId: ID, $quotedPostId: ID) { + createNote(input: { content: $content, language: $language, visibility: $visibility, quotePolicy: $quotePolicy, replyTargetId: $replyTargetId, quotedPostId: $quotedPostId }) { ... on CreateNotePayload { note { ...PostFields @@ -932,8 +936,8 @@ query PostByUrl($url: String!) { } } -mutation PublishArticleDraft($id: ID!, $slug: String!, $language: Locale!, $allowLlmTranslation: Boolean) { - publishArticleDraft(input: { id: $id, slug: $slug, language: $language, allowLlmTranslation: $allowLlmTranslation }) { +mutation PublishArticleDraft($id: ID!, $slug: String!, $language: Locale!, $allowLlmTranslation: Boolean, $quotePolicy: QuotePolicy) { + publishArticleDraft(input: { id: $id, slug: $slug, language: $language, allowLlmTranslation: $allowLlmTranslation, quotePolicy: $quotePolicy }) { ... on PublishArticleDraftPayload { article { id diff --git a/app/src/main/graphql/pub/hackers/android/schema.graphqls b/app/src/main/graphql/pub/hackers/android/schema.graphqls index 030d04cb..9fe28f61 100644 --- a/app/src/main/graphql/pub/hackers/android/schema.graphqls +++ b/app/src/main/graphql/pub/hackers/android/schema.graphqls @@ -456,6 +456,8 @@ type Article implements Node & Post & Reactable { publishedYear: Int! + quotePolicy: QuotePolicy! + quotedPost: Post quotes(after: String, before: String, first: Int, last: Int): PostQuotesConnection! @@ -484,6 +486,10 @@ type Article implements Node & Post & Reactable { uuid: UUID! + viewerCanQuote: Boolean! + + viewerCanRevokeQuote: Boolean! + viewerHasBookmarked: Boolean! viewerHasShared: Boolean! @@ -586,6 +592,8 @@ input CreateNoteInput { language: Locale! + quotePolicy: QuotePolicy + quotedPostId: ID replyTargetId: ID @@ -1011,6 +1019,8 @@ type Note implements Node & Post & Reactable { published: DateTime! + quotePolicy: QuotePolicy! + quotedPost: Post quotes(after: String, before: String, first: Int, last: Int): PostQuotesConnection! @@ -1035,6 +1045,10 @@ type Note implements Node & Post & Reactable { uuid: UUID! + viewerCanQuote: Boolean! + + viewerCanRevokeQuote: Boolean! + viewerHasBookmarked: Boolean! viewerHasShared: Boolean! @@ -1209,6 +1223,8 @@ interface Post implements Node & Reactable { published: DateTime! + quotePolicy: QuotePolicy! + quotedPost: Post quotes(after: String, before: String, first: Int, last: Int): PostQuotesConnection! @@ -1233,6 +1249,10 @@ interface Post implements Node & Reactable { uuid: UUID! + viewerCanQuote: Boolean! + + viewerCanRevokeQuote: Boolean! + viewerHasBookmarked: Boolean! viewerHasShared: Boolean! @@ -1372,6 +1392,14 @@ enum PostVisibility { UNLISTED } +enum QuotePolicy { + EVERYONE + + FOLLOWERS + + SELF +} + input PublishArticleDraftInput { allowLlmTranslation: Boolean @@ -1381,6 +1409,8 @@ input PublishArticleDraftInput { language: Locale! + quotePolicy: QuotePolicy + slug: String! } @@ -1548,6 +1578,8 @@ type Question implements Node & Post & Reactable { published: DateTime! + quotePolicy: QuotePolicy! + quotedPost: Post quotes(after: String, before: String, first: Int, last: Int): PostQuotesConnection! @@ -1572,6 +1604,10 @@ type Question implements Node & Post & Reactable { uuid: UUID! + viewerCanQuote: Boolean! + + viewerCanRevokeQuote: Boolean! + viewerHasBookmarked: Boolean! viewerHasShared: Boolean! diff --git a/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt b/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt index 891b2987..56e0e94a 100644 --- a/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt +++ b/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt @@ -64,6 +64,7 @@ import pub.hackers.android.graphql.fragment.MediaFields import pub.hackers.android.graphql.fragment.PostFields import pub.hackers.android.graphql.fragment.SharedPostFields import pub.hackers.android.graphql.type.PostVisibility as GqlPostVisibility +import pub.hackers.android.graphql.type.QuotePolicy as GqlQuotePolicy import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -929,6 +930,7 @@ class HackersPubRepository @Inject constructor( content: String, language: String = "en", visibility: PostVisibility = PostVisibility.PUBLIC, + quotePolicy: QuotePolicy = QuotePolicy.EVERYONE, replyTargetId: String? = null, quotedPostId: String? = null ): Result { @@ -940,12 +942,18 @@ class HackersPubRepository @Inject constructor( PostVisibility.DIRECT -> GqlPostVisibility.DIRECT PostVisibility.NONE -> GqlPostVisibility.NONE } + val gqlQuotePolicy = when (quotePolicy) { + QuotePolicy.EVERYONE -> GqlQuotePolicy.EVERYONE + QuotePolicy.FOLLOWERS -> GqlQuotePolicy.FOLLOWERS + QuotePolicy.SELF -> GqlQuotePolicy.SELF + } val response = apolloClient.mutation( CreateNoteMutation( content = content, language = language, visibility = gqlVisibility, + quotePolicy = Optional.present(gqlQuotePolicy), replyTargetId = Optional.presentIfNotNull(replyTargetId), quotedPostId = Optional.presentIfNotNull(quotedPostId) ) @@ -1400,15 +1408,22 @@ class HackersPubRepository @Inject constructor( id: String, slug: String, language: String, - allowLlmTranslation: Boolean = true + allowLlmTranslation: Boolean = true, + quotePolicy: QuotePolicy = QuotePolicy.EVERYONE ): Result { return try { + val gqlQuotePolicy = when (quotePolicy) { + QuotePolicy.EVERYONE -> GqlQuotePolicy.EVERYONE + QuotePolicy.FOLLOWERS -> GqlQuotePolicy.FOLLOWERS + QuotePolicy.SELF -> GqlQuotePolicy.SELF + } val response = apolloClient.mutation( PublishArticleDraftMutation( id = id, slug = slug, language = language, - allowLlmTranslation = Optional.present(allowLlmTranslation) + allowLlmTranslation = Optional.present(allowLlmTranslation), + quotePolicy = Optional.present(gqlQuotePolicy) ) ).execute() @@ -1515,6 +1530,7 @@ class HackersPubRepository @Inject constructor( iri = iri.toString(), viewerHasShared = viewerHasShared, viewerHasBookmarked = viewerHasBookmarked, + viewerCanQuote = viewerCanQuote, actor = actor.actorFields.toActor(), media = media.map { it.mediaFields.toMedia() }, link = link?.let { l -> @@ -1543,6 +1559,7 @@ class HackersPubRepository @Inject constructor( replyTarget = replyTarget, quotedPost = quotedPost?.sharedPostFields?.toPost(), visibility = visibility, + quotePolicy = quotePolicy.toQuotePolicy(), reactionGroups = reactionGroups.mapNotNull { group -> when { group.onEmojiReactionGroup != null -> ReactionGroup( @@ -1582,13 +1599,24 @@ class HackersPubRepository @Inject constructor( iri = iri.toString(), viewerHasShared = viewerHasShared, viewerHasBookmarked = viewerHasBookmarked, + viewerCanQuote = viewerCanQuote, actor = actor.actorFields.toActor(), media = media.map { it.mediaFields.toMedia() }, engagementStats = engagementStats.engagementStatsFields.toEngagementStats(), - mentions = mentions.edges.map { it.node.handle } + mentions = mentions.edges.map { it.node.handle }, + quotePolicy = quotePolicy.toQuotePolicy() ) } + private fun GqlQuotePolicy.toQuotePolicy(): QuotePolicy { + return when (this) { + GqlQuotePolicy.EVERYONE -> QuotePolicy.EVERYONE + GqlQuotePolicy.FOLLOWERS -> QuotePolicy.FOLLOWERS + GqlQuotePolicy.SELF -> QuotePolicy.SELF + GqlQuotePolicy.UNKNOWN__ -> QuotePolicy.EVERYONE + } + } + private fun ActorFields.toActor(): Actor { return Actor( id = id, diff --git a/app/src/main/java/pub/hackers/android/domain/model/Models.kt b/app/src/main/java/pub/hackers/android/domain/model/Models.kt index 7aa5242e..5ff35983 100644 --- a/app/src/main/java/pub/hackers/android/domain/model/Models.kt +++ b/app/src/main/java/pub/hackers/android/domain/model/Models.kt @@ -79,6 +79,7 @@ data class Post( val iri: String? = null, val viewerHasShared: Boolean, val viewerHasBookmarked: Boolean = false, + val viewerCanQuote: Boolean = true, val actor: Actor, val media: List, val link: PostLink? = null, @@ -90,6 +91,7 @@ data class Post( val replyTarget: Post? = null, val quotedPost: Post? = null, val visibility: PostVisibility = PostVisibility.PUBLIC, + val quotePolicy: QuotePolicy = QuotePolicy.EVERYONE, val reactionGroups: List = emptyList() ) @@ -97,6 +99,10 @@ enum class PostVisibility { PUBLIC, UNLISTED, FOLLOWERS, DIRECT, NONE } +enum class QuotePolicy { + EVERYONE, FOLLOWERS, SELF +} + @Immutable data class NotificationPost( val id: String, diff --git a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt index 0713220f..37ffc1df 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt @@ -594,7 +594,7 @@ private fun EngagementBar( isShared = isShared, count = post.engagementStats.shares, onShareClick = onShareClick, - onQuoteClick = onQuoteClick + onQuoteClick = if (post.viewerCanQuote) onQuoteClick else null ) // Heart/React — tap to toggle ❤️, long-press for emoji picker diff --git a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeArticleScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeArticleScreen.kt index 2f52cdc1..42617f06 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeArticleScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeArticleScreen.kt @@ -24,10 +24,12 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -39,11 +41,14 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -56,6 +61,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import pub.hackers.android.R +import pub.hackers.android.domain.model.QuotePolicy import pub.hackers.android.ui.theme.AppShapes import pub.hackers.android.ui.theme.LocalAppColors import pub.hackers.android.ui.theme.LocalAppTypography @@ -70,6 +76,7 @@ fun ComposeArticleScreen( ) { val uiState by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + var showQuotePolicyMenu by remember { mutableStateOf(false) } val colors = LocalAppColors.current val typography = LocalAppTypography.current @@ -349,6 +356,66 @@ fun ComposeArticleScreen( } Spacer(modifier = Modifier.height(8.dp)) + TextButton( + onClick = { showQuotePolicyMenu = true }, + enabled = !uiState.isPublishing, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 8.dp, + vertical = 4.dp + ) + ) { + Icon( + imageVector = quotePolicyIcon(uiState.quotePolicy), + contentDescription = quotePolicyLabel(uiState.quotePolicy), + tint = colors.textSecondary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = quotePolicyShortLabel(uiState.quotePolicy), + color = colors.textSecondary, + style = typography.labelMedium + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = colors.textSecondary + ) + + DropdownMenu( + expanded = showQuotePolicyMenu, + onDismissRequest = { showQuotePolicyMenu = false } + ) { + quotePolicyMenuItem( + policy = QuotePolicy.EVERYONE, + selectedPolicy = uiState.quotePolicy, + enabled = true, + onClick = { + viewModel.updateQuotePolicy(QuotePolicy.EVERYONE) + showQuotePolicyMenu = false + } + ) + quotePolicyMenuItem( + policy = QuotePolicy.FOLLOWERS, + selectedPolicy = uiState.quotePolicy, + enabled = true, + onClick = { + viewModel.updateQuotePolicy(QuotePolicy.FOLLOWERS) + showQuotePolicyMenu = false + } + ) + quotePolicyMenuItem( + policy = QuotePolicy.SELF, + selectedPolicy = uiState.quotePolicy, + enabled = true, + onClick = { + viewModel.updateQuotePolicy(QuotePolicy.SELF) + showQuotePolicyMenu = false + } + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End diff --git a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeArticleViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeArticleViewModel.kt index f3af3bc2..a4b03859 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeArticleViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeArticleViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import pub.hackers.android.data.repository.HackersPubRepository +import pub.hackers.android.domain.model.QuotePolicy import javax.inject.Inject data class ComposeArticleUiState( @@ -26,6 +27,7 @@ data class ComposeArticleUiState( val slug: String = "", val language: String = java.util.Locale.getDefault().language, val allowLlmTranslation: Boolean = true, + val quotePolicy: QuotePolicy = QuotePolicy.EVERYONE, val showPublishFields: Boolean = false, val isPublishing: Boolean = false, val isPublished: Boolean = false, @@ -94,6 +96,10 @@ class ComposeArticleViewModel @Inject constructor( _uiState.update { it.copy(allowLlmTranslation = allow) } } + fun updateQuotePolicy(quotePolicy: QuotePolicy) { + _uiState.update { it.copy(quotePolicy = quotePolicy) } + } + fun saveDraft() { val state = _uiState.value if (state.title.isBlank()) { @@ -195,7 +201,8 @@ class ComposeArticleViewModel @Inject constructor( id = draftId, slug = state.slug, language = state.language, - allowLlmTranslation = state.allowLlmTranslation + allowLlmTranslation = state.allowLlmTranslation, + quotePolicy = state.quotePolicy ) .onSuccess { article -> _uiState.update { diff --git a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeScreen.kt index feadd3d6..036a845b 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeScreen.kt @@ -22,9 +22,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.outlined.FormatQuote import androidx.compose.material.icons.outlined.Group import androidx.compose.material.icons.outlined.Lock @@ -76,6 +78,7 @@ import coil3.compose.AsyncImage import pub.hackers.android.R import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.PostVisibility +import pub.hackers.android.domain.model.QuotePolicy import pub.hackers.android.ui.components.HtmlContent import pub.hackers.android.ui.components.MentionAutocomplete import pub.hackers.android.ui.theme.AppShapes @@ -122,6 +125,10 @@ fun ComposeScreen( val uiState by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } var showVisibilityMenu by remember { mutableStateOf(false) } + var showQuotePolicyMenu by remember { mutableStateOf(false) } + val quotePolicyLocked = uiState.visibility != PostVisibility.PUBLIC && + uiState.visibility != PostVisibility.UNLISTED + val effectiveQuotePolicy = if (quotePolicyLocked) QuotePolicy.SELF else uiState.quotePolicy val colors = LocalAppColors.current val typography = LocalAppTypography.current @@ -383,20 +390,16 @@ fun ComposeScreen( ) ) { Icon( - imageVector = when (uiState.visibility) { - PostVisibility.PUBLIC -> Icons.Filled.Public - PostVisibility.UNLISTED -> Icons.Outlined.Lock - PostVisibility.FOLLOWERS -> Icons.Outlined.Group - else -> Icons.Filled.Public - }, - contentDescription = when (uiState.visibility) { - PostVisibility.PUBLIC -> stringResource(R.string.visibility_public) - PostVisibility.UNLISTED -> stringResource(R.string.visibility_unlisted) - PostVisibility.FOLLOWERS -> stringResource(R.string.visibility_followers) - else -> stringResource(R.string.visibility_public) - }, + imageVector = visibilityIcon(uiState.visibility), + contentDescription = visibilityLabel(uiState.visibility), tint = colors.textSecondary ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = visibilityLabel(uiState.visibility), + color = colors.textSecondary, + style = typography.labelMedium + ) Icon( imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, @@ -408,61 +411,95 @@ fun ComposeScreen( expanded = showVisibilityMenu, onDismissRequest = { showVisibilityMenu = false } ) { - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.visibility_public), - color = colors.textPrimary - ) - }, + visibilityMenuItem( + visibility = PostVisibility.PUBLIC, + selectedVisibility = uiState.visibility, onClick = { viewModel.updateVisibility(PostVisibility.PUBLIC) showVisibilityMenu = false - }, - leadingIcon = { - Icon( - Icons.Filled.Public, - contentDescription = null, - tint = colors.textSecondary - ) } ) - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.visibility_unlisted), - color = colors.textPrimary - ) - }, + visibilityMenuItem( + visibility = PostVisibility.UNLISTED, + selectedVisibility = uiState.visibility, onClick = { viewModel.updateVisibility(PostVisibility.UNLISTED) showVisibilityMenu = false - }, - leadingIcon = { - Icon( - Icons.Outlined.Lock, - contentDescription = null, - tint = colors.textSecondary - ) } ) - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.visibility_followers), - color = colors.textPrimary - ) - }, + visibilityMenuItem( + visibility = PostVisibility.FOLLOWERS, + selectedVisibility = uiState.visibility, onClick = { viewModel.updateVisibility(PostVisibility.FOLLOWERS) showVisibilityMenu = false - }, - leadingIcon = { - Icon( - Icons.Outlined.Group, - contentDescription = null, - tint = colors.textSecondary - ) + } + ) + visibilityMenuItem( + visibility = PostVisibility.DIRECT, + selectedVisibility = uiState.visibility, + onClick = { + viewModel.updateVisibility(PostVisibility.DIRECT) + showVisibilityMenu = false + } + ) + } + } + + TextButton( + onClick = { showQuotePolicyMenu = true }, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 8.dp, + vertical = 4.dp + ) + ) { + Icon( + imageVector = quotePolicyIcon(effectiveQuotePolicy), + contentDescription = quotePolicyLabel(effectiveQuotePolicy), + tint = colors.textSecondary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = quotePolicyShortLabel(effectiveQuotePolicy), + color = colors.textSecondary, + style = typography.labelMedium + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = colors.textSecondary, + modifier = Modifier.size(18.dp) + ) + + DropdownMenu( + expanded = showQuotePolicyMenu, + onDismissRequest = { showQuotePolicyMenu = false } + ) { + quotePolicyMenuItem( + policy = QuotePolicy.EVERYONE, + selectedPolicy = effectiveQuotePolicy, + enabled = !quotePolicyLocked, + onClick = { + viewModel.updateQuotePolicy(QuotePolicy.EVERYONE) + showQuotePolicyMenu = false + } + ) + quotePolicyMenuItem( + policy = QuotePolicy.FOLLOWERS, + selectedPolicy = effectiveQuotePolicy, + enabled = !quotePolicyLocked, + onClick = { + viewModel.updateQuotePolicy(QuotePolicy.FOLLOWERS) + showQuotePolicyMenu = false + } + ) + quotePolicyMenuItem( + policy = QuotePolicy.SELF, + selectedPolicy = effectiveQuotePolicy, + enabled = true, + onClick = { + viewModel.updateQuotePolicy(QuotePolicy.SELF) + showQuotePolicyMenu = false } ) } @@ -490,6 +527,111 @@ fun ComposeScreen( } } +@Composable +private fun visibilityMenuItem( + visibility: PostVisibility, + selectedVisibility: PostVisibility, + onClick: () -> Unit, +) { + val colors = LocalAppColors.current + DropdownMenuItem( + text = { + Text( + text = visibilityLabel(visibility), + color = colors.textPrimary + ) + }, + onClick = onClick, + leadingIcon = { + Icon( + visibilityIcon(visibility), + contentDescription = null, + tint = colors.textSecondary + ) + }, + trailingIcon = { + if (selectedVisibility == visibility) { + Icon(Icons.Filled.Check, contentDescription = null) + } + } + ) +} + +@Composable +private fun visibilityLabel(visibility: PostVisibility): String { + return when (visibility) { + PostVisibility.PUBLIC -> stringResource(R.string.visibility_public) + PostVisibility.UNLISTED -> stringResource(R.string.visibility_unlisted) + PostVisibility.FOLLOWERS -> stringResource(R.string.visibility_followers) + PostVisibility.DIRECT -> stringResource(R.string.visibility_direct) + PostVisibility.NONE -> stringResource(R.string.visibility_public) + } +} + +private fun visibilityIcon(visibility: PostVisibility) = when (visibility) { + PostVisibility.PUBLIC -> Icons.Filled.Public + PostVisibility.UNLISTED -> Icons.Outlined.Lock + PostVisibility.FOLLOWERS -> Icons.Outlined.Group + PostVisibility.DIRECT -> Icons.Outlined.Lock + PostVisibility.NONE -> Icons.Filled.Public +} + +@Composable +internal fun quotePolicyMenuItem( + policy: QuotePolicy, + selectedPolicy: QuotePolicy, + enabled: Boolean, + onClick: () -> Unit, +) { + val colors = LocalAppColors.current + DropdownMenuItem( + text = { + Text( + text = quotePolicyLabel(policy), + color = if (enabled) colors.textPrimary else colors.textSecondary + ) + }, + onClick = onClick, + enabled = enabled, + leadingIcon = { + Icon( + quotePolicyIcon(policy), + contentDescription = null, + tint = colors.textSecondary + ) + }, + trailingIcon = { + if (selectedPolicy == policy) { + Icon(Icons.Filled.Check, contentDescription = null) + } + } + ) +} + +@Composable +internal fun quotePolicyLabel(policy: QuotePolicy): String { + return when (policy) { + QuotePolicy.EVERYONE -> stringResource(R.string.quote_policy_everyone) + QuotePolicy.FOLLOWERS -> stringResource(R.string.quote_policy_followers) + QuotePolicy.SELF -> stringResource(R.string.quote_policy_self) + } +} + +internal fun quotePolicyIcon(policy: QuotePolicy) = when (policy) { + QuotePolicy.EVERYONE -> Icons.Filled.Repeat + QuotePolicy.FOLLOWERS -> Icons.Outlined.Group + QuotePolicy.SELF -> Icons.Outlined.Lock +} + +@Composable +internal fun quotePolicyShortLabel(policy: QuotePolicy): String { + return when (policy) { + QuotePolicy.EVERYONE -> stringResource(R.string.quote_policy_short_everyone) + QuotePolicy.FOLLOWERS -> stringResource(R.string.quote_policy_short_followers) + QuotePolicy.SELF -> stringResource(R.string.quote_policy_short_self) + } +} + @Composable private fun ReplyTargetPreview( post: Post, diff --git a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeViewModel.kt index 840ecc9f..66e41cbd 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeViewModel.kt @@ -20,6 +20,7 @@ import pub.hackers.android.data.repository.HackersPubRepository import pub.hackers.android.domain.model.Actor import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.PostVisibility +import pub.hackers.android.domain.model.QuotePolicy import dagger.hilt.android.qualifiers.ApplicationContext import android.content.Context import javax.inject.Inject @@ -29,6 +30,7 @@ data class ComposeUiState( val cursorPosition: Int = 0, val language: String = java.util.Locale.getDefault().language, val visibility: PostVisibility = PostVisibility.PUBLIC, + val quotePolicy: QuotePolicy = QuotePolicy.EVERYONE, val replyToId: String? = null, val replyTargetPost: Post? = null, val isLoadingReplyTarget: Boolean = false, @@ -194,11 +196,25 @@ class ComposeViewModel @Inject constructor( viewModelScope.launch { repository.getPostDetail(postId) .onSuccess { result -> - _uiState.update { - it.copy( - quotedPost = result.post, - isLoadingQuotedPost = false - ) + val post = result.post + if (post.viewerCanQuote) { + _uiState.update { + it.copy( + quotedPost = post, + quotedPostId = post.id, + isLoadingQuotedPost = false + ) + } + } else { + _uiState.update { + it.copy( + quotedPostId = null, + quotedPost = null, + isLoadingQuotedPost = false, + quotedPostLoadFailed = false, + error = "You cannot quote this post" + ) + } } } .onFailure { @@ -284,6 +300,10 @@ class ComposeViewModel @Inject constructor( _uiState.update { it.copy(visibility = visibility) } } + fun updateQuotePolicy(quotePolicy: QuotePolicy) { + _uiState.update { it.copy(quotePolicy = quotePolicy) } + } + fun post() { val state = _uiState.value if (state.content.isBlank() || state.isPosting) return @@ -295,6 +315,7 @@ class ComposeViewModel @Inject constructor( content = state.content, language = state.language, visibility = state.visibility, + quotePolicy = state.effectiveQuotePolicy(), replyTargetId = state.replyToId, quotedPostId = state.quotedPostId ) @@ -318,4 +339,12 @@ class ComposeViewModel @Inject constructor( fun clearError() { _uiState.update { it.copy(error = null) } } + + private fun ComposeUiState.effectiveQuotePolicy(): QuotePolicy { + return if (visibility == PostVisibility.PUBLIC || visibility == PostVisibility.UNLISTED) { + quotePolicy + } else { + QuotePolicy.SELF + } + } } diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt index cbe0fcc9..06f78476 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt @@ -1008,12 +1008,14 @@ internal fun PostDetailContent( ) } } - IconButton(onClick = onQuoteClick) { - Icon( - imageVector = Icons.Outlined.FormatQuote, - contentDescription = stringResource(R.string.quotes), - tint = colors.textSecondary - ) + if (post.viewerCanQuote) { + IconButton(onClick = onQuoteClick) { + Icon( + imageVector = Icons.Outlined.FormatQuote, + contentDescription = stringResource(R.string.quotes), + tint = colors.textSecondary + ) + } } IconButton(onClick = onExternalShareClick) { Icon( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f07b4d2..88522dd2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,9 +47,18 @@ Preview Write something to see the preview Unable to load quoted post + Visibility Public Unlisted Followers only + Mentioned only + Quote permission + Anyone can quote + Followers can quote + Only you can quote + Quote: Anyone + Quote: Followers + Quote: Only you New Article diff --git a/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeArticleViewModelTest.kt b/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeArticleViewModelTest.kt new file mode 100644 index 00000000..47b64594 --- /dev/null +++ b/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeArticleViewModelTest.kt @@ -0,0 +1,82 @@ +package pub.hackers.android.ui.screens.compose + +import android.content.Context +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import pub.hackers.android.data.repository.HackersPubRepository +import pub.hackers.android.domain.model.ArticleDraft +import pub.hackers.android.domain.model.PublishedArticle +import pub.hackers.android.domain.model.QuotePolicy +import pub.hackers.android.testutil.MainDispatcherRule +import java.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ComposeArticleViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository = mockk(relaxed = true) + private val context = mockk(relaxed = true) + + @Test + fun `publishDraft sends selected quote policy`() = runTest { + coEvery { + repository.saveArticleDraft( + title = "Article title", + content = "Article body", + tags = emptyList(), + id = null, + ) + } returns Result.success( + ArticleDraft( + id = "draft-1", + title = "Article title", + content = "Article body", + tags = emptyList(), + created = Instant.parse("2025-01-01T00:00:00Z"), + updated = Instant.parse("2025-01-01T00:00:00Z"), + ) + ) + coEvery { + repository.publishArticleDraft( + id = "draft-1", + slug = "article-title", + language = any(), + allowLlmTranslation = true, + quotePolicy = QuotePolicy.FOLLOWERS, + ) + } returns Result.success( + PublishedArticle(id = "article-1", name = "Article title", url = null) + ) + + val vm = ComposeArticleViewModel(repository, context) + + vm.updateTitle("Article title") + vm.updateContent("Article body") + vm.updateQuotePolicy(QuotePolicy.FOLLOWERS) + vm.publishDraft() + advanceUntilIdle() + + coVerify { + repository.publishArticleDraft( + id = "draft-1", + slug = "article-title", + language = any(), + allowLlmTranslation = true, + quotePolicy = QuotePolicy.FOLLOWERS, + ) + } + } +} diff --git a/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeViewModelTest.kt b/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeViewModelTest.kt index 3a8da108..73d372cd 100644 --- a/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeViewModelTest.kt +++ b/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeViewModelTest.kt @@ -2,6 +2,7 @@ package pub.hackers.android.ui.screens.compose import android.content.Context import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -18,6 +19,8 @@ import pub.hackers.android.domain.model.Actor import pub.hackers.android.domain.model.EngagementStats import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.PostDetailResult +import pub.hackers.android.domain.model.PostVisibility +import pub.hackers.android.domain.model.QuotePolicy import pub.hackers.android.testutil.MainDispatcherRule import java.time.Instant @@ -116,4 +119,73 @@ class ComposeViewModelTest { assertEquals("user typed this", vm.uiState.value.content) } + + @Test + fun `post sends selected quote policy for public notes`() = runTest { + val createdPost = samplePost(id = "created") + coEvery { + repository.createNote( + content = "hello", + language = any(), + visibility = PostVisibility.PUBLIC, + quotePolicy = QuotePolicy.FOLLOWERS, + replyTargetId = null, + quotedPostId = null, + ) + } returns Result.success(createdPost) + + val vm = newViewModel() + advanceUntilIdle() + + vm.updateContent("hello") + vm.updateQuotePolicy(QuotePolicy.FOLLOWERS) + vm.post() + advanceUntilIdle() + + coVerify { + repository.createNote( + content = "hello", + language = any(), + visibility = PostVisibility.PUBLIC, + quotePolicy = QuotePolicy.FOLLOWERS, + replyTargetId = null, + quotedPostId = null, + ) + } + } + + @Test + fun `post clamps quote policy to self for followers-only notes`() = runTest { + val createdPost = samplePost(id = "created") + coEvery { + repository.createNote( + content = "hello", + language = any(), + visibility = PostVisibility.FOLLOWERS, + quotePolicy = QuotePolicy.SELF, + replyTargetId = null, + quotedPostId = null, + ) + } returns Result.success(createdPost) + + val vm = newViewModel() + advanceUntilIdle() + + vm.updateContent("hello") + vm.updateQuotePolicy(QuotePolicy.EVERYONE) + vm.updateVisibility(PostVisibility.FOLLOWERS) + vm.post() + advanceUntilIdle() + + coVerify { + repository.createNote( + content = "hello", + language = any(), + visibility = PostVisibility.FOLLOWERS, + quotePolicy = QuotePolicy.SELF, + replyTargetId = null, + quotedPostId = null, + ) + } + } }