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 package dev.msfjarvis.claw.common.comments
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.model.Comment import dev.msfjarvis.claw.model.Comment
internal class CommentNode( internal data class CommentNode(
val comment: Comment, val comment: Comment,
private var parent: CommentNode? = null, private var parent: CommentNode? = null,
val children: MutableList<CommentNode> = mutableListOf(), val children: MutableList<CommentNode> = mutableListOf(),
val isUnread: Boolean = false, val isUnread: Boolean = false,
val indentLevel: Int, val indentLevel: Int,
val isExpanded: Boolean = true,
) { ) {
fun addChild(child: CommentNode) { fun addChild(child: CommentNode) {
@ -50,6 +50,7 @@ internal class CommentNode(
if (children != other.children) return false if (children != other.children) return false
if (isUnread != other.isUnread) return false if (isUnread != other.isUnread) return false
if (indentLevel != other.indentLevel) return false if (indentLevel != other.indentLevel) return false
if (isExpanded != other.isExpanded) return false
return true return true
} }
@ -59,37 +60,7 @@ internal class CommentNode(
result = 31 * result + children.hashCode() result = 31 * result + children.hashCode()
result = 31 * result + isUnread.hashCode() result = 31 * result + isUnread.hashCode()
result = 31 * result + indentLevel result = 31 * result + indentLevel
result = 31 * result + isExpanded.hashCode()
return result 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, markSeenComments = markSeenComments,
openUserProfile = openUserProfile, openUserProfile = openUserProfile,
contentPadding = contentPadding, contentPadding = contentPadding,
commentsHandler = CommentsHandler(),
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) )
} }

View file

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