feat: show hidden reply count on collapsed thread cards

A collapsed reply now displays "+N replies" on the right side, counting the
descendant replies hidden underneath it, next to the expand indicator. The
count is computed in the same depth-first pass that builds the visible list.

Uses a <plurals> resource so the noun declines correctly across locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
This commit is contained in:
Claude
2026-06-18 20:07:35 +00:00
parent 0c4b4a9902
commit 9a577e13b8
2 changed files with 44 additions and 8 deletions
@@ -60,6 +60,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -367,33 +368,42 @@ fun RenderThreadFeed(
val items by loaded.feed.collectAsStateWithLifecycle()
val levels by viewModel.levelCacheFlow.collectAsStateWithLifecycle()
// Hides every descendant of a collapsed reply. The feed is ordered depth-first, so a note's
// descendants are the contiguous items that follow it with a strictly deeper reply level.
val visibleItems by
// Hides every descendant of a collapsed reply and counts how many were hidden. The feed is
// ordered depth-first, so a note's descendants are the contiguous items that follow it with a
// strictly deeper reply level.
val visible by
remember(items, levels) {
derivedStateOf {
val full = items.list
if (viewModel.collapsedReplies.isEmpty()) {
full
VisibleThread(full, emptyMap())
} else {
val result = ArrayList<Note>(full.size)
val hiddenCounts = HashMap<String, Int>()
var hideDeeperThan = Int.MAX_VALUE
var collapsedAncestorId: String? = null
full.forEach { note ->
val level = levels[note] ?: 0
if (level > hideDeeperThan) return@forEach
if (level > hideDeeperThan) {
collapsedAncestorId?.let { hiddenCounts[it] = (hiddenCounts[it] ?: 0) + 1 }
return@forEach
}
hideDeeperThan = Int.MAX_VALUE
collapsedAncestorId = null
result.add(note)
if (viewModel.isCollapsed(note.idHex)) {
hideDeeperThan = level
collapsedAncestorId = note.idHex
}
}
result
VisibleThread(result, hiddenCounts)
}
}
}
val visibleItems = visible.items
val position = visibleItems.indexOfFirst { it.idHex == noteId }
LaunchedEffect(noteId, position) {
@@ -467,6 +477,7 @@ fun RenderThreadFeed(
CollapsedNoteCompose(
baseNote = item,
modifier = modifier,
hiddenReplyCount = visible.hiddenCounts[item.idHex] ?: 0,
onExpand = { viewModel.toggleCollapsed(item.idHex) },
accountViewModel = accountViewModel,
nav = nav,
@@ -500,15 +511,26 @@ fun RenderThreadFeed(
}
}
/**
* Holds the thread items currently visible after collapsing, plus, for each collapsed reply id,
* the number of descendant replies that were hidden underneath it.
*/
private class VisibleThread(
val items: List<Note>,
val hiddenCounts: Map<String, Int>,
)
/**
* Compact rendering of a thread reply the user has collapsed: avatar, author name, and the
* first two lines of its content, with an ExpandMore indicator to reopen it.
* Tapping anywhere on the row expands the note (and reveals its hidden children) again.
* first two lines of its content. The right side shows how many replies are hidden underneath it
* and an ExpandMore indicator. Tapping anywhere on the row expands the note (and reveals its
* hidden children) again.
*/
@Composable
private fun CollapsedNoteCompose(
baseNote: Note,
modifier: Modifier,
hiddenReplyCount: Int,
onExpand: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
@@ -542,6 +564,16 @@ private fun CollapsedNoteCompose(
Spacer(modifier = StdHorzSpacer)
if (hiddenReplyCount > 0) {
Text(
text = pluralStringResource(R.plurals.thread_collapsed_reply_count, hiddenReplyCount, hiddenReplyCount),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
style = MaterialTheme.typography.labelMedium,
)
Spacer(modifier = StdHorzSpacer)
}
ExpandMoreIcon(modifier = Modifier.size(Size20dp), contentDescriptor = R.string.expand)
}
}
+4
View File
@@ -18,6 +18,10 @@
<item quantity="one">%1$d asset bundled</item>
<item quantity="other">%1$d assets bundled</item>
</plurals>
<plurals name="thread_collapsed_reply_count">
<item quantity="one">+%1$d reply</item>
<item quantity="other">+%1$d replies</item>
</plurals>
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Channel Image</string>