feat(comments): remember comment collapsed state

Remember the comment expanded/collapsed state when scrolling. Previously,
the state was remembered inside the `Node` composable. When the users scroll,
and `Node` re-composes, the state was being reset.

In this commit, we add the collapsed state into the `CommentNode`. This makes
sure that the state is retained even when the UI is re-composed.
This commit is contained in:
Ruben Quadros 2024-10-04 00:14:10 +05:30
parent 00c75588cd
commit 86400c352b
4 changed files with 101 additions and 45 deletions

View file

@ -6,15 +6,15 @@
*/
package dev.msfjarvis.claw.common.comments
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.model.Comment
internal class CommentNode(
internal data class CommentNode(
val comment: Comment,
private var parent: CommentNode? = null,
val children: MutableList<CommentNode> = mutableListOf(),
val isUnread: Boolean = false,
val indentLevel: Int,
val isExpanded: Boolean = true,
) {
fun addChild(child: CommentNode) {
@ -50,6 +50,7 @@ internal class CommentNode(
if (children != other.children) return false
if (isUnread != other.isUnread) return false
if (indentLevel != other.indentLevel) return false
if (isExpanded != other.isExpanded) return false
return true
}
@ -59,37 +60,7 @@ internal class CommentNode(
result = 31 * result + children.hashCode()
result = 31 * result + isUnread.hashCode()
result = 31 * result + indentLevel
result = 31 * result + isExpanded.hashCode()
return result
}
}
internal fun createListNode(
comments: List<Comment>,
commentState: PostComments?,
): MutableList<CommentNode> {
val commentNodes = mutableListOf<CommentNode>()
val isUnread = { id: String -> commentState?.commentIds?.contains(id) == false }
for (i in comments.indices) {
if (comments[i].parentComment == null) {
commentNodes.add(
CommentNode(
comment = comments[i],
isUnread = isUnread(comments[i].shortId),
indentLevel = 1,
)
)
} else {
commentNodes.lastOrNull()?.let { commentNode ->
commentNode.addChild(
CommentNode(
comment = comments[i],
isUnread = isUnread(comments[i].shortId),
indentLevel = commentNode.indentLevel + 1,
)
)
}
}
}
return commentNodes
}

View file

@ -0,0 +1,60 @@
/*
* Copyright © 2024 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.comments
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.model.Comment
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
internal class CommentsHandler {
private val _listItems: MutableStateFlow<List<CommentNode>> = MutableStateFlow(emptyList())
val listItems: StateFlow<List<CommentNode>> = _listItems.asStateFlow()
fun createListNode(comments: List<Comment>, commentState: PostComments?) {
val commentNodes = mutableListOf<CommentNode>()
val isUnread = { id: String -> commentState?.commentIds?.contains(id) == false }
for (i in comments.indices) {
if (comments[i].parentComment == null) {
commentNodes.add(
CommentNode(
comment = comments[i],
isUnread = isUnread(comments[i].shortId),
indentLevel = 1,
)
)
} else {
commentNodes.lastOrNull()?.let { commentNode ->
commentNode.addChild(
CommentNode(
comment = comments[i],
isUnread = isUnread(comments[i].shortId),
indentLevel = commentNode.indentLevel + 1,
)
)
}
}
}
_listItems.value = commentNodes
}
fun updateListNode(shortId: String, isExpanded: Boolean) {
val listNode = _listItems.value.toMutableList()
val index = listNode.indexOfFirst { it.comment.shortId == shortId }
if (index != -1) {
val commentNode = listNode[index].copy(isExpanded = isExpanded)
listNode[index] = commentNode
_listItems.value = listNode.toList()
}
}
}

View file

@ -60,6 +60,7 @@ fun CommentsPage(
markSeenComments = markSeenComments,
openUserProfile = openUserProfile,
contentPadding = contentPadding,
commentsHandler = CommentsHandler(),
modifier = modifier.fillMaxSize(),
)
}

View file

@ -8,6 +8,12 @@ package dev.msfjarvis.claw.common.comments
import android.text.format.DateUtils
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -21,6 +27,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -28,9 +35,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.platform.LocalContext
@ -39,6 +43,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.Submitter
import dev.msfjarvis.claw.common.ui.ThemedRichText
@ -48,6 +53,8 @@ import dev.msfjarvis.claw.model.UIPost
import java.time.Instant
import java.time.temporal.TemporalAccessor
private const val AnimationDuration = 100
@Composable
internal fun CommentsPageInternal(
details: UIPost,
@ -57,10 +64,19 @@ internal fun CommentsPageInternal(
markSeenComments: (String, List<Comment>) -> Unit,
openUserProfile: (String) -> Unit,
contentPadding: PaddingValues,
commentsHandler: CommentsHandler,
modifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) { commentsHandler.createListNode(details.comments, commentState) }
val onToggleExpandedState = { shortId: String, isExpanded: Boolean ->
commentsHandler.updateListNode(shortId, isExpanded)
}
val context = LocalContext.current
val commentNodes = createListNode(details.comments, commentState)
val commentNodes by commentsHandler.listItems.collectAsStateWithLifecycle()
LaunchedEffect(key1 = commentNodes) {
if (details.comments.isNotEmpty() && !commentState?.commentIds.isNullOrEmpty()) {
val unreadCount = details.comments.size - (commentState?.commentIds?.size ?: 0)
@ -96,8 +112,8 @@ internal fun CommentsPageInternal(
)
}
commentNodes.forEach { node ->
item(key = node.comment.shortId) { Node(node, htmlConverter, openUserProfile) }
items(items = commentNodes, key = { node -> node.comment.shortId }) { node ->
Node(node, htmlConverter, openUserProfile, onToggleExpandedState)
}
item(key = "bottom_spacer") {
@ -127,22 +143,30 @@ private fun Node(
node: CommentNode,
htmlConverter: HTMLConverter,
openUserProfile: (String) -> Unit,
onToggleExpandedState: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
var isChildrenShown by remember { mutableStateOf(true) }
NodeBox(
node = node,
isExpanded = isChildrenShown,
isExpanded = node.isExpanded,
htmlConverter = htmlConverter,
openUserProfile,
modifier = modifier.clickable(onClick = { isChildrenShown = !isChildrenShown }),
modifier =
modifier.clickable(
onClick = { onToggleExpandedState(node.comment.shortId, !node.isExpanded) }
),
)
if (isChildrenShown) {
AnimatedVisibility(
visible = node.isExpanded,
enter = fadeIn(tween(AnimationDuration)) + expandVertically(tween(AnimationDuration)),
exit = fadeOut(tween(AnimationDuration)) + shrinkVertically(tween(AnimationDuration)),
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
node.children.forEach { model -> Node(model, htmlConverter, openUserProfile) }
node.children.forEach { model ->
Node(model, htmlConverter, openUserProfile, onToggleExpandedState)
}
}
}
}