Merge pull request #55 from dmnyc/feat/collapsible-threads
feat: collapsible reply threads + iOS-parity UI polish
This commit is contained in:
@@ -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 parent→children 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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* parent→children 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user