feat: collapse thread replies on click in thread view

Tapping a reply in the thread view now collapses it instead of opening it
as a new thread. A collapsed reply renders only its author and the first two
lines of its content, and all of its descendant replies are hidden. An
ExpandMore indicator on the collapsed row (or tapping the row) reopens it
and restores its children.

- LevelFeedViewModel tracks the collapsed reply ids and exposes toggle/query
  helpers; collapsing also flags the thread as interacted so it stops
  auto-scrolling to the focused note.
- RenderThreadFeed filters out descendants of collapsed replies (the feed is
  depth-first ordered, so descendants are the contiguous deeper-level items)
  and renders the compact CollapsedNoteCompose for collapsed entries.
- NoteCompose gains an optional onClick override so the thread view can
  intercept the tap for collapsing without changing default navigation.

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 03:42:07 +00:00
parent e1ec0b6823
commit d4c3b83295
3 changed files with 145 additions and 19 deletions
@@ -348,6 +348,7 @@ fun NoteCompose(
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: INav,
onClick: (() -> Unit)? = null,
moreOptions: (@Composable () -> Unit)? = null,
) {
WatchNoteEvent(
@@ -378,6 +379,7 @@ fun NoteCompose(
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav,
onClick = onClick,
moreOptions = moreOptions,
)
}
@@ -399,6 +401,7 @@ fun AcceptableNote(
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: INav,
onClick: (() -> Unit)? = null,
moreOptions: (@Composable () -> Unit)?,
) {
if (isQuotedNote || isBoostedNote) {
@@ -451,6 +454,7 @@ fun AcceptableNote(
accountViewModel = accountViewModel,
showPopup = showPopup,
nav = nav,
onClick = onClick,
moreOptions = moreOptions,
)
}
@@ -505,6 +509,7 @@ fun AcceptableNote(
accountViewModel = accountViewModel,
showPopup = showPopup,
nav = nav,
onClick = onClick,
moreOptions = moreOptions,
)
}
@@ -571,6 +576,7 @@ private fun CheckNewAndRenderNote(
accountViewModel: AccountViewModel,
showPopup: () -> Unit,
nav: INav,
onClick: (() -> Unit)? = null,
moreOptions: (@Composable () -> Unit)? = null,
) {
val backgroundColor =
@@ -584,7 +590,7 @@ private fun CheckNewAndRenderNote(
InnerNoteWithReactions(
baseNote = baseNote,
backgroundColor = backgroundColor,
clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav),
clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav, onClick),
isBoostedNote = isBoostedNote,
isQuotedNote = isQuotedNote,
unPackReply = unPackReply,
@@ -614,25 +620,30 @@ fun clickableNoteModifier(
accountViewModel: AccountViewModel,
showPopup: () -> Unit,
nav: INav,
onClick: (() -> Unit)? = null,
): Modifier =
remember(baseNote, modifier) {
remember(baseNote, modifier, onClick) {
modifier
.combinedClickable(
onClick = {
val redirectToNote =
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
baseNote.replyTo?.lastOrNull() ?: baseNote
} else {
baseNote
}
nav.nav {
if (redirectToNote.event is DraftWrapEvent) {
withContext(Dispatchers.IO) {
routeEditDraftTo(redirectToNote, accountViewModel.account)
if (onClick != null) {
onClick()
} else {
val redirectToNote =
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
baseNote.replyTo?.lastOrNull() ?: baseNote
} else {
baseNote
}
nav.nav {
if (redirectToNote.event is DraftWrapEvent) {
withContext(Dispatchers.IO) {
routeEditDraftTo(redirectToNote, accountViewModel.account)
}
} else {
routeFor(redirectToNote, accountViewModel.account)
}
} else {
routeFor(redirectToNote, accountViewModel.account)
}
}
},
@@ -94,8 +94,10 @@ import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus
import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.DisplayDraft
import com.vitorpamplona.amethyst.ui.note.DisplayOtsIfInOriginal
import com.vitorpamplona.amethyst.ui.note.ExpandMoreIcon
import com.vitorpamplona.amethyst.ui.note.Expiration
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -220,6 +222,8 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -362,8 +366,36 @@ fun RenderThreadFeed(
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val levels by viewModel.levelCacheFlow.collectAsStateWithLifecycle()
val position = items.list.indexOfFirst { it.idHex == noteId }
// 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
remember(items, levels) {
derivedStateOf {
val full = items.list
if (viewModel.collapsedReplies.isEmpty()) {
full
} else {
val result = ArrayList<Note>(full.size)
var hideDeeperThan = Int.MAX_VALUE
full.forEach { note ->
val level = levels[note] ?: 0
if (level > hideDeeperThan) return@forEach
hideDeeperThan = Int.MAX_VALUE
result.add(note)
if (viewModel.isCollapsed(note.idHex)) {
hideDeeperThan = level
}
}
result
}
}
}
val position = visibleItems.indexOfFirst { it.idHex == noteId }
LaunchedEffect(noteId, position) {
// hack to allow multiple scrolls to Item while posts on the screen load.
@@ -382,7 +414,7 @@ fun RenderThreadFeed(
if (position >= 0 && !viewModel.hasDragged.value) {
val offset =
if (position > items.list.size - 3) {
if (position > visibleItems.size - 3) {
0
} else {
-200
@@ -398,9 +430,15 @@ fun RenderThreadFeed(
state = listState,
) {
itemsIndexed(
items.list,
visibleItems,
key = { _, item -> item.idHex },
contentType = { index, _ -> if (index == 0) "master" else "reply" },
contentType = { index, item ->
when {
index == 0 -> "master"
viewModel.isCollapsed(item.idHex) -> "collapsed"
else -> "reply"
}
},
) { index, item ->
val level = viewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0)
@@ -426,6 +464,14 @@ fun RenderThreadFeed(
nav = nav,
)
}
} else if (viewModel.isCollapsed(item.idHex)) {
CollapsedNoteCompose(
baseNote = item,
modifier = modifier,
onExpand = { viewModel.toggleCollapsed(item.idHex) },
accountViewModel = accountViewModel,
nav = nav,
)
} else {
val selectedNoteColor = MaterialTheme.colorScheme.selectedNote
val background =
@@ -433,6 +479,8 @@ fun RenderThreadFeed(
if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null
}
val onCollapse = remember(item) { { viewModel.toggleCollapsed(item.idHex) } }
NoteCompose(
baseNote = item,
modifier = modifier,
@@ -442,6 +490,7 @@ fun RenderThreadFeed(
parentBackgroundColor = background,
accountViewModel = accountViewModel,
nav = nav,
onClick = onCollapse,
)
}
@@ -452,6 +501,53 @@ fun RenderThreadFeed(
}
}
/**
* 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.
*/
@Composable
private fun CollapsedNoteCompose(
baseNote: Note,
modifier: Modifier,
onExpand: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchNoteEvent(baseNote, accountViewModel, nav) {
Row(
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onExpand)
.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NoteAuthorPicture(baseNote = baseNote, size = Size25dp, accountViewModel = accountViewModel, nav = nav)
Spacer(modifier = StdHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
NoteUsernameDisplay(baseNote, accountViewModel = accountViewModel)
LoadDecryptedContent(baseNote, accountViewModel) { body ->
Text(
text = body,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = StdHorzSpacer)
ExpandMoreIcon(modifier = Modifier.size(Size20dp), contentDescriptor = R.string.expand)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NoteMaster(
@@ -24,6 +24,7 @@ import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
@@ -56,6 +57,24 @@ abstract class LevelFeedViewModel(
val hasDragged = mutableStateOf(false)
/**
* Reply notes (by id hex) the user has collapsed in the thread view. A collapsed note
* renders only a short preview of itself and hides all of its descendants. Backed by a
* snapshot map so reads in composition recompose when the set changes.
*/
val collapsedReplies = mutableStateMapOf<String, Boolean>()
fun isCollapsed(idHex: String): Boolean = collapsedReplies[idHex] == true
fun toggleCollapsed(idHex: String) {
if (collapsedReplies.remove(idHex) == null) {
collapsedReplies[idHex] = true
}
// Collapsing is an explicit interaction, so stop the thread from auto-scrolling
// to the focused note afterwards.
hasDragged.value = true
}
val selectedIDHex =
llState.interactionSource.interactions
.onEach {