mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+100
-4
@@ -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(
|
||||
|
||||
+19
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user