From 2a8f0cb0287c5b67828d2121fa4a49222c9b2177 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Mon, 20 Jul 2026 14:42:09 -0400 Subject: [PATCH] feat: collapsible reply threads + iOS-parity UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports wisp #622 (collapsible threads) and #624 (iOS-parity timestamps, NIP-05 badge, follow badge) to dark-wisp. Collapsible threads: - Typed ThreadItem model + pure, unit-tested ThreadFlattener replace the flat Pair list, shared by note threads and article comments. - Replies deeper than 3 levels fold behind a "Show N more replies" affordance that expands inline, holding the viewport on the note above. - Shared threadIndentDp/threadConnector draw a single L-shaped depth-guide rail; orphaned rails dash their top. - Sticky "Reply…" bar targets the note at the top of the viewport. - Reply order purely oldest-first; iOS-style "Replying to X" above the commenter row, de-linked and secondary-colored; NIP-05 badge hidden on reply rows. iOS-parity polish: - Timestamps: compact "1d".."6d" instead of "yesterday". - NIP-05 badge: scalloped Verified seal, icon-only next to the username everywhere (handle text reserved for the profile screen); added to quoted-note previews too. - Follow badge: orange circle with a mode-adaptive checkmark (black in dark mode, white in light mode). --- .../app/ui/component/CollapsedRepliesRow.kt | 58 +++ .../darkwisp/app/ui/component/GalleryCard.kt | 36 +- .../com/darkwisp/app/ui/component/PostCard.kt | 104 +++-- .../app/ui/component/ProfilePicture.kt | 7 +- .../darkwisp/app/ui/component/RichContent.kt | 19 +- .../darkwisp/app/ui/component/ThreadIndent.kt | 103 +++++ .../darkwisp/app/ui/screen/ArticleScreen.kt | 116 ++++-- .../app/ui/screen/NotificationsScreen.kt | 2 +- .../darkwisp/app/ui/screen/ThreadScreen.kt | 390 +++++++++--------- .../app/viewmodel/ArticleViewModel.kt | 53 ++- .../darkwisp/app/viewmodel/ThreadViewModel.kt | 67 ++- .../app/viewmodel/thread/ThreadFlattener.kt | 202 +++++++++ .../app/viewmodel/thread/ThreadItem.kt | 56 +++ app/src/main/res/values/strings.xml | 4 + .../viewmodel/thread/ThreadFlattenerTest.kt | 108 +++++ 15 files changed, 971 insertions(+), 354 deletions(-) create mode 100644 app/src/main/kotlin/com/darkwisp/app/ui/component/CollapsedRepliesRow.kt create mode 100644 app/src/main/kotlin/com/darkwisp/app/ui/component/ThreadIndent.kt create mode 100644 app/src/main/kotlin/com/darkwisp/app/viewmodel/thread/ThreadFlattener.kt create mode 100644 app/src/main/kotlin/com/darkwisp/app/viewmodel/thread/ThreadItem.kt create mode 100644 app/src/test/kotlin/com/darkwisp/app/viewmodel/thread/ThreadFlattenerTest.kt diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/CollapsedRepliesRow.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/CollapsedRepliesRow.kt new file mode 100644 index 0000000..559133c --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/CollapsedRepliesRow.kt @@ -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 + ) + } +} diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/GalleryCard.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/GalleryCard.kt index 31293af..5758bbc 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/GalleryCard.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/GalleryCard.kt @@ -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() diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt index 3ec47f5..b054779 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt @@ -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) diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/ProfilePicture.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/ProfilePicture.kt index 142e017..078de35 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/ProfilePicture.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/ProfilePicture.kt @@ -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) ) } diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/RichContent.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/RichContent.kt index 3311929..16ee178 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/RichContent.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/RichContent.kt @@ -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)) } } diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/ThreadIndent.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/ThreadIndent.kt new file mode 100644 index 0000000..f8f3817 --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/ThreadIndent.kt @@ -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 + ) +} diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ArticleScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ArticleScreen.kt index 66565c1..c0e3cc5 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ArticleScreen.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ArticleScreen.kt @@ -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)) } diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt index 677befe..da2bfb8 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt @@ -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() diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt index 93459f5..f5129ab 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt @@ -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?>(null) } + + // The sticky reply bar targets whichever note is centered in the viewport, not always the root. + var focusedReplyEvent by remember { mutableStateOf(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) - ) } } } diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ArticleViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ArticleViewModel.kt index 4f60741..c286f60 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ArticleViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ArticleViewModel.kt @@ -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> = _definedKinds // Comments - private val _comments = MutableStateFlow>>(emptyList()) - val comments: StateFlow>> = _comments + private val _comments = MutableStateFlow>(emptyList()) + val comments: StateFlow> = _comments private val _isCommentsLoading = MutableStateFlow(false) val isCommentsLoading: StateFlow = _isCommentsLoading private val commentEvents = mutableMapOf() + private var currentArticleEventId: String? = null + /** Anchors whose folded comment subtree the user expanded inline. */ + private val expandedIds = mutableSetOf() 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>() - val visited = mutableSetOf() - - 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>, - result: MutableList>, - visited: MutableSet - ) { - 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) { diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ThreadViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ThreadViewModel.kt index de939c2..4379653 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ThreadViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ThreadViewModel.kt @@ -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(null) val rootEvent: StateFlow = _rootEvent - private val _flatThread = MutableStateFlow>>(emptyList()) - val flatThread: StateFlow>> = _flatThread + private val _flatThread = MutableStateFlow>(emptyList()) + val flatThread: StateFlow> = _flatThread private val _isLoading = MutableStateFlow(true) val isLoading: StateFlow = _isLoading @@ -50,6 +52,8 @@ class ThreadViewModel : ViewModel() { private val threadEvents = mutableMapOf() private var rootId: String = "" private var scrollTargetId: String? = null + /** Anchors whose depth-capped subtree the user expanded inline. */ + private val expandedIds = mutableSetOf() private var muteRepo: MuteRepository? = null private val activeMetadataSubs = mutableListOf() 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>() - val visited = mutableSetOf() 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>, - result: MutableList>, - visited: MutableSet - ) { - 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) - } - } } diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/thread/ThreadFlattener.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/thread/ThreadFlattener.kt new file mode 100644 index 0000000..850bdee --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/thread/ThreadFlattener.kt @@ -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>, + collapsedIds: Set = emptySet(), + expandedIds: Set = emptySet(), + expandedFanOut: Set = emptySet(), + scrollTargetId: String? = null, + maxSiblingsInline: Int = MAX_SIBLINGS_INLINE, + depthCap: Int = DEPTH_CAP + ): List { + val subtreeSizes = computeSubtreeSizes(parentToChildren) + val pathToTarget = scrollTargetId?.let { ancestorsOf(it, parentToChildren) } ?: emptySet() + val raw = mutableListOf() + val visited = HashSet() + + 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>, + subtreeSizes: Map, + collapsedIds: Set, + expandedIds: Set, + expandedFanOut: Set, + pathToTarget: Set, + visited: HashSet, + result: MutableList, + 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): List { + 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>): Map { + val sizes = HashMap() + val visiting = HashSet() + + 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>): Set { + val childToParent = HashMap() + for ((parentId, children) in parentToChildren) { + for (child in children) { + if (child.id !in childToParent) childToParent[child.id] = parentId + } + } + val result = LinkedHashSet() + var cur: String? = targetId + while (cur != null && result.add(cur)) { + cur = childToParent[cur] + } + return result + } +} diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/thread/ThreadItem.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/thread/ThreadItem.kt new file mode 100644 index 0000000..0826cc8 --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/thread/ThreadItem.kt @@ -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` 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" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index edaba24..373ff04 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -551,6 +551,10 @@ Show Hide Not spam + + Show %1$d more reply + Show %1$d more replies + Copy Profile JSON Profile JSON copied diff --git a/app/src/test/kotlin/com/darkwisp/app/viewmodel/thread/ThreadFlattenerTest.kt b/app/src/test/kotlin/com/darkwisp/app/viewmodel/thread/ThreadFlattenerTest.kt new file mode 100644 index 0000000..bc4ea03 --- /dev/null +++ b/app/src/test/kotlin/com/darkwisp/app/viewmodel/thread/ThreadFlattenerTest.kt @@ -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>): Map> { + val m = HashMap>() + for ((parent, kids) in edges) m[parent] = kids.map(::ev) + return m + } + + private fun posts(items: List) = items.filterIsInstance() + private fun collapsed(items: List) = items.filterIsInstance() + + 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 }) + } +}