feat: comment collapsing (#83)

* feat: new comment expanding policy

* fix: typo in Italian l10n

* fix: text fields in create post screen
This commit is contained in:
Diego Beraldin 2023-10-29 09:30:58 +01:00 committed by GitHub
parent dfff1ed9fb
commit 596d4a5cd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 427 additions and 219 deletions

View File

@ -0,0 +1,107 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.utils.toLocalDp
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommentModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.UserModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.CommentRepository
@Composable
fun CollapsedCommentCard(
comment: CommentModel,
modifier: Modifier = Modifier,
separateUpAndDownVotes: Boolean = false,
autoLoadImages: Boolean = true,
options: List<String> = emptyList(),
onOpenCreator: ((UserModel) -> Unit)? = null,
onUpVote: (() -> Unit)? = null,
onDownVote: (() -> Unit)? = null,
onSave: (() -> Unit)? = null,
onReply: (() -> Unit)? = null,
onOptionSelected: ((Int) -> Unit)? = null,
onToggleExpanded: (() -> Unit)? = null,
) {
val themeRepository = remember { getThemeRepository() }
var commentHeight by remember { mutableStateOf(0f) }
val barWidth = 2.dp
val barColor = themeRepository.getCommentBarColor(
depth = comment.depth,
maxDepth = CommentRepository.MAX_COMMENT_DEPTH,
startColor = MaterialTheme.colorScheme.primary,
endColor = MaterialTheme.colorScheme.background,
)
Column(
modifier = modifier
) {
Box(
modifier = Modifier.padding(
start = (10 * comment.depth).dp
),
) {
Column(
modifier = Modifier
.padding(start = barWidth)
.fillMaxWidth()
.padding(
vertical = Spacing.xxs,
horizontal = Spacing.s,
).onGloballyPositioned {
commentHeight = it.size.toSize().height
}
) {
CommunityAndCreatorInfo(
iconSize = 20.dp,
creator = comment.creator,
indicatorExpanded = comment.expanded,
autoLoadImages = autoLoadImages,
onToggleExpanded = {
onToggleExpanded?.invoke()
},
onOpenCreator = onOpenCreator,
)
PostCardFooter(
score = comment.score,
separateUpAndDownVotes = separateUpAndDownVotes,
upvotes = comment.upvotes,
downvotes = comment.downvotes,
saved = comment.saved,
upVoted = comment.myVote > 0,
downVoted = comment.myVote < 0,
comments = comment.comments,
onUpVote = onUpVote,
onDownVote = onDownVote,
onSave = onSave,
onReply = onReply,
date = comment.publishDate,
options = options,
onOptionSelected = onOptionSelected,
)
}
Box(
modifier = Modifier
.padding(top = Spacing.xs)
.width(barWidth)
.height(commentHeight.toLocalDp())
.background(color = barColor)
)
}
}
}

View File

@ -45,6 +45,7 @@ fun CommentCard(
onOpenCommunity: ((CommunityModel) -> Unit)? = null,
onOpenCreator: ((UserModel) -> Unit)? = null,
onOptionSelected: ((Int) -> Unit)? = null,
onToggleExpanded: (() -> Unit)? = null,
) {
val themeRepository = remember { getThemeRepository() }
Column(
@ -85,6 +86,7 @@ fun CommentCard(
autoLoadImages = autoLoadImages,
onOpenCreator = onOpenCreator,
onOpenCommunity = onOpenCommunity,
onToggleExpanded = onToggleExpanded,
)
ScaledContent {
PostCardBody(

View File

@ -36,6 +36,7 @@ fun CommunityAndCreatorInfo(
creator: UserModel? = null,
onOpenCommunity: ((CommunityModel) -> Unit)? = null,
onOpenCreator: ((UserModel) -> Unit)? = null,
onToggleExpanded: (() -> Unit)? = null,
) {
val communityName = community?.name.orEmpty()
val communityIcon = community?.icon.orEmpty()
@ -144,16 +145,18 @@ fun CommunityAndCreatorInfo(
}
if (indicatorExpanded != null) {
Spacer(modifier = Modifier.weight(1f))
val modifier = Modifier.padding(end = Spacing.xs)
val expandedModifier = Modifier
.padding(end = Spacing.xs)
.onClick { onToggleExpanded?.invoke() }
if (indicatorExpanded) {
Icon(
modifier = modifier,
modifier = expandedModifier,
imageVector = Icons.Default.ExpandLess,
contentDescription = null,
)
} else {
Icon(
modifier = modifier,
modifier = expandedModifier,
imageVector = Icons.Default.ExpandMore,
contentDescription = null,
)

View File

@ -44,6 +44,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
@ -174,6 +175,7 @@ class CreatePostScreen(
},
textStyle = MaterialTheme.typography.titleMedium,
value = uiState.title,
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrect = false,
@ -218,7 +220,6 @@ class CreatePostScreen(
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
maxLines = 1,
label = {
Text(text = stringResource(MR.strings.create_post_url))
},
@ -231,8 +232,11 @@ class CreatePostScreen(
contentDescription = null,
)
},
textStyle = MaterialTheme.typography.bodyMedium,
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
value = uiState.url,
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrect = false,

View File

@ -1,10 +1,13 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.postdetail
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -76,6 +79,7 @@ import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepos
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.communitydetail.CommunityDetailScreen
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CollapsedCommentCard
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CommentCard
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CommentCardPlaceholder
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.FloatingActionButtonMenu
@ -455,161 +459,114 @@ class PostDetailScreen(
}
itemsIndexed(uiState.comments, key = { _, c -> c.id }) { idx, comment ->
val commentId = comment.id
AnimatedVisibility(
visible = comment.visible,
exit = fadeOut(),
enter = fadeIn(),
AnimatedContent(
targetState = comment.expanded,
transitionSpec = {
fadeIn(animationSpec = tween(220, delayMillis = 90))
.togetherWith(fadeOut(animationSpec = tween(90)))
},
) {
if (comment.expanded) {
SwipeableCard(
modifier = Modifier.fillMaxWidth(),
enabled = uiState.swipeActionsEnabled && !isOnOtherInstance,
backgroundColor = {
when (it) {
DismissValue.DismissedToStart -> upvoteColor
?: defaultUpvoteColor
) {
SwipeableCard(
modifier = Modifier.fillMaxWidth(),
enabled = uiState.swipeActionsEnabled && !isOnOtherInstance,
backgroundColor = {
when (it) {
DismissValue.DismissedToStart -> upvoteColor
?: defaultUpvoteColor
DismissValue.DismissedToEnd -> downvoteColor
?: defaultDownVoteColor
DismissValue.DismissedToEnd -> downvoteColor
?: defaultDownVoteColor
DismissValue.Default -> Color.Transparent
}
},
onGestureBegin = {
model.reduce(PostDetailMviModel.Intent.HapticIndication)
},
onDismissToStart = {
model.reduce(
PostDetailMviModel.Intent.UpVoteComment(commentId),
)
},
onDismissToEnd = {
model.reduce(
PostDetailMviModel.Intent.DownVoteComment(commentId),
)
},
swipeContent = { direction ->
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.ArrowCircleDown
DismissDirection.EndToStart -> Icons.Default.ArrowCircleUp
}
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
)
},
content = {
CommentCard(
modifier = Modifier.background(MaterialTheme.colorScheme.background)
.let {
if (comment.id == highlightCommentId) {
it.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(
5.dp
).copy(
alpha = 0.75f
DismissValue.Default -> Color.Transparent
}
},
onGestureBegin = {
model.reduce(PostDetailMviModel.Intent.HapticIndication)
},
onDismissToStart = {
model.reduce(
PostDetailMviModel.Intent.UpVoteComment(commentId),
)
},
onDismissToEnd = {
model.reduce(
PostDetailMviModel.Intent.DownVoteComment(commentId),
)
},
swipeContent = { direction ->
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.ArrowCircleDown
DismissDirection.EndToStart -> Icons.Default.ArrowCircleUp
}
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
)
},
content = {
CommentCard(
modifier = Modifier.background(MaterialTheme.colorScheme.background)
.let {
if (comment.id == highlightCommentId) {
it.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(
5.dp
).copy(
alpha = 0.75f
)
)
)
} else {
it
}
}.onClick {
} else {
it
}
},
comment = comment,
separateUpAndDownVotes = uiState.separateUpAndDownVotes,
autoLoadImages = uiState.autoLoadImages,
onToggleExpanded = {
model.reduce(
PostDetailMviModel.Intent.ToggleExpandComment(
commentId,
)
)
},
comment = comment,
separateUpAndDownVotes = uiState.separateUpAndDownVotes,
autoLoadImages = uiState.autoLoadImages,
onUpVote = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.UpVoteComment(
commentId = commentId,
feedback = true,
),
)
}
},
onDownVote = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.DownVoteComment(
commentId = commentId,
feedback = true,
),
)
}
},
onSave = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.SaveComment(
commentId = commentId,
feedback = true,
),
)
}
},
onReply = {
if (!isOnOtherInstance) {
val screen = CreateCommentScreen(
originalPost = statePost,
originalComment = comment,
)
notificationCenter.addObserver(
{
model.reduce(PostDetailMviModel.Intent.Refresh)
model.reduce(PostDetailMviModel.Intent.RefreshPost)
},
key,
NotificationCenterContractKeys.CommentCreated
)
bottomSheetNavigator.show(screen)
}
},
onOpenCreator = {
val user = comment.creator
if (user != null) {
navigator?.push(
UserDetailScreen(
user = user,
otherInstance = otherInstance,
),
)
}
},
onOpenCommunity = {
val community = comment.community
if (community != null) {
navigator?.push(
CommunityDetailScreen(
community = community,
otherInstance = otherInstance,
),
)
}
},
options = buildList {
add(stringResource(MR.strings.post_action_see_raw))
add(stringResource(MR.strings.post_action_report))
if (comment.creator?.id == uiState.currentUserId) {
add(stringResource(MR.strings.post_action_edit))
add(stringResource(MR.strings.comment_action_delete))
}
},
onOptionSelected = { optionId ->
when (optionId) {
3 -> model.reduce(
PostDetailMviModel.Intent.DeleteComment(
comment.id
)
)
2 -> {
},
onUpVote = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.UpVoteComment(
commentId = commentId,
feedback = true,
),
)
}
},
onDownVote = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.DownVoteComment(
commentId = commentId,
feedback = true,
),
)
}
},
onSave = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.SaveComment(
commentId = commentId,
feedback = true,
),
)
}
},
onReply = {
if (!isOnOtherInstance) {
val screen = CreateCommentScreen(
originalPost = statePost,
originalComment = comment,
)
notificationCenter.addObserver(
{
model.reduce(PostDetailMviModel.Intent.Refresh)
@ -618,29 +575,195 @@ class PostDetailScreen(
key,
NotificationCenterContractKeys.CommentCreated
)
bottomSheetNavigator.show(
CreateCommentScreen(
editedComment = comment,
)
bottomSheetNavigator.show(screen)
}
},
onOpenCreator = {
val user = comment.creator
if (user != null) {
navigator?.push(
UserDetailScreen(
user = user,
otherInstance = otherInstance,
),
)
}
1 -> {
bottomSheetNavigator.show(
CreateReportScreen(
commentId = comment.id
)
},
onOpenCommunity = {
val community = comment.community
if (community != null) {
navigator?.push(
CommunityDetailScreen(
community = community,
otherInstance = otherInstance,
),
)
}
else -> {
rawContent = comment
},
options = buildList {
add(stringResource(MR.strings.post_action_see_raw))
add(stringResource(MR.strings.post_action_report))
if (comment.creator?.id == uiState.currentUserId) {
add(stringResource(MR.strings.post_action_edit))
add(stringResource(MR.strings.comment_action_delete))
}
},
onOptionSelected = { optionId ->
when (optionId) {
3 -> model.reduce(
PostDetailMviModel.Intent.DeleteComment(
comment.id
)
)
2 -> {
notificationCenter.addObserver(
{
model.reduce(PostDetailMviModel.Intent.Refresh)
model.reduce(PostDetailMviModel.Intent.RefreshPost)
},
key,
NotificationCenterContractKeys.CommentCreated
)
bottomSheetNavigator.show(
CreateCommentScreen(
editedComment = comment,
)
)
}
1 -> {
bottomSheetNavigator.show(
CreateReportScreen(
commentId = comment.id
)
)
}
else -> {
rawContent = comment
}
}
},
)
},
)
} else {
CollapsedCommentCard(
comment = comment,
modifier = Modifier.padding(vertical = Spacing.xs),
onToggleExpanded = {
model.reduce(
PostDetailMviModel.Intent.ToggleExpandComment(
comment.id
)
)
},
onUpVote = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.UpVoteComment(
commentId = commentId,
feedback = true,
),
)
}
},
onDownVote = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.DownVoteComment(
commentId = commentId,
feedback = true,
),
)
}
},
onSave = {
if (!isOnOtherInstance) {
model.reduce(
PostDetailMviModel.Intent.SaveComment(
commentId = commentId,
feedback = true,
),
)
}
},
onReply = {
if (!isOnOtherInstance) {
val screen = CreateCommentScreen(
originalPost = statePost,
originalComment = comment,
)
notificationCenter.addObserver(
{
model.reduce(PostDetailMviModel.Intent.Refresh)
model.reduce(PostDetailMviModel.Intent.RefreshPost)
},
key,
NotificationCenterContractKeys.CommentCreated
)
bottomSheetNavigator.show(screen)
}
},
onOpenCreator = {
val user = comment.creator
if (user != null) {
navigator?.push(
UserDetailScreen(
user = user,
otherInstance = otherInstance,
),
)
}
},
options = buildList {
add(stringResource(MR.strings.post_action_see_raw))
add(stringResource(MR.strings.post_action_report))
if (comment.creator?.id == uiState.currentUserId) {
add(stringResource(MR.strings.post_action_edit))
add(stringResource(MR.strings.comment_action_delete))
}
},
onOptionSelected = { optionId ->
when (optionId) {
3 -> model.reduce(
PostDetailMviModel.Intent.DeleteComment(
comment.id
)
)
2 -> {
notificationCenter.addObserver(
{
model.reduce(PostDetailMviModel.Intent.Refresh)
model.reduce(PostDetailMviModel.Intent.RefreshPost)
},
key,
NotificationCenterContractKeys.CommentCreated
)
bottomSheetNavigator.show(
CreateCommentScreen(
editedComment = comment,
)
)
}
},
)
},
)
1 -> {
bottomSheetNavigator.show(
CreateReportScreen(
commentId = comment.id
)
)
}
else -> {
rawContent = comment
}
}
},
)
}
Divider(
modifier = Modifier.padding(vertical = Spacing.xxxs),
thickness = 0.25.dp

View File

@ -43,7 +43,6 @@ class PostDetailViewModel(
private var currentPage: Int = 1
private var highlightCommentPath: String? = null
private var commentWasHighlighted = false
private var expandedTopLevelComments = mutableListOf<Int>()
init {
notificationCenter.addObserver({
@ -250,24 +249,18 @@ class PostDetailViewModel(
sort = sort,
)?.processCommentsToGetNestedOrder(
ancestorId = null,
)?.populateLoadMoreComments().let {
)?.populateLoadMoreComments()?.let { list ->
if (refreshing) {
it
list
} else {
it?.filter { c1 ->
list.filter { c1 ->
// prevents accidental duplication
currentState.comments.none { c2 -> c1.id == c2.id }
}
}
}?.let {
if (autoExpandComments) {
expandedTopLevelComments =
it.filter { c -> c.depth == 0 }.map { c -> c.id }.toMutableList()
}
it
}?.applyExpansionFilter(
expandedTopLevelCommentIds = expandedTopLevelComments,
)
}?.map {
it.copy(expanded = autoExpandComments)
}
if (!itemList.isNullOrEmpty()) {
currentPage++
@ -322,13 +315,10 @@ class PostDetailViewModel(
ancestorId = parentId.toString(),
)?.filter {
currentState.comments.none { c -> c.id == it.id }
}?.let {
if (autoExpandComments) {
expandedTopLevelComments =
it.filter { c -> c.depth == 0 }.map { c -> c.id }.toMutableList()
}
it
}?.map {
it.copy(expanded = autoExpandComments)
}
val commentsToInsert = fetchResult.orEmpty()
if (commentsToInsert.isEmpty()) {
// abort and disable load more button
@ -622,25 +612,20 @@ class PostDetailViewModel(
}
private fun toggleExpanded(comment: CommentModel) {
if (comment.depth > 0) {
return
}
val id = comment.id
if (expandedTopLevelComments.contains(id)) {
expandedTopLevelComments -= id
} else {
expandedTopLevelComments += id
}
val commentId = comment.id
mvi.updateState {
val newComments = it.comments.applyExpansionFilter(
expandedTopLevelCommentIds = expandedTopLevelComments,
)
val newComments = it.comments.map { comment ->
if (comment.id == commentId) {
comment.copy(expanded = !comment.expanded)
} else {
comment
}
}
it.copy(comments = newComments)
}
}
}
private data class Node(
val comment: CommentModel?,
val children: MutableList<Node> = mutableListOf(),
@ -705,17 +690,3 @@ private fun List<CommentModel>.processCommentsToGetNestedOrder(
return result.reversed().toList()
}
private fun List<CommentModel>.applyExpansionFilter(
expandedTopLevelCommentIds: List<Int>,
): List<CommentModel> = map { comment ->
val visible = comment.depth == 0 || expandedTopLevelCommentIds.any { e ->
e == comment.path.split(".")[1].toInt()
}
val indicatorExpanded = when {
comment.depth > 0 -> null
(comment.comments ?: 0) == 0 -> null
else -> expandedTopLevelCommentIds.contains(comment.id)
}
comment.copy(visible = visible, expanded = indicatorExpanded)
}

View File

@ -18,9 +18,7 @@ data class CommentModel(
val comments: Int? = null,
val path: String = "",
@Transient
val visible: Boolean = true,
@Transient
val expanded: Boolean? = null,
val expanded: Boolean = true,
@Transient
val loadMoreButtonVisible: Boolean = false,
) : JavaSerializable {

View File

@ -197,5 +197,5 @@
<string name="settings_ui_font_family">Font UI</string>
<string name="settings_ui_font_scale">Dimensione testo UI</string>
<string name="settings_ui_theme">Tema interfaccia</string>
<string name="settings_upvote_color">Colore voti positivo</string>
<string name="settings_upvote_color">Colore voti positivi</string>
</resources>