diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index e41f4ae5e9..7223ddf48b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -348,6 +348,7 @@ fun NoteCompose( parentBackgroundColor: MutableState? = 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? = 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) } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 55cd5886a1..f6b192a279 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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(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( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt index 1f3f0bd1ed..44179cb763 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt @@ -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() + + 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 {