Merge pull request #55 from dmnyc/feat/collapsible-threads

feat: collapsible reply threads + iOS-parity UI polish
This commit is contained in:
Barry Deen
2026-07-23 10:54:14 -04:00
committed by GitHub
15 changed files with 971 additions and 354 deletions
@@ -0,0 +1,58 @@
package com.darkwisp.app.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import com.darkwisp.app.R
import com.darkwisp.app.viewmodel.thread.ThreadItem
/**
* Folded-subtree affordance: "Show N more replies". Tapping expands the subtree inline — the
* caller's [onExpand] handles the VM toggle (and scroll anchoring, where used). The guide rail
* is dashed at the top to signal it continues upward to the anchor note. Shared by ThreadScreen
* and ArticleScreen so the two progressive-disclosure surfaces stay consistent.
*/
@Composable
fun CollapsedRepliesRow(
item: ThreadItem.CollapsedReplies,
onExpand: () -> Unit,
modifier: Modifier = Modifier
) {
val indent = threadIndentDp(item.depth)
val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
Row(
modifier = modifier
.fillMaxWidth()
.threadConnector(show = true, indent = indent, lineColor = lineColor, dashedTop = true)
.clickable(onClick = onExpand)
.padding(start = indent, top = 8.dp, bottom = 8.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(6.dp))
Text(
text = pluralStringResource(R.plurals.thread_continue, item.hiddenCount, item.hiddenCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
@@ -323,21 +323,27 @@ fun GalleryCard(
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onProfileClick)
)
profile?.nip05?.let { nip05 ->
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = nip05Repo,
onClick = onProfileClick
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f, fill = false)
.clickable(onClick = onProfileClick)
)
profile?.nip05?.let { nip05 ->
Spacer(Modifier.width(4.dp))
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = nip05Repo,
onClick = onProfileClick,
showHandle = false
)
}
}
}
Text(
@@ -933,7 +939,7 @@ private fun formatGalleryTimestamp(epoch: Long): String {
if (hours < 24) return "${hours}h"
val days = diff / (24 * 60 * 60 * 1000L)
if (days == 1L) return "yesterday"
if (days < 7) return "${days}d"
val date = Date(millis)
val cal = java.util.Calendar.getInstance()
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Reply
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.MoreVert
@@ -60,7 +61,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.zIndex
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@@ -289,6 +290,27 @@ fun PostCard(
}
}
}
if (replyToName != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Reply,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.post_replying_to, replyToName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
ProfilePicture(
url = profile?.picture,
@@ -298,30 +320,25 @@ fun PostCard(
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onProfileClick)
)
if (replyToName != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "replying to ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = replyToName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable {
replyToPubkey?.let { onNavigateToProfile?.invoke(it) }
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f, fill = false)
.clickable(onClick = onProfileClick)
)
profile?.nip05?.let { nip05 ->
Spacer(Modifier.width(4.dp))
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = nip05Repo,
onClick = onProfileClick,
showHandle = false
)
}
}
@@ -340,14 +357,6 @@ fun PostCard(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
}
profile?.nip05?.let { nip05 ->
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = nip05Repo,
onClick = onProfileClick
)
}
}
if (isPrivate) {
Icon(
@@ -1261,7 +1270,7 @@ private val dateTimeYearFormat = SimpleDateFormat("MMM d, yyyy", Locale.US)
/**
* Format an epoch timestamp into a relative or absolute time string.
* Avoids Calendar allocations — uses simple arithmetic for "yesterday" check.
* Avoids Calendar allocations for the s/m/h/d tiers — simple arithmetic.
*/
private fun formatTimestamp(epoch: Long): String {
val now = System.currentTimeMillis()
@@ -1279,7 +1288,7 @@ private fun formatTimestamp(epoch: Long): String {
if (hours < 24) return "${hours}h"
val days = diff / (24 * 60 * 60 * 1000L)
if (days == 1L) return "yesterday"
if (days < 7) return "${days}d"
val date = Date(millis)
val cal = java.util.Calendar.getInstance()
@@ -1307,6 +1316,10 @@ internal fun Nip05Badge(
onClick: (() -> Unit)? = null,
maxLines: Int = 1,
verifiedTint: Color = MaterialTheme.colorScheme.primary,
/** When false, render only the verification icon — no handle text. The handle itself
* is reserved for the profile screen; everywhere else (feed, threads, comments) just
* the badge icon appears next to the username. */
showHandle: Boolean = true,
modifier: Modifier = Modifier
) {
if (nip05.isBlank()) return
@@ -1335,6 +1348,25 @@ internal fun Nip05Badge(
}
)
) {
if (!showHandle) {
// Icon-only badge: appears once verification resolves. No retry icon here —
// a transient relay error shouldn't flag every row across the timeline.
when {
status == Nip05Status.VERIFIED -> Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = verifiedTint,
modifier = Modifier.size(14.dp)
)
isImpersonator -> Icon(
Icons.Default.Cancel,
contentDescription = "Impersonator",
tint = Color.Red,
modifier = Modifier.size(14.dp)
)
}
return@Row
}
Text(
text = nip05,
style = MaterialTheme.typography.bodySmall,
@@ -1346,7 +1378,7 @@ internal fun Nip05Badge(
if (status == Nip05Status.VERIFIED) {
Spacer(Modifier.width(4.dp))
Icon(
Icons.Default.CheckCircle,
Icons.Default.Verified,
contentDescription = "Verified",
tint = verifiedTint,
modifier = Modifier.size(14.dp)
@@ -28,6 +28,7 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
@@ -196,6 +197,10 @@ private fun BaseProfilePicture(
@Composable
private fun FollowBadge(size: Int, modifier: Modifier = Modifier) {
val badgeSize = (size * 0.3f).coerceIn(10f, 16f)
// iOS parity: the check flips with the color scheme — black on the
// orange circle in dark mode, white in light mode. Derive from the
// active surface so theme presets stay correct too.
val checkColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) Color.Black else Color.White
Box(
contentAlignment = Alignment.Center,
modifier = modifier
@@ -207,7 +212,7 @@ private fun FollowBadge(size: Int, modifier: Modifier = Modifier) {
Icon(
Icons.Default.Check,
contentDescription = "Following",
tint = MaterialTheme.colorScheme.onPrimary,
tint = checkColor,
modifier = Modifier.size((badgeSize * 0.65f).dp)
)
}
@@ -1359,15 +1359,28 @@ fun QuotedNote(
Row(verticalAlignment = Alignment.CenterVertically) {
ProfilePicture(url = profile?.picture, size = 34)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Text(
text = profile?.displayString
?: event.pubkey.take(8) + "..." + event.pubkey.takeLast(4),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
profile?.nip05?.let { nip05 ->
Spacer(Modifier.width(4.dp))
Nip05Badge(
nip05 = nip05,
pubkey = event.pubkey,
nip05Repo = noteActions?.nip05Repo,
showHandle = false
)
}
}
Text(
text = formatQuotedTimestamp(event.created_at),
@@ -2000,7 +2013,7 @@ private fun formatQuotedTimestamp(epoch: Long): String {
seconds < 60 -> "${seconds}s"
minutes < 60 -> "${minutes}m"
hours < 24 -> "${hours}h"
days == 1L -> "yesterday"
days < 7 -> "${days}d"
else -> java.text.SimpleDateFormat("MMM d", java.util.Locale.US).format(java.util.Date(epoch * 1000))
}
}
@@ -0,0 +1,103 @@
package com.darkwisp.app.ui.component
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.darkwisp.app.viewmodel.thread.ThreadFlattener
import kotlin.math.min
/** Per-level indent step for threaded replies. */
private val INDENT_STEP: Dp = 16.dp
/**
* Start-padding indent for a reply at [depth], clamped to the thread depth cap so the rail
* never runs out of horizontal space.
*/
fun threadIndentDp(depth: Int, cap: Int = ThreadFlattener.DEPTH_CAP, step: Dp = INDENT_STEP): Dp =
step * min(depth, cap)
/**
* Depth connector: a single vertical rail into a rounded corner and a short horizontal run to
* the reply instead of one straight line per ancestor level. Drawn behind a row when [show]
* is true (depth > 0). [indent] is the row's start padding (from [threadIndentDp]); the rail
* lands one corner radius left of the card's padded edge so the arc's horizontal run meets the
* card edge exactly.
*
* When [dashedTop] is true the top of the rail is drawn dashed, signalling that the guide line
* continues upward to a parent that isn't the row directly above (e.g. the first reply of a
* branch, or a reply revealed by expanding a folded subtree) so the rail doesn't look like it
* starts "in mid-air."
*
* Shared by ThreadScreen and ArticleScreen so the two indent paths can't drift.
*/
fun Modifier.threadConnector(
show: Boolean,
indent: Dp,
lineColor: Color,
cornerRadius: Dp = 8.dp,
stroke: Dp = 1.dp,
dashedTop: Boolean = false,
dashLength: Dp = 14.dp
): Modifier = this.drawBehind {
if (!show) return@drawBehind
val r = cornerRadius.toPx()
val strokePx = stroke.toPx()
val lineX = indent.toPx() - r + strokePx
val railBottom = size.height - r
if (dashedTop && railBottom > 0f) {
val dashEnd = min(railBottom, dashLength.toPx())
val dashOn = (stroke * 3).toPx()
val dashOff = (stroke * 3).toPx()
drawIntoCanvas { canvas ->
val paint = Paint().apply {
color = lineColor
strokeWidth = strokePx
style = PaintingStyle.Stroke
pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashOn, dashOff), 0f)
isAntiAlias = true
}
canvas.drawLine(Offset(lineX, 0f), Offset(lineX, dashEnd), paint)
}
if (dashEnd < railBottom) {
drawLine(
color = lineColor,
start = Offset(lineX, dashEnd),
end = Offset(lineX, railBottom),
strokeWidth = strokePx
)
}
} else {
drawLine(
color = lineColor,
start = Offset(lineX, 0f),
end = Offset(lineX, railBottom),
strokeWidth = strokePx
)
}
drawArc(
color = lineColor,
startAngle = 90f,
sweepAngle = 90f,
useCenter = false,
topLeft = Offset(lineX, size.height - 2f * r),
size = Size(2f * r, 2f * r),
style = Stroke(width = strokePx, cap = StrokeCap.Round)
)
drawLine(
color = lineColor,
start = Offset(lineX + r, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokePx
)
}
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -64,8 +65,11 @@ import com.darkwisp.app.ui.component.ActionBar
import com.darkwisp.app.ui.component.NoteActions
import com.darkwisp.app.ui.component.PostCard
import com.darkwisp.app.ui.component.RichContent
import com.darkwisp.app.ui.component.CollapsedRepliesRow
import com.darkwisp.app.ui.component.threadConnector
import com.darkwisp.app.ui.component.threadIndentDp
import com.darkwisp.app.viewmodel.ArticleViewModel
import kotlin.math.min
import com.darkwisp.app.viewmodel.thread.ThreadItem
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -355,51 +359,73 @@ fun ArticleScreen(
}
// Comment items
items(comments.size, key = { "comment-${comments[it].first.id}" }) { index ->
val (event, depth) = comments[index]
val commentProfile = remember(profileVersion, event.pubkey) { eventRepo.getProfileData(event.pubkey) }
val commentLikeCount = remember(reactionVersion, event.id) { eventRepo.getReactionCount(event.id) }
val commentReplyCount = remember(replyCountVersion, event.id) { eventRepo.getReplyCount(event.id) }
val commentZapSats = remember(zapVersion, event.id) { eventRepo.getZapSats(event.id) }
val commentUserEmojis = remember(reactionVersion, event.id, userPubkey) {
userPubkey?.let { eventRepo.getUserReactionEmojis(event.id, it) } ?: emptySet()
items(items = comments, key = { "comment_${it.key}" }, contentType = { "comment" }) { item ->
if (item !is ThreadItem.Post) {
// Folded comment subtree — expand inline.
if (item is ThreadItem.CollapsedReplies) {
CollapsedRepliesRow(
item = item,
onExpand = { viewModel.expandBranch(item.anchor.id) },
modifier = Modifier.animateItem()
)
}
} else {
val event = item.event
val commentProfile = remember(profileVersion, event.pubkey) { eventRepo.getProfileData(event.pubkey) }
val commentLikeCount = remember(reactionVersion, event.id) { eventRepo.getReactionCount(event.id) }
val commentReplyCount = remember(replyCountVersion, event.id) { eventRepo.getReplyCount(event.id) }
val commentZapSats = remember(zapVersion, event.id) { eventRepo.getZapSats(event.id) }
val commentUserEmojis = remember(reactionVersion, event.id, userPubkey) {
userPubkey?.let { eventRepo.getUserReactionEmojis(event.id, it) } ?: emptySet()
}
val commentRepostCount = remember(repostVersion, event.id) { eventRepo.getRepostCount(event.id) }
val commentHasUserReposted = remember(repostVersion, event.id) { eventRepo.hasUserReposted(event.id) }
val commentHasUserZapped = remember(zapVersion, event.id) { eventRepo.hasUserZapped(event.id) }
val commentReactionEmojiUrls = remember(reactionVersion, event.id) { eventRepo.getReactionEmojiUrls(event.id) }
val indent = threadIndentDp(item.depth)
val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
PostCard(
event = event,
profile = commentProfile,
onReply = { onReply(event) },
onProfileClick = { onProfileClick(event.pubkey) },
onNavigateToProfile = onProfileClick,
onNoteClick = {},
onReact = { emoji -> onReact(event, emoji) },
userReactionEmojis = commentUserEmojis,
onRepost = { onRepost(event) },
onQuote = { onQuote(event) },
hasUserReposted = commentHasUserReposted,
repostCount = commentRepostCount,
onZap = { onZap(event) },
onZapLongPress = { onZapInstant(event) },
hasUserZapped = commentHasUserZapped,
likeCount = commentLikeCount,
replyCount = commentReplyCount,
zapSats = commentZapSats,
isZapAnimating = event.id in zapAnimatingIds,
isZapInProgress = event.id in zapInProgressIds,
eventRepo = eventRepo,
reactionEmojiUrls = commentReactionEmojiUrls,
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
isOwnEvent = event.pubkey == userPubkey,
onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds,
noteActions = noteActions,
modifier = Modifier
.fillMaxWidth()
.animateItem()
.threadConnector(
show = item.depth > 0,
indent = indent,
lineColor = lineColor,
dashedTop = item.connectorStartsMidAir
)
.padding(start = indent)
)
}
val commentRepostCount = remember(repostVersion, event.id) { eventRepo.getRepostCount(event.id) }
val commentHasUserReposted = remember(repostVersion, event.id) { eventRepo.hasUserReposted(event.id) }
val commentHasUserZapped = remember(zapVersion, event.id) { eventRepo.hasUserZapped(event.id) }
val commentReactionEmojiUrls = remember(reactionVersion, event.id) { eventRepo.getReactionEmojiUrls(event.id) }
PostCard(
event = event,
profile = commentProfile,
onReply = { onReply(event) },
onProfileClick = { onProfileClick(event.pubkey) },
onNavigateToProfile = onProfileClick,
onNoteClick = {},
onReact = { emoji -> onReact(event, emoji) },
userReactionEmojis = commentUserEmojis,
onRepost = { onRepost(event) },
onQuote = { onQuote(event) },
hasUserReposted = commentHasUserReposted,
repostCount = commentRepostCount,
onZap = { onZap(event) },
onZapLongPress = { onZapInstant(event) },
hasUserZapped = commentHasUserZapped,
likeCount = commentLikeCount,
replyCount = commentReplyCount,
zapSats = commentZapSats,
isZapAnimating = event.id in zapAnimatingIds,
isZapInProgress = event.id in zapInProgressIds,
eventRepo = eventRepo,
reactionEmojiUrls = commentReactionEmojiUrls,
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
isOwnEvent = event.pubkey == userPubkey,
onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds,
noteActions = noteActions,
modifier = Modifier.padding(start = (min(depth, 4) * 24).dp)
)
}
item(key = "footer") { Spacer(Modifier.height(32.dp)) }
@@ -2255,7 +2255,7 @@ private fun formatNotifTimestamp(epoch: Long): String {
if (hours < 24) return "${hours}h"
val days = diff / (24 * 60 * 60 * 1000L)
if (days == 1L) return "yesterday"
if (days < 7) return "${days}d"
val date = Date(millis)
val cal = java.util.Calendar.getInstance()
@@ -51,11 +51,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -68,11 +63,14 @@ import com.darkwisp.app.repo.Nip05Repository
import com.darkwisp.app.repo.RelayInfoRepository
import com.darkwisp.app.repo.TranslationRepository
import com.darkwisp.app.ui.component.NoteActions
import com.darkwisp.app.ui.component.CollapsedRepliesRow
import com.darkwisp.app.ui.component.GalleryCard
import com.darkwisp.app.ui.component.isGalleryEvent
import com.darkwisp.app.ui.component.PostCard
import com.darkwisp.app.ui.component.threadConnector
import com.darkwisp.app.ui.component.threadIndentDp
import com.darkwisp.app.viewmodel.ThreadViewModel
import kotlin.math.min
import com.darkwisp.app.viewmodel.thread.ThreadItem
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -131,6 +129,14 @@ fun ThreadScreen(
var showRootButton by remember { mutableStateOf(false) }
var previousIndex by remember { mutableIntStateOf(0) }
var previousOffset by remember { mutableIntStateOf(0) }
// When expanding a folded branch, hold the viewport steady on the note above the button:
// capture the top visible item + its scroll offset, then snap back to it once the subtree is
// inserted so the screen keeps its exact position.
var restoreAnchor by remember { mutableStateOf<Pair<Any, Int>?>(null) }
// The sticky reply bar targets whichever note is centered in the viewport, not always the root.
var focusedReplyEvent by remember { mutableStateOf<NostrEvent?>(null) }
val threadState = rememberUpdatedState(flatThread)
LaunchedEffect(listState.isScrollInProgress) {
if (listState.isScrollInProgress) {
@@ -164,6 +170,21 @@ fun ThreadScreen(
}
}
// After an inline expand inserts a subtree, restore the captured viewport position so the
// note above the button stays exactly where it was.
LaunchedEffect(restoreAnchor, flatThread) {
val (anchorKey, anchorOffset) = restoreAnchor ?: return@LaunchedEffect
val index = flatThread.indexOfFirst { it.key == anchorKey }
if (index >= 0) listState.scrollToItem(index, anchorOffset)
restoreAnchor = null
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }.collect { idx ->
focusedReplyEvent = (threadState.value.getOrNull(idx) as? ThreadItem.Post)?.event
}
}
val reactionVersion by eventRepo.reactionVersion.collectAsState()
val zapVersion by eventRepo.zapVersion.collectAsState()
val replyCountVersion by eventRepo.replyCountVersion.collectAsState()
@@ -217,7 +238,14 @@ fun ThreadScreen(
Toast.makeText(zapDisabledContext, zapDisabledMessage, Toast.LENGTH_SHORT).show()
}
val focalEvent = flatThread.firstOrNull()?.first
val expandBranch: (String) -> Unit = { anchorId ->
listState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { first ->
restoreAnchor = first.key to listState.firstVisibleItemScrollOffset
}
viewModel.expandBranch(anchorId)
}
val focalEvent = (flatThread.firstOrNull() as? ThreadItem.Post)?.event
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
@@ -234,9 +262,10 @@ fun ThreadScreen(
)
},
bottomBar = {
val replyTarget = focusedReplyEvent ?: focalEvent
ThreadReplyBar(
enabled = focalEvent != null,
onClick = { focalEvent?.let { onReply(it) } }
enabled = replyTarget != null,
onClick = { replyTarget?.let { onReply(it) } }
)
}
) { padding ->
@@ -255,187 +284,178 @@ fun ThreadScreen(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(items = flatThread, key = { it.first.id }, contentType = { "post" }) { (event, depth) ->
val profileData = eventRepo.getProfileData(event.pubkey)
val likeCount = reactionVersion.let { eventRepo.getReactionCount(event.id) }
val replyCount = replyCountVersion.let { eventRepo.getReplyCount(event.id) }
val zapSats = zapVersion.let { eventRepo.getZapSats(event.id) }
val userEmojis = reactionVersion.let { userPubkey?.let { eventRepo.getUserReactionEmojis(event.id, it) } ?: emptySet() }
val reactionDetails = reactionVersion.let { eventRepo.getReactionDetails(event.id) }
val zapDetailsList = zapVersion.let { eventRepo.getZapDetails(event.id) }
val repostCount = repostVersion.let { eventRepo.getRepostCount(event.id) }
val repostPubkeys = repostVersion.let { eventRepo.getReposterPubkeys(event.id) }
val hasUserReposted = repostVersion.let { eventRepo.hasUserReposted(event.id) }
val hasUserZapped = zapVersion.let { eventRepo.hasUserZapped(event.id) }
val eventReactionEmojiUrls = reactionVersion.let { eventRepo.getReactionEmojiUrls(event.id) }
val relayIcons = remember(relaySourceVersion, event.id) {
eventRepo.getEventRelays(event.id).map { url ->
url to relayInfoRepo?.getIconUrl(url)
items(items = flatThread, key = { it.key }, contentType = { it.contentType }) { item ->
if (item !is ThreadItem.Post) {
// Folded subtree — expand inline, anchoring the note above the button.
if (item is ThreadItem.CollapsedReplies) {
CollapsedRepliesRow(
item = item,
onExpand = { expandBranch(item.anchor.id) },
modifier = Modifier.animateItem()
)
}
}
val translationState = remember(translationVersion, event.id) {
translationRepo?.getState(event.id) ?: com.darkwisp.app.repo.TranslationState()
}
val pollVoteCounts = remember(pollVoteVersion, event.id) {
if (event.kind == 1068) eventRepo.getPollVoteCounts(event.id) else emptyMap()
}
val pollTotalVotes = remember(pollVoteVersion, event.id) {
if (event.kind == 1068) eventRepo.getPollTotalVotes(event.id) else 0
}
val userPollVotes = remember(pollVoteVersion, event.id) {
if (event.kind == 1068) eventRepo.getUserPollVotes(event.id) else emptyList()
}
val zapPollSatsCounts = remember(pollVoteVersion, event.id) {
if (event.kind == 6969) eventRepo.getZapPollSatsCounts(event.id) else emptyMap()
}
val zapPollTotalSats = remember(pollVoteVersion, event.id) {
if (event.kind == 6969) eventRepo.getZapPollTotalSats(event.id) else 0L
}
val userZapPollVote = remember(pollVoteVersion, event.id) {
if (event.kind == 6969) eventRepo.getUserZapPollVote(event.id) else null
}
val indentStepDp = 12.dp
val clampedDepth = min(depth, 5)
val cornerRadiusDp = 8.dp
val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
val showConnector = depth > 0
Box(
modifier = Modifier
.fillMaxWidth()
.drawBehind {
if (!showConnector) return@drawBehind
val lineX = (clampedDepth * indentStepDp.toPx()) - 8.dp.toPx() + 1.dp.toPx()
val r = cornerRadiusDp.toPx()
val strokePx = 1.dp.toPx()
drawLine(
color = lineColor,
start = Offset(lineX, 0f),
end = Offset(lineX, size.height - r),
strokeWidth = strokePx
} else {
val event = item.event
val depth = item.depth
val profileData = eventRepo.getProfileData(event.pubkey)
val likeCount = reactionVersion.let { eventRepo.getReactionCount(event.id) }
val replyCount = replyCountVersion.let { eventRepo.getReplyCount(event.id) }
val zapSats = zapVersion.let { eventRepo.getZapSats(event.id) }
val userEmojis = reactionVersion.let { userPubkey?.let { eventRepo.getUserReactionEmojis(event.id, it) } ?: emptySet() }
val reactionDetails = reactionVersion.let { eventRepo.getReactionDetails(event.id) }
val zapDetailsList = zapVersion.let { eventRepo.getZapDetails(event.id) }
val repostCount = repostVersion.let { eventRepo.getRepostCount(event.id) }
val repostPubkeys = repostVersion.let { eventRepo.getReposterPubkeys(event.id) }
val hasUserReposted = repostVersion.let { eventRepo.hasUserReposted(event.id) }
val hasUserZapped = zapVersion.let { eventRepo.hasUserZapped(event.id) }
val eventReactionEmojiUrls = reactionVersion.let { eventRepo.getReactionEmojiUrls(event.id) }
val relayIcons = remember(relaySourceVersion, event.id) {
eventRepo.getEventRelays(event.id).map { url ->
url to relayInfoRepo?.getIconUrl(url)
}
}
val translationState = remember(translationVersion, event.id) {
translationRepo?.getState(event.id) ?: com.darkwisp.app.repo.TranslationState()
}
val pollVoteCounts = remember(pollVoteVersion, event.id) {
if (event.kind == 1068) eventRepo.getPollVoteCounts(event.id) else emptyMap()
}
val pollTotalVotes = remember(pollVoteVersion, event.id) {
if (event.kind == 1068) eventRepo.getPollTotalVotes(event.id) else 0
}
val userPollVotes = remember(pollVoteVersion, event.id) {
if (event.kind == 1068) eventRepo.getUserPollVotes(event.id) else emptyList()
}
val zapPollSatsCounts = remember(pollVoteVersion, event.id) {
if (event.kind == 6969) eventRepo.getZapPollSatsCounts(event.id) else emptyMap()
}
val zapPollTotalSats = remember(pollVoteVersion, event.id) {
if (event.kind == 6969) eventRepo.getZapPollTotalSats(event.id) else 0L
}
val userZapPollVote = remember(pollVoteVersion, event.id) {
if (event.kind == 6969) eventRepo.getUserZapPollVote(event.id) else null
}
val indentPadding = threadIndentDp(depth)
val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
val showConnector = depth > 0
Box(
modifier = Modifier
.animateItem()
.fillMaxWidth()
.threadConnector(
show = showConnector,
indent = indentPadding,
lineColor = lineColor,
dashedTop = item.connectorStartsMidAir
)
drawArc(
color = lineColor,
startAngle = 90f,
sweepAngle = 90f,
useCenter = false,
topLeft = Offset(lineX, size.height - 2f * r),
size = Size(2f * r, 2f * r),
style = Stroke(width = strokePx, cap = StrokeCap.Round)
) {
if (isGalleryEvent(event)) {
GalleryCard(
event = event,
profile = profileData,
onReply = { onReply(event) },
onProfileClick = { onProfileClick(event.pubkey) },
onNavigateToProfile = onProfileClick,
onNoteClick = { onNoteClick(event) },
onReact = { emoji -> onReact(event, emoji) },
userReactionEmojis = userEmojis,
onRepost = { onRepost(event) },
onQuote = { onQuote(event) },
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = { onZap(event) },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
zapSats = zapSats,
isZapAnimating = event.id in zapAnimatingIds,
isZapInProgress = event.id in zapInProgressIds,
eventRepo = eventRepo,
reactionDetails = reactionDetails,
zapDetails = zapDetailsList,
repostDetails = repostPubkeys,
reactionEmojiUrls = eventReactionEmojiUrls,
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
relayIcons = relayIcons,
onNavigateToProfileFromDetails = onProfileClick,
onFollowAuthor = { onToggleFollow(event.pubkey) },
onBlockAuthor = { onBlockUser(event.pubkey) },
isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) },
isOwnEvent = event.pubkey == userPubkey,
onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds,
onPin = { onTogglePin(event.id) },
isPinned = event.id in pinnedIds,
onDelete = { onDeleteEvent(event.id, event.kind) },
nip05Repo = nip05Repo,
onQuotedNoteClick = onQuotedNoteClick,
noteActions = noteActions,
showDivider = !showConnector,
modifier = Modifier.padding(start = indentPadding)
)
drawLine(
color = lineColor,
start = Offset(lineX + r, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokePx
} else {
PostCard(
event = event,
profile = profileData,
onReply = { onReply(event) },
onProfileClick = { onProfileClick(event.pubkey) },
onNavigateToProfile = onProfileClick,
onNoteClick = { onNoteClick(event) },
onReact = { emoji -> onReact(event, emoji) },
userReactionEmojis = userEmojis,
onRepost = { onRepost(event) },
onQuote = { onQuote(event) },
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = { onZap(event) },
onZapLongPress = { onZapInstant(event) },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
zapSats = zapSats,
isZapAnimating = event.id in zapAnimatingIds,
isZapInProgress = event.id in zapInProgressIds,
eventRepo = eventRepo,
reactionDetails = reactionDetails,
zapDetails = zapDetailsList,
repostDetails = repostPubkeys,
reactionEmojiUrls = eventReactionEmojiUrls,
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
relayIcons = relayIcons,
onNavigateToProfileFromDetails = onProfileClick,
onFollowAuthor = { onToggleFollow(event.pubkey) },
onBlockAuthor = { onBlockUser(event.pubkey) },
isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) },
isOwnEvent = event.pubkey == userPubkey,
isPrivate = eventRepo.isPrivate(event.id),
zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event),
onZapDisabledTap = onZapDisabledTap,
onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds,
onPin = { onTogglePin(event.id) },
isPinned = event.id in pinnedIds,
onDelete = { onDeleteEvent(event.id, event.kind) },
nip05Repo = nip05Repo,
onQuotedNoteClick = onQuotedNoteClick,
noteActions = noteActions,
translationState = translationState,
onTranslate = { translationRepo?.translate(event.id, event.content) },
pollVoteCounts = pollVoteCounts,
pollTotalVotes = pollTotalVotes,
userPollVotes = userPollVotes,
onPollVote = { optionIds -> onPollVote(event.id, optionIds) },
zapPollSatsCounts = zapPollSatsCounts,
zapPollTotalSats = zapPollTotalSats,
userZapPollVote = userZapPollVote,
onZapPollVote = { idx -> onZapPollVote(event.id, idx) },
showDivider = !showConnector,
modifier = Modifier.padding(start = indentPadding)
)
}
) {
if (isGalleryEvent(event)) {
GalleryCard(
event = event,
profile = profileData,
onReply = { onReply(event) },
onProfileClick = { onProfileClick(event.pubkey) },
onNavigateToProfile = onProfileClick,
onNoteClick = { onNoteClick(event) },
onReact = { emoji -> onReact(event, emoji) },
userReactionEmojis = userEmojis,
onRepost = { onRepost(event) },
onQuote = { onQuote(event) },
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = { onZap(event) },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
zapSats = zapSats,
isZapAnimating = event.id in zapAnimatingIds,
isZapInProgress = event.id in zapInProgressIds,
eventRepo = eventRepo,
reactionDetails = reactionDetails,
zapDetails = zapDetailsList,
repostDetails = repostPubkeys,
reactionEmojiUrls = eventReactionEmojiUrls,
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
relayIcons = relayIcons,
onNavigateToProfileFromDetails = onProfileClick,
onFollowAuthor = { onToggleFollow(event.pubkey) },
onBlockAuthor = { onBlockUser(event.pubkey) },
isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) },
isOwnEvent = event.pubkey == userPubkey,
onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds,
onPin = { onTogglePin(event.id) },
isPinned = event.id in pinnedIds,
onDelete = { onDeleteEvent(event.id, event.kind) },
nip05Repo = nip05Repo,
onQuotedNoteClick = onQuotedNoteClick,
noteActions = noteActions,
showDivider = !showConnector,
modifier = Modifier.padding(start = (clampedDepth * indentStepDp.value).dp)
)
} else {
PostCard(
event = event,
profile = profileData,
onReply = { onReply(event) },
onProfileClick = { onProfileClick(event.pubkey) },
onNavigateToProfile = onProfileClick,
onNoteClick = { onNoteClick(event) },
onReact = { emoji -> onReact(event, emoji) },
userReactionEmojis = userEmojis,
onRepost = { onRepost(event) },
onQuote = { onQuote(event) },
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = { onZap(event) },
onZapLongPress = { onZapInstant(event) },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
zapSats = zapSats,
isZapAnimating = event.id in zapAnimatingIds,
isZapInProgress = event.id in zapInProgressIds,
eventRepo = eventRepo,
reactionDetails = reactionDetails,
zapDetails = zapDetailsList,
repostDetails = repostPubkeys,
reactionEmojiUrls = eventReactionEmojiUrls,
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
relayIcons = relayIcons,
onNavigateToProfileFromDetails = onProfileClick,
onFollowAuthor = { onToggleFollow(event.pubkey) },
onBlockAuthor = { onBlockUser(event.pubkey) },
isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) },
isOwnEvent = event.pubkey == userPubkey,
isPrivate = eventRepo.isPrivate(event.id),
zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event),
onZapDisabledTap = onZapDisabledTap,
onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds,
onPin = { onTogglePin(event.id) },
isPinned = event.id in pinnedIds,
onDelete = { onDeleteEvent(event.id, event.kind) },
nip05Repo = nip05Repo,
onQuotedNoteClick = onQuotedNoteClick,
noteActions = noteActions,
translationState = translationState,
onTranslate = { translationRepo?.translate(event.id, event.content) },
pollVoteCounts = pollVoteCounts,
pollTotalVotes = pollTotalVotes,
userPollVotes = userPollVotes,
onPollVote = { optionIds -> onPollVote(event.id, optionIds) },
zapPollSatsCounts = zapPollSatsCounts,
zapPollTotalSats = zapPollTotalSats,
userZapPollVote = userZapPollVote,
onZapPollVote = { idx -> onZapPollVote(event.id, idx) },
showDivider = !showConnector,
modifier = Modifier.padding(start = (clampedDepth * indentStepDp.value).dp)
)
}
}
}
@@ -15,6 +15,8 @@ import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.MetadataFetcher
import com.darkwisp.app.repo.RelayHintStore
import com.darkwisp.app.repo.RelayListRepository
import com.darkwisp.app.viewmodel.thread.ThreadFlattener
import com.darkwisp.app.viewmodel.thread.ThreadItem
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -45,13 +47,16 @@ class ArticleViewModel : ViewModel() {
val definedKinds: StateFlow<List<CustomNip.DefinedKind>> = _definedKinds
// Comments
private val _comments = MutableStateFlow<List<Pair<NostrEvent, Int>>>(emptyList())
val comments: StateFlow<List<Pair<NostrEvent, Int>>> = _comments
private val _comments = MutableStateFlow<List<ThreadItem>>(emptyList())
val comments: StateFlow<List<ThreadItem>> = _comments
private val _isCommentsLoading = MutableStateFlow(false)
val isCommentsLoading: StateFlow<Boolean> = _isCommentsLoading
private val commentEvents = mutableMapOf<String, NostrEvent>()
private var currentArticleEventId: String? = null
/** Anchors whose folded comment subtree the user expanded inline. */
private val expandedIds = mutableSetOf<String>()
private var collectorJob: Job? = null
private var engagementCollectorJob: Job? = null
private var rebuildJob: Job? = null
@@ -111,6 +116,7 @@ class ArticleViewModel : ViewModel() {
this.topRelayUrls = topRelayUrls
this.relayListRepoRef = relayListRepo
this.relayHintStoreRef = relayHintStore
this.currentArticleEventId = articleEventId
_isCommentsLoading.value = true
val coordinate = "$kind:$author:$dTag"
@@ -335,34 +341,25 @@ class ArticleViewModel : ViewModel() {
children.sortBy { it.created_at }
}
val result = mutableListOf<Pair<NostrEvent, Int>>()
val visited = mutableSetOf<String>()
val rootChildren = parentToChildren["root"] ?: emptyList()
for (child in rootChildren) {
if (child.id in visited) continue
visited.add(child.id)
result.add(child to 0)
dfs(child.id, 1, parentToChildren, result, visited)
}
_comments.value = result
// Article comments share note threads' progressive disclosure: deeper-than-cap
// subtrees fold behind an inline "Show N more replies" affordance (expandBranch).
_comments.value = ThreadFlattener.flatten(
rootId = "root",
rootEvent = null,
parentToChildren = parentToChildren,
expandedIds = expandedIds,
// Fan-out ("show more replies") cap stays disabled for article comments.
maxSiblingsInline = Int.MAX_VALUE
)
}
private fun dfs(
parentId: String,
depth: Int,
parentToChildren: Map<String, List<NostrEvent>>,
result: MutableList<Pair<NostrEvent, Int>>,
visited: MutableSet<String>
) {
val children = parentToChildren[parentId] ?: return
for (child in children) {
if (child.id in visited) continue
visited.add(child.id)
result.add(child to depth)
dfs(child.id, depth + 1, parentToChildren, result, visited)
}
/** Expand a folded comment subtree inline. */
fun expandBranch(anchorId: String) {
if (expandedIds.add(anchorId)) rebuildTree(currentArticleEventId)
}
fun collapseBranch(anchorId: String) {
if (expandedIds.remove(anchorId)) rebuildTree(currentArticleEventId)
}
private fun parseAndEmit(event: NostrEvent) {
@@ -20,6 +20,8 @@ import com.darkwisp.app.repo.RelayHintStore
import com.darkwisp.app.repo.RelayListRepository
import com.darkwisp.app.repo.SafetyPreferences
import com.darkwisp.app.repo.SpamAuthorCache
import com.darkwisp.app.viewmodel.thread.ThreadFlattener
import com.darkwisp.app.viewmodel.thread.ThreadItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -32,8 +34,8 @@ class ThreadViewModel : ViewModel() {
private val _rootEvent = MutableStateFlow<NostrEvent?>(null)
val rootEvent: StateFlow<NostrEvent?> = _rootEvent
private val _flatThread = MutableStateFlow<List<Pair<NostrEvent, Int>>>(emptyList())
val flatThread: StateFlow<List<Pair<NostrEvent, Int>>> = _flatThread
private val _flatThread = MutableStateFlow<List<ThreadItem>>(emptyList())
val flatThread: StateFlow<List<ThreadItem>> = _flatThread
private val _isLoading = MutableStateFlow(true)
val isLoading: StateFlow<Boolean> = _isLoading
@@ -50,6 +52,8 @@ class ThreadViewModel : ViewModel() {
private val threadEvents = mutableMapOf<String, NostrEvent>()
private var rootId: String = ""
private var scrollTargetId: String? = null
/** Anchors whose depth-capped subtree the user expanded inline. */
private val expandedIds = mutableSetOf<String>()
private var muteRepo: MuteRepository? = null
private val activeMetadataSubs = mutableListOf<String>()
private var relayPoolRef: RelayPool? = null
@@ -93,6 +97,15 @@ class ThreadViewModel : ViewModel() {
scheduleRebuild()
}
/** Expand a folded subtree inline. Scroll-position anchoring is handled in the screen. */
fun expandBranch(anchorId: String) {
if (expandedIds.add(anchorId)) rebuildTree()
}
fun collapseBranch(anchorId: String) {
if (expandedIds.remove(anchorId)) rebuildTree()
}
fun loadThread(
eventId: String,
eventRepo: EventRepository,
@@ -467,53 +480,27 @@ class ThreadViewModel : ViewModel() {
}
for (children in parentToChildren.values) {
children.sortWith(Comparator { a, b ->
a.created_at.compareTo(b.created_at)
})
children.sortBy { it.created_at }
}
val result = mutableListOf<Pair<NostrEvent, Int>>()
val visited = mutableSetOf<String>()
val root = threadEvents[rootId]
if (root != null) {
result.add(root to 0)
visited.add(root.id)
dfs(rootId, 1, parentToChildren, result, visited)
} else {
// Root not yet loaded — render replies we have
val rootChildren = parentToChildren[rootId] ?: emptyList()
for (child in rootChildren) {
if (child.id in visited) continue
visited.add(child.id)
result.add(child to 0)
dfs(child.id, 1, parentToChildren, result, visited)
}
}
_flatThread.value = result
val flattened = ThreadFlattener.flatten(
rootId = rootId,
rootEvent = root,
parentToChildren = parentToChildren,
expandedIds = expandedIds,
scrollTargetId = scrollTargetId,
// Fan-out ("show more replies") cap lands with its UI toggle in a follow-up.
maxSiblingsInline = Int.MAX_VALUE
)
_flatThread.value = flattened
val targetId = scrollTargetId
if (targetId != null) {
val index = result.indexOfFirst { it.first.id == targetId }
val index = flattened.indexOfFirst { it is ThreadItem.Post && it.event.id == targetId }
if (index >= 0) {
_scrollToIndex.value = index
}
}
}
private fun dfs(
parentId: String,
depth: Int,
parentToChildren: Map<String, List<NostrEvent>>,
result: MutableList<Pair<NostrEvent, Int>>,
visited: MutableSet<String>
) {
val children = parentToChildren[parentId] ?: return
for (child in children) {
if (child.id in visited) continue
visited.add(child.id)
result.add(child to depth)
dfs(child.id, depth + 1, parentToChildren, result, visited)
}
}
}
@@ -0,0 +1,202 @@
package com.darkwisp.app.viewmodel.thread
import com.darkwisp.app.nostr.NostrEvent
/**
* Pure, side-effect-free flattener that turns a parentchildren reply tree into a
* [ThreadItem] list for the UI. Shared by [com.darkwisp.app.viewmodel.ThreadViewModel] and
* [com.darkwisp.app.viewmodel.ArticleViewModel] so the two paths can't drift.
*
* Progressive disclosure (so long/deep threads stay legible "who is replying to whom"):
* - **Depth cap**: replies deeper than [DEPTH_CAP] levels fold behind a [ThreadItem.CollapsedReplies]
* affordance that the user expands *inline* (scroll position preserved) rather than
* navigating away. Putting the anchor in [expandedIds] overrides the cap for that branch.
* - **Collapsible branches**: a node in [collapsedIds] emits only itself; its descendants fold
* into the "+N replies" affordance the UI renders from [ThreadItem.Post.descendantCount].
* - **High fan-out**: a parent with more than [maxSiblingsInline] direct replies shows the first
* few then a [ThreadItem.ShowMoreReplies] affordance (unless it is in [expandedFanOut]).
*
* The [scrollTargetId] path (the note to scroll to, plus all its ancestors) is exempt from
* collapse and the depth cap, so a freshly-published reply is always reachable/visible.
*
* All inputs are assumed already cleaned (deletions/blocked/spam filtered) and chronologically
* sorted within each sibling group done by the caller's `rebuildTree`.
*/
object ThreadFlattener {
/** Root is depth 0; replies at depths 1..DEPTH_CAP render as posts; deeper replies fold. */
const val DEPTH_CAP = 3
/** Default direct-replies shown inline before a "show more" affordance appears under a parent. */
const val MAX_SIBLINGS_INLINE = 4
fun flatten(
rootId: String,
rootEvent: NostrEvent?,
parentToChildren: Map<String, List<NostrEvent>>,
collapsedIds: Set<String> = emptySet(),
expandedIds: Set<String> = emptySet(),
expandedFanOut: Set<String> = emptySet(),
scrollTargetId: String? = null,
maxSiblingsInline: Int = MAX_SIBLINGS_INLINE,
depthCap: Int = DEPTH_CAP
): List<ThreadItem> {
val subtreeSizes = computeSubtreeSizes(parentToChildren)
val pathToTarget = scrollTargetId?.let { ancestorsOf(it, parentToChildren) } ?: emptySet()
val raw = mutableListOf<ThreadItem>()
val visited = HashSet<String>()
if (rootEvent != null) {
visited.add(rootEvent.id)
raw.add(
ThreadItem.Post(
event = rootEvent,
depth = 0,
descendantCount = subtreeSizes[rootEvent.id] ?: 0,
collapsed = false
)
)
walk(rootEvent, 0, parentToChildren, subtreeSizes, collapsedIds, expandedIds, expandedFanOut, pathToTarget, visited, raw, maxSiblingsInline, depthCap, false)
} else {
// Root not yet loaded — render the top-level replies we have, each as a depth-0 root.
for (child in parentToChildren[rootId].orEmpty()) {
if (child.id in visited) continue
visited.add(child.id)
val collapsed = child.id in collapsedIds && child.id !in pathToTarget
raw.add(
ThreadItem.Post(
event = child,
depth = 0,
descendantCount = subtreeSizes[child.id] ?: 0,
collapsed = collapsed
)
)
walk(child, 0, parentToChildren, subtreeSizes, collapsedIds, expandedIds, expandedFanOut, pathToTarget, visited, raw, maxSiblingsInline, depthCap, false)
}
}
return applyConnectorFlags(raw)
}
private fun walk(
parentEvent: NostrEvent,
parentDepth: Int,
parentToChildren: Map<String, List<NostrEvent>>,
subtreeSizes: Map<String, Int>,
collapsedIds: Set<String>,
expandedIds: Set<String>,
expandedFanOut: Set<String>,
pathToTarget: Set<String>,
visited: HashSet<String>,
result: MutableList<ThreadItem>,
maxSiblingsInline: Int,
depthCap: Int,
insideExpanded: Boolean
) {
val children = parentToChildren[parentEvent.id] ?: return
val childDepth = parentDepth + 1
val fanOut = children.size > maxSiblingsInline && parentEvent.id !in expandedFanOut
val visibleChildren = if (fanOut) children.take(maxSiblingsInline) else children
for (child in visibleChildren) {
if (child.id in visited) continue
visited.add(child.id)
val collapsed = child.id in collapsedIds && child.id !in pathToTarget
val hasChildren = !parentToChildren[child.id].isNullOrEmpty()
result.add(
ThreadItem.Post(
event = child,
depth = childDepth,
descendantCount = subtreeSizes[child.id] ?: 0,
collapsed = collapsed
)
)
// Fold the subtree when capped (unless the user expanded this branch, or it's on the
// scroll-to-reply path). Expanded branches descend normally and the cap reapplies
// one level deeper, so expansion is progressive.
val capHere = childDepth >= depthCap &&
child.id !in pathToTarget &&
child.id !in expandedIds &&
!insideExpanded &&
hasChildren
when {
collapsed || (capHere && !hasChildren) -> {
// Descendants hidden — the "+N replies" affordance renders on the Post row.
}
capHere -> {
result.add(
ThreadItem.CollapsedReplies(
anchor = child,
depth = childDepth + 1,
hiddenCount = subtreeSizes[child.id] ?: 0
)
)
}
else -> walk(child, childDepth, parentToChildren, subtreeSizes, collapsedIds, expandedIds, expandedFanOut, pathToTarget, visited, result, maxSiblingsInline, depthCap, insideExpanded || child.id in expandedIds)
}
}
if (fanOut) {
result.add(
ThreadItem.ShowMoreReplies(
parent = parentEvent,
depth = childDepth,
hiddenCount = children.size - maxSiblingsInline
)
)
}
}
/**
* Marks each [ThreadItem.Post]'s rail as "starting in mid-air" when the row immediately above
* (ignoring affordance rows) is not a Post at the same depth i.e. the depth-guide spine is
* broken, so the rail's top would otherwise float. The UI dashes the top of such rails to
* signal they continue upward to the parent.
*/
private fun applyConnectorFlags(items: List<ThreadItem>): List<ThreadItem> {
var prevDepth = -1
return items.map { item ->
if (item is ThreadItem.Post) {
val midAir = item.depth > 0 && prevDepth != item.depth
prevDepth = item.depth
if (midAir == item.connectorStartsMidAir) item else item.copy(connectorStartsMidAir = midAir)
} else item
}
}
/** Total descendants of each node (excluding the node itself), cycle-guarded. */
private fun computeSubtreeSizes(parentToChildren: Map<String, List<NostrEvent>>): Map<String, Int> {
val sizes = HashMap<String, Int>()
val visiting = HashSet<String>()
fun sizeOf(id: String): Int {
sizes[id]?.let { return it }
visiting.add(id)
var total = 0
for (child in parentToChildren[id].orEmpty()) {
if (child.id in visiting) continue // cycle guard
total += 1 + sizeOf(child.id)
}
visiting.remove(id)
sizes[id] = total
return total
}
for (id in parentToChildren.keys) sizeOf(id)
return sizes
}
/** [targetId] plus all of its ancestors (up to a missing parent or a cycle). Used to keep
* the scroll-to-reply path expanded through collapse/depth-cap. */
private fun ancestorsOf(targetId: String, parentToChildren: Map<String, List<NostrEvent>>): Set<String> {
val childToParent = HashMap<String, String>()
for ((parentId, children) in parentToChildren) {
for (child in children) {
if (child.id !in childToParent) childToParent[child.id] = parentId
}
}
val result = LinkedHashSet<String>()
var cur: String? = targetId
while (cur != null && result.add(cur)) {
cur = childToParent[cur]
}
return result
}
}
@@ -0,0 +1,56 @@
package com.darkwisp.app.viewmodel.thread
import com.darkwisp.app.nostr.NostrEvent
/**
* One row in a flattened reply thread. Replaces the old `Pair<NostrEvent, Int>` so the list
* can also carry synthetic progressive-disclosure rows (a folded subtree that expands inline)
* with stable LazyColumn keys.
*
* Each variant carries its own [depth] so the UI's indent + connector math is uniform, plus
* a stable [key] and a distinct [contentType] (so Compose pools synthetic rows separately
* from full PostCard rows).
*/
sealed interface ThreadItem {
val depth: Int
val key: Any
val contentType: String
/** A real note row.
* - [descendantCount]: full subtree size under this note (used for "+N replies" affordances).
* - [connectorStartsMidAir]: true when this note's depth-guide rail has no same-depth Post
* immediately above it in the rendered list i.e. the rail's top would "start in mid-air"
* rather than continuing a visible spine. The UI dashes the top of the rail in that case. */
data class Post(
val event: NostrEvent,
override val depth: Int,
val descendantCount: Int,
val collapsed: Boolean,
val connectorStartsMidAir: Boolean = false
) : ThreadItem {
override val key: Any get() = event.id
override val contentType: String get() = "post"
}
/** A folded subtree under [anchor] that the user can expand inline, keeping scroll
* position anchored on the note above. [hiddenCount] is the subtree size. */
data class CollapsedReplies(
val anchor: NostrEvent,
override val depth: Int,
val hiddenCount: Int
) : ThreadItem {
override val key: Any get() = "collapsed_${anchor.id}"
override val contentType: String get() = "collapsed"
}
/** High-fan-out cap: [hiddenCount] sibling replies under [parent] are folded away. Tapping
* expands them inline. */
data class ShowMoreReplies(
val parent: NostrEvent,
override val depth: Int,
val hiddenCount: Int
) : ThreadItem {
override val key: Any get() = "more_${parent.id}"
override val contentType: String get() = "more"
}
}
+4
View File
@@ -551,6 +551,10 @@
<string name="thread_tap_to_show">Show</string>
<string name="thread_tap_to_hide">Hide</string>
<string name="thread_not_spam">Not spam</string>
<plurals name="thread_continue">
<item quantity="one">Show %1$d more reply</item>
<item quantity="other">Show %1$d more replies</item>
</plurals>
<string name="settings_copy_profile_json">Copy Profile JSON</string>
<string name="settings_profile_json_copied">Profile JSON copied</string>
@@ -0,0 +1,108 @@
package com.darkwisp.app.viewmodel.thread
import com.darkwisp.app.nostr.NostrEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure-logic tests for [ThreadFlattener] the progressive-disclosure flattener that is now the
* heart of the thread view (depth cap, inline expand, scroll-to-reply path exemption, cycle
* safety, and the "starts in mid-air" connector flag). The flattener consumes an already-parsed
* parentchildren map, so these tests build synthetic trees directly no NIP-10 wiring needed.
*/
class ThreadFlattenerTest {
private fun ev(id: String) = NostrEvent(id, "pk", 0L, 1, emptyList(), "", "")
private fun tree(vararg edges: Pair<String, List<String>>): Map<String, List<NostrEvent>> {
val m = HashMap<String, List<NostrEvent>>()
for ((parent, kids) in edges) m[parent] = kids.map(::ev)
return m
}
private fun posts(items: List<ThreadItem>) = items.filterIsInstance<ThreadItem.Post>()
private fun collapsed(items: List<ThreadItem>) = items.filterIsInstance<ThreadItem.CollapsedReplies>()
private val deepChain = tree(
"R" to listOf("A"), "A" to listOf("B"), "B" to listOf("C"),
"C" to listOf("D"), "D" to listOf("E")
)
@Test
fun depthCap_foldsDeepSubtree_behindCollapsedAffordance() {
val out = ThreadFlattener.flatten("R", ev("R"), deepChain)
val p = posts(out)
assertEquals(listOf("R", "A", "B", "C"), p.map { it.event.id })
assertEquals(listOf(0, 1, 2, 3), p.map { it.depth })
val c = collapsed(out)
assertEquals(1, c.size)
assertEquals("C", c[0].anchor.id)
assertEquals(2, c[0].hiddenCount) // D + E folded
assertNull(p.firstOrNull { it.event.id == "D" || it.event.id == "E" })
}
@Test
fun expand_revealsEntireSubtree_atOnce() {
// Tapping "N more replies" must reveal the whole hidden subtree at once, not one level
// at a time. C's subtree is D -> E (hiddenCount 2); expanding C shows both D and E.
val out = ThreadFlattener.flatten("R", ev("R"), deepChain, expandedIds = setOf("C"))
val p = posts(out)
assertTrue(p.any { it.event.id == "D" })
assertTrue(p.any { it.event.id == "E" })
assertTrue(collapsed(out).isEmpty()) // no re-cap inside the expanded subtree
}
@Test
fun scrollTargetPath_staysVisibleThroughCap() {
// E is depth 5 — normally capped — but it (and its ancestors) are exempt so a freshly
// published reply is always reachable/visible.
val out = ThreadFlattener.flatten("R", ev("R"), deepChain, scrollTargetId = "E")
val p = posts(out)
assertTrue(p.any { it.event.id == "E" })
assertEquals(5, p.last().depth)
assertTrue(collapsed(out).isEmpty())
}
@Test
fun cycle_doesNotInfiniteLoop() {
val out = ThreadFlattener.flatten("A", ev("A"), tree("A" to listOf("B"), "B" to listOf("A")))
assertEquals(listOf("A", "B"), posts(out).map { it.event.id })
}
@Test
fun collapsedBranch_hidesSubtree_andFlagsThePost() {
val out = ThreadFlattener.flatten(
"R", ev("R"),
tree("R" to listOf("A", "B"), "A" to listOf("A1")),
collapsedIds = setOf("A")
)
val p = posts(out)
assertTrue(p.any { it.event.id == "A" && it.collapsed })
assertFalse(p.any { it.event.id == "A1" })
}
@Test
fun connectorFlag_dashesOrphanedTops_butNotContinuedSiblingSpines() {
val out = ThreadFlattener.flatten("R", ev("R"), tree("R" to listOf("A", "B")))
val p = posts(out)
val a = p.first { it.event.id == "A" }
val b = p.first { it.event.id == "B" }
assertTrue(a.connectorStartsMidAir) // first reply: spine broken above
assertFalse(b.connectorStartsMidAir) // sibling immediately below: spine continues
}
@Test
fun rootNull_rendersTopLevelRepliesAsDepthZeroRoots() {
val out = ThreadFlattener.flatten(
"R", null,
tree("R" to listOf("A", "B"), "A" to listOf("A1"))
)
val p = posts(out)
assertEquals("A", p[0].event.id)
assertEquals(0, p[0].depth)
assertTrue(p.any { it.event.id == "A1" && it.depth == 1 })
}
}