diff --git a/app/build.gradle.kts b/app/build.gradle.kts index abfd2473..93547f84 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -274,7 +274,9 @@ dependencies { androidTestImplementation(libs.android.test.core) androidTestImplementation(libs.android.test.rules) + androidTestImplementation(libs.android.test.espresso.core) androidTestImplementation(libs.compose.ui.test.junit4) + testImplementation(libs.junit4) } aboutLibraries { @@ -307,4 +309,4 @@ sqldelight { packageName.set("chat.stoat.persistence") } } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/chat/stoat/composables/screens/chat/TypingIndicatorAnimationTest.kt b/app/src/androidTest/java/chat/stoat/composables/screens/chat/TypingIndicatorAnimationTest.kt new file mode 100644 index 00000000..d52c7dc3 --- /dev/null +++ b/app/src/androidTest/java/chat/stoat/composables/screens/chat/TypingIndicatorAnimationTest.kt @@ -0,0 +1,86 @@ +package chat.stoat.composables.screens.chat + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class TypingIndicatorAnimationTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun typingIndicatorMovesContentAboveItSmoothly() { + var typingUsers by mutableStateOf(emptyList()) + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + MaterialTheme { + Box( + modifier = Modifier + .width(200.dp) + .height(200.dp) + ) { + Column(Modifier.align(Alignment.BottomCenter)) { + Spacer( + Modifier + .testTag("content-above-typing-indicator") + .fillMaxWidth() + .height(48.dp) + ) + TypingIndicator( + users = typingUsers, + serverId = null, + ) + } + } + } + } + + val hiddenPosition = contentTop() + + composeRule.runOnIdle { + typingUsers = listOf("typing-user") + } + composeRule.mainClock.advanceTimeBy(200) + val enteringPosition = contentTop() + composeRule.mainClock.advanceTimeBy(300) + val shownPosition = contentTop() + + assertTrue(enteringPosition < hiddenPosition) + assertTrue(enteringPosition > shownPosition) + + composeRule.runOnIdle { + typingUsers = emptyList() + } + composeRule.mainClock.advanceTimeBy(200) + val exitingPosition = contentTop() + composeRule.mainClock.advanceTimeBy(300) + val hiddenAgainPosition = contentTop() + + assertTrue(exitingPosition > shownPosition) + assertTrue(exitingPosition < hiddenAgainPosition) + } + + private fun contentTop(): Float = + composeRule + .onNodeWithTag("content-above-typing-indicator") + .fetchSemanticsNode() + .boundsInRoot + .top +} diff --git a/app/src/androidTest/java/chat/stoat/screens/chat/views/channel/ChannelHistoryScrollTest.kt b/app/src/androidTest/java/chat/stoat/screens/chat/views/channel/ChannelHistoryScrollTest.kt new file mode 100644 index 00000000..54571fc7 --- /dev/null +++ b/app/src/androidTest/java/chat/stoat/screens/chat/views/channel/ChannelHistoryScrollTest.kt @@ -0,0 +1,167 @@ +package chat.stoat.screens.chat.views.channel + +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class ChannelHistoryScrollTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun historicalPagesPreservePositionUntilTheListReachesPresentDay() { + val messages = mutableStateListOf().apply { + addAll((0 until 100).map { "old-$it" }) + } + var showBottomAnchor by mutableStateOf(false) + lateinit var state: LazyListState + + composeRule.setContent { + state = rememberLazyListState() + LazyColumn( + modifier = Modifier + .width(200.dp) + .height(240.dp), + state = state, + reverseLayout = true, + ) { + if (showBottomAnchor) { + item(key = "guaranteed_first") { + Spacer(Modifier.height(1.dp)) + } + } + items(messages, key = { it }) { message -> + Text( + text = message, + modifier = Modifier.height(48.dp), + ) + } + } + } + + repeat(2) { page -> + composeRule.runOnIdle { + runBlocking { + state.scrollToItem(index = 3, scrollOffset = 11) + } + } + val anchor = composeRule.runOnIdle { state.currentAnchor() } + + composeRule.runOnIdle { + messages.addAll( + index = 0, + elements = (0 until 50).map { "newer-$page-$it" }, + ) + } + + composeRule.runOnIdle { + assertEquals(anchor, state.currentAnchor()) + assertTrue(state.firstVisibleItemIndex > 6) + } + } + + composeRule.runOnIdle { + runBlocking { + state.scrollToItem(index = 3, scrollOffset = 11) + } + } + val finalHistoricalAnchor = composeRule.runOnIdle { state.currentAnchor() } + composeRule.runOnIdle { + messages.addAll( + index = 0, + elements = (0 until 50).map { "final-$it" }, + ) + showBottomAnchor = true + } + composeRule.runOnIdle { + assertEquals(finalHistoricalAnchor, state.currentAnchor()) + assertTrue(state.firstVisibleItemIndex > 6) + } + + composeRule.runOnIdle { + runBlocking { + state.scrollToItem(0) + } + } + composeRule.runOnIdle { + messages.add(0, "live-message") + } + composeRule.runOnIdle { + assertTrue( + state.layoutInfo.visibleItemsInfo.any { it.key == "live-message" } + ) + } + } + + @Test + fun reversedListUsesViewportDisplacementToCenterAnItem() { + val messages = (0 until 100).map { "message-$it" } + lateinit var state: LazyListState + + composeRule.setContent { + state = rememberLazyListState() + LazyColumn( + modifier = Modifier + .width(200.dp) + .height(240.dp), + state = state, + reverseLayout = true, + ) { + items(messages, key = { it }) { message -> + Text( + text = message, + modifier = Modifier.height(48.dp), + ) + } + } + } + + composeRule.runOnIdle { + runBlocking { + state.scrollToItem(20) + } + } + composeRule.runOnIdle { + val viewportCenter = + (state.layoutInfo.viewportStartOffset + state.layoutInfo.viewportEndOffset) / 2 + val targetCenter = state.itemCenter("message-20") + runBlocking { + state.scrollBy((targetCenter - viewportCenter).toFloat()) + } + } + composeRule.runOnIdle { + val viewportCenter = + (state.layoutInfo.viewportStartOffset + state.layoutInfo.viewportEndOffset) / 2 + assertEquals(viewportCenter, state.itemCenter("message-20")) + } + } + + private fun LazyListState.currentAnchor(): Pair { + val firstVisibleItem = layoutInfo.visibleItemsInfo + .first { it.index == firstVisibleItemIndex } + return firstVisibleItem.key to firstVisibleItem.offset + } + + private fun LazyListState.itemCenter(key: Any): Int { + val item = layoutInfo.visibleItemsInfo.first { it.key == key } + return item.offset + item.size / 2 + } +} diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index 20291498..ebeb0041 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.lifecycle.ViewModel @@ -98,6 +99,8 @@ import chat.stoat.screens.about.AttributionScreen import chat.stoat.screens.changelogs.ReadChangelogScreen import chat.stoat.screens.chat.ChannelPinsScreen import chat.stoat.screens.chat.ChannelSearchScreen +import chat.stoat.screens.chat.CHANNEL_MESSAGE_JUMP_CHANNEL_KEY +import chat.stoat.screens.chat.CHANNEL_MESSAGE_JUMP_MESSAGE_KEY import chat.stoat.screens.chat.ChatRouterScreen import chat.stoat.screens.chat.standalone.CatchUpScreen import chat.stoat.screens.chat.views.channel.ChannelScreen @@ -426,6 +429,7 @@ class MainActivity : AppCompatActivity() { } val StoatTweenInt: FiniteAnimationSpec = tween(400, easing = EaseInOutExpo) +val StoatTweenSize: FiniteAnimationSpec = tween(400, easing = EaseInOutExpo) val StoatTweenFloat: FiniteAnimationSpec = tween(400, easing = EaseInOutExpo) val StoatTweenDp: FiniteAnimationSpec = tween(400, easing = EaseInOutExpo) val StoatTweenColour: FiniteAnimationSpec = tween(400, easing = EaseInOutExpo) @@ -691,6 +695,14 @@ fun AppEntrypoint( } ) { backStackEntry -> val channelId = backStackEntry.arguments?.getString("channelId") ?: "" + val requestedMessageId = + backStackEntry.savedStateHandle.get( + CHANNEL_MESSAGE_JUMP_MESSAGE_KEY + )?.takeIf { + backStackEntry.savedStateHandle.get( + CHANNEL_MESSAGE_JUMP_CHANNEL_KEY + ) == channelId + } ChannelScreen( channelId = channelId, onToggleDrawer = {}, @@ -699,7 +711,16 @@ fun AppEntrypoint( backButtonAction = { navController.popBackStack() }, - useChatUI = true + useChatUI = true, + requestedMessageId = requestedMessageId, + onRequestedMessageConsumed = { + backStackEntry.savedStateHandle.remove( + CHANNEL_MESSAGE_JUMP_CHANNEL_KEY + ) + backStackEntry.savedStateHandle.remove( + CHANNEL_MESSAGE_JUMP_MESSAGE_KEY + ) + }, ) } diff --git a/app/src/main/java/chat/stoat/composables/chat/Message.kt b/app/src/main/java/chat/stoat/composables/chat/Message.kt index 3d43e005..c248d60c 100644 --- a/app/src/main/java/chat/stoat/composables/chat/Message.kt +++ b/app/src/main/java/chat/stoat/composables/chat/Message.kt @@ -210,12 +210,14 @@ fun formatLongAsTime(time: Long): String { @Composable fun Message( message: MessageSchema, + onClick: () -> Unit = {}, onMessageContextMenu: () -> Unit = {}, onAvatarClick: () -> Unit = {}, onNameClick: (() -> Unit)? = null, canReply: Boolean = false, onReply: () -> Unit = {}, onAddReaction: () -> Unit = {}, + onJumpToMessage: (String) -> Unit = {}, fromWebhook: Boolean = false, webhookName: String? = null, modifier: Modifier = Modifier, @@ -270,7 +272,7 @@ fun Message( Row( modifier = Modifier .combinedClickable( - onClick = {}, + onClick = onClick, onDoubleClick = {}, onLongClick = { onMessageContextMenu() @@ -329,16 +331,15 @@ fun Message( replyMessage.author ) } == true), - ) { - // TODO Add jump to message - } + onMessageClick = onJumpToMessage, + ) } } Row( modifier = Modifier .combinedClickable( - onClick = {}, + onClick = onClick, onDoubleClick = { if (canReply && LoadedSettings.messageReplyStyle == MessageReplyStyle.DoubleTap) { onReply() diff --git a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt index c531f6cd..e7454bb0 100644 --- a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt +++ b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt @@ -1,6 +1,7 @@ package chat.stoat.composables.chat import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -54,7 +55,10 @@ fun String?.mention(): String { } @Composable -fun SystemMessage(message: Message) { +fun SystemMessage( + message: Message, + onClick: (() -> Unit)? = null, +) { if (message.system == null) return val serverId = StoatAPI.channelCache[message.channel]?.server @@ -75,6 +79,7 @@ fun SystemMessage(message: Message) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .padding(horizontal = 10.dp, vertical = 4.dp) .fillMaxWidth() ) { diff --git a/app/src/main/java/chat/stoat/composables/screens/chat/TypingIndicator.kt b/app/src/main/java/chat/stoat/composables/screens/chat/TypingIndicator.kt index eaa0825d..21b55273 100644 --- a/app/src/main/java/chat/stoat/composables/screens/chat/TypingIndicator.kt +++ b/app/src/main/java/chat/stoat/composables/screens/chat/TypingIndicator.kt @@ -1,8 +1,10 @@ package chat.stoat.composables.screens.chat import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background @@ -15,6 +17,7 @@ import androidx.compose.foundation.layout.size 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.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -24,6 +27,7 @@ import androidx.compose.ui.unit.sp import chat.stoat.R import chat.stoat.activities.StoatTweenFloat import chat.stoat.activities.StoatTweenInt +import chat.stoat.activities.StoatTweenSize import chat.stoat.api.StoatAPI import chat.stoat.core.model.schemas.User import chat.stoat.composables.generic.UserAvatar @@ -77,10 +81,16 @@ fun TypingIndicator(users: List, serverId: String?) { enter = slideInVertically( animationSpec = StoatTweenInt, initialOffsetY = { it } + ) + expandVertically( + animationSpec = StoatTweenSize, + expandFrom = Alignment.Bottom, ) + fadeIn(animationSpec = StoatTweenFloat), exit = slideOutVertically( animationSpec = StoatTweenInt, targetOffsetY = { it } + ) + shrinkVertically( + animationSpec = StoatTweenSize, + shrinkTowards = Alignment.Bottom, ) + fadeOut(animationSpec = StoatTweenFloat) ) { Row( diff --git a/app/src/main/java/chat/stoat/composables/screens/chat/atoms/RegularMessage.kt b/app/src/main/java/chat/stoat/composables/screens/chat/atoms/RegularMessage.kt index 88afc3d9..d2b9c91a 100644 --- a/app/src/main/java/chat/stoat/composables/screens/chat/atoms/RegularMessage.kt +++ b/app/src/main/java/chat/stoat/composables/screens/chat/atoms/RegularMessage.kt @@ -75,6 +75,7 @@ fun RegularMessage( showReactBottomSheet: () -> Unit, putTextAtCursorPosition: (String) -> Unit, replyToMessage: suspend (String) -> Unit, + jumpToMessage: (String) -> Unit = {}, scope: CoroutineScope = rememberCoroutineScope(), mdAst: State? = null ) { @@ -207,6 +208,7 @@ fun RegularMessage( showReactBottomSheet() } }, + onJumpToMessage = jumpToMessage, fromWebhook = message.webhook != null, webhookName = message.webhook?.name, mdAst = mdAst, @@ -307,4 +309,4 @@ fun RegularMessage( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/chat/stoat/screens/chat/ChannelSearchScreen.kt b/app/src/main/java/chat/stoat/screens/chat/ChannelSearchScreen.kt index f7ad97f0..31896129 100644 --- a/app/src/main/java/chat/stoat/screens/chat/ChannelSearchScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/ChannelSearchScreen.kt @@ -58,6 +58,8 @@ import kotlinx.coroutines.launch private const val MAX_QUERY_LENGTH = 64 private const val SEARCH_DEBOUNCE_MS = 350L +const val CHANNEL_MESSAGE_JUMP_CHANNEL_KEY = "channelMessageJumpChannel" +const val CHANNEL_MESSAGE_JUMP_MESSAGE_KEY = "channelMessageJumpMessage" enum class SearchSort(val apiValue: String, val label: Int) { RELEVANCE("Relevance", R.string.channel_search_sort_relevance), @@ -209,7 +211,17 @@ fun ChannelSearchScreen( containerColor = MaterialTheme.colorScheme.background ) ) { - SearchResults(channelId = channelId, viewModel = viewModel) + SearchResults( + channelId = channelId, + viewModel = viewModel, + onMessageSelected = { messageId -> + navController.previousBackStackEntry?.savedStateHandle?.apply { + set(CHANNEL_MESSAGE_JUMP_CHANNEL_KEY, channelId) + set(CHANNEL_MESSAGE_JUMP_MESSAGE_KEY, messageId) + } + navController.popBackStack() + }, + ) } } } @@ -218,7 +230,8 @@ fun ChannelSearchScreen( @Composable private fun SearchResults( channelId: String, - viewModel: ChannelSearchScreenViewModel + viewModel: ChannelSearchScreenViewModel, + onMessageSelected: (String) -> Unit, ) { Column(modifier = Modifier.fillMaxHeight()) { if (viewModel.hasSearched) { @@ -275,10 +288,17 @@ private fun SearchResults( key = { i -> viewModel.results[i].id ?: i } ) { i -> val message = viewModel.results[i].copy(tail = false) + val onClick = { + message.id?.let(onMessageSelected) + Unit + } if (message.system != null) { - SystemMessage(message) + SystemMessage(message, onClick = onClick) } else { - chat.stoat.composables.chat.Message(message = message) + chat.stoat.composables.chat.Message( + message = message, + onClick = onClick, + ) } } } diff --git a/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt index 846e7a4f..f4d580c7 100644 --- a/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt @@ -65,6 +65,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.viewModelScope import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState import chat.stoat.BuildConfig import chat.stoat.R import chat.stoat.api.StoatAPI @@ -1028,6 +1029,7 @@ fun ChannelNavigator( setDrawerGestureEnabled: (Boolean) -> Unit = {}, ) { val scope = rememberCoroutineScope() + val currentTopEntry by topNav.currentBackStackEntryAsState() BackHandler(useDrawer && !disableBackHandler) { toggleDrawer() @@ -1052,6 +1054,15 @@ fun ChannelNavigator( } is ChatRouterDestination.Channel -> { + val requestedChannelId = + currentTopEntry?.savedStateHandle?.get( + CHANNEL_MESSAGE_JUMP_CHANNEL_KEY + ) + val requestedMessageId = + currentTopEntry?.savedStateHandle?.get( + CHANNEL_MESSAGE_JUMP_MESSAGE_KEY + )?.takeIf { requestedChannelId == dest.channelId } + ChannelScreen( channelId = dest.channelId, onToggleDrawer = { @@ -1067,6 +1078,15 @@ fun ChannelNavigator( drawerGestureEnabled = drawerGestureEnabled, setDrawerGestureEnabled = setDrawerGestureEnabled, drawerIsOpen = drawerState?.isOpen == true, + requestedMessageId = requestedMessageId, + onRequestedMessageConsumed = { + currentTopEntry?.savedStateHandle?.remove( + CHANNEL_MESSAGE_JUMP_CHANNEL_KEY + ) + currentTopEntry?.savedStateHandle?.remove( + CHANNEL_MESSAGE_JUMP_MESSAGE_KEY + ) + }, ) } diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index 652ef2f1..deec0468 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -17,15 +17,21 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -44,6 +50,7 @@ import androidx.compose.foundation.layout.imeAnimationTarget import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size @@ -53,20 +60,28 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.AssistChip +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -86,6 +101,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -104,6 +120,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.documentfile.provider.DocumentFile import chat.stoat.R import chat.stoat.StoatApplication @@ -123,7 +140,6 @@ import chat.stoat.composables.chat.Message import chat.stoat.composables.chat.MessageField import chat.stoat.composables.chat.SystemMessage import chat.stoat.composables.emoji.EmojiPicker -import chat.stoat.composables.voice.VoiceCallBanner import chat.stoat.composables.generic.GroupIcon import chat.stoat.composables.generic.PresenceBadge import chat.stoat.composables.generic.UserAvatar @@ -138,6 +154,7 @@ import chat.stoat.composables.screens.chat.atoms.RegularMessage import chat.stoat.composables.screens.chat.molecules.JoinVoiceChannelButton import chat.stoat.composables.skeletons.MessageSkeleton import chat.stoat.composables.skeletons.MessageSkeletonVariant +import chat.stoat.composables.voice.VoiceCallBanner import chat.stoat.core.model.schemas.ChannelType import chat.stoat.core.model.schemas.Message import chat.stoat.internals.extensions.rememberChannelPermissions @@ -150,7 +167,9 @@ import com.mikepenz.markdown.model.State import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.Instant import org.koin.androidx.compose.koinViewModel @@ -185,7 +204,10 @@ private fun pxAsDp(px: Int): Dp { private const val NOT_ENOUGH_SPACE_FOR_PANES_THRESHOLD = 500 @SuppressLint("UnusedBoxWithConstraintsScope") -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn( + ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, + ExperimentalMaterial3ExpressiveApi::class +) @Composable fun ChannelScreen( channelId: String, @@ -197,6 +219,8 @@ fun ChannelScreen( drawerIsOpen: Boolean = false, backButtonAction: (() -> Unit)? = null, useChatUI: Boolean = false, + requestedMessageId: String? = null, + onRequestedMessageConsumed: () -> Unit = {}, viewModel: ChannelScreenViewModel = koinViewModel() ) { // @@ -223,6 +247,12 @@ fun ChannelScreen( LaunchedEffect(channelId) { viewModel.switchChannel(channelId) } + LaunchedEffect(channelId, requestedMessageId) { + val messageId = requestedMessageId ?: return@LaunchedEffect + snapshotFlow { viewModel.channelId }.first { it == channelId } + viewModel.requestJump(messageId) + onRequestedMessageConsumed() + } // // val imeTarget = WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current) @@ -389,15 +419,18 @@ fun ChannelScreen( // // val lazyListState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } var disableScroll by remember { mutableStateOf(false) } + var highlightedMessageId by remember { mutableStateOf(null) } + val showBottomAnchor = !viewModel.canLoadNewer && !viewModel.isJumpLoading - val isScrolledToBottom = remember(lazyListState) { + val isScrolledToBottom = remember(lazyListState, viewModel) { derivedStateOf { - lazyListState.firstVisibleItemIndex <= 6 + !viewModel.canLoadNewer && lazyListState.firstVisibleItemIndex <= 6 } } - val isNearTop = remember(lazyListState) { + val isNearOlderEdge = remember(lazyListState) { derivedStateOf { val layoutInfo = lazyListState.layoutInfo val totalItemsNumber = layoutInfo.totalItemsCount @@ -415,27 +448,97 @@ fun ChannelScreen( label = "ScrollDownFABPadding" ) - // Load more messages when we reach the top of the list - // TODO: Temp - use LoadTrigger instead - - LaunchedEffect(isNearTop) { - snapshotFlow { isNearTop.value } + LaunchedEffect(lazyListState) { + snapshotFlow { + Triple( + isNearOlderEdge.value, + viewModel.canLoadOlder, + viewModel.isLoadingOlder, + ) + } .distinctUntilChanged() - .collect { isNearTop -> - if (isNearTop) { + .collect { (isNearEdge, canLoad, isLoading) -> + if (isNearEdge && canLoad && !isLoading) { Log.d("ChannelScreen", "Loading more messages") - viewModel.loadMessages(before = viewModel.items.lastOrNull { - it is ChannelScreenItem.RegularMessage || it is ChannelScreenItem.SystemMessage - }?.let { - when (it) { - is ChannelScreenItem.RegularMessage -> it.message.id - is ChannelScreenItem.SystemMessage -> it.message.id - else -> null - } - }, amount = 50) + viewModel.loadOlder() } } } + + LaunchedEffect(lazyListState) { + snapshotFlow { + Triple( + lazyListState.firstVisibleItemIndex <= 6, + viewModel.canLoadNewer, + viewModel.isLoadingNewer, + ) + } + .distinctUntilChanged() + .collect { (isNearEdge, canLoad, isLoading) -> + if (isNearEdge && canLoad && !isLoading) viewModel.loadNewer() + } + } + + LaunchedEffect(viewModel.scrollRequest) { + val request = viewModel.scrollRequest ?: return@LaunchedEffect + when (request) { + is ChannelScrollRequest.Bottom -> lazyListState.scrollToItem(0) + is ChannelScrollRequest.FocusMessage -> { + val itemIndex = + viewModel.items.indexOfFirst { it.messageIdOrNull() == request.messageId } + if (itemIndex >= 0) { + val bottomAnchorOffset = if (showBottomAnchor) 1 else 0 + val lazyItemIndex = itemIndex + bottomAnchorOffset + val visibleItemsBeforeJump = lazyListState.layoutInfo.visibleItemsInfo + val targetIsVisible = visibleItemsBeforeJump + .any { it.key == request.messageId } + if (!targetIsVisible) { + // Off-screen lazy items must be measured before exact centering + // so we snap the target into the viewport, then animate the centering distance + lazyListState.scrollToItem(lazyItemIndex) + } + val target = checkNotNull( + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == request.messageId } + }.first { it != null } + ) + val viewportCenter = + (lazyListState.layoutInfo.viewportStartOffset + + lazyListState.layoutInfo.viewportEndOffset) / 2 + val targetCenter = target.offset + target.size / 2 + val centerOffset = (targetCenter - viewportCenter).toFloat() + if (request.animated) { + lazyListState.animateScrollBy( + value = centerOffset, + animationSpec = StoatTweenFloat, + ) + } else { + lazyListState.scrollBy(centerOffset) + } + highlightedMessageId = request.messageId + delay(1_500) + if (highlightedMessageId == request.messageId) { + highlightedMessageId = null + } + } + } + } + viewModel.consumeScrollRequest(request.requestId) + } + + LaunchedEffect(viewModel.jumpFailure) { + val failure = viewModel.jumpFailure ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar( + message = resources.getString(R.string.message_jump_failed), + actionLabel = resources.getString(R.string.retry), + duration = SnackbarDuration.Long, + ) + viewModel.consumeJumpFailure(failure.requestId) + if (result == SnackbarResult.ActionPerformed) { + viewModel.requestJump(failure.messageId) + } + } // // var channelInfoSheetShown by remember { mutableStateOf(false) } @@ -720,12 +823,11 @@ fun ChannelScreen( reverseLayout = true, contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp) ) { - - // If we don't have a guaranteed first item, the message list will not scroll - // to the bottom when new messages are added. Evil hack to make our other evil - // hack (clear/addAll) work. Too bad! - item(key = "guaranteed_first") { - Box {} + if (showBottomAnchor) { + // Hack - Too bad! + item(key = "guaranteed_first") { + Spacer(Modifier.height(1.dp)) + } } items( @@ -761,58 +863,61 @@ fun ChannelScreen( if (index < 0 || index >= viewModel.items.size) { return@items } - when (val item = viewModel.items[index]) { - is ChannelScreenItem.RegularMessage -> { - RegularMessage( - item.message, - viewModel.channel, - drawerIsOpen = drawerIsOpen, - setDrawerGestureEnabled = { - setDrawerGestureEnabled(it) - }, - setDisableScroll = { - disableScroll = it - }, - showMessageBottomSheet = { - messageContextSheetTarget = it - messageContextSheetShown = true - }, - showReactBottomSheet = { - item.message.id?.let { - reactSheetTarget = it - reactSheetShown = true - } - }, - putTextAtCursorPosition = viewModel::putAtCursorPosition, - replyToMessage = viewModel::addReplyTo, - scope = scope, - mdAst = item.mdAst - ) - } - - is ChannelScreenItem.ProspectiveMessage -> { - Box(Modifier.alpha(0.5f)) { - Message( - message = item.message, - onMessageContextMenu = { - // TODO Context menu that allows you to cancel send + val item = viewModel.items[index] + val messageId = item.messageIdOrNull() + val isHighlighted = + highlightedMessageId?.let { it == messageId } == true + val highlightColor by animateColorAsState( + targetValue = if (isHighlighted) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + } else { + Color.Transparent + }, + animationSpec = tween(durationMillis = 500), + label = "messageJumpHighlight", + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(highlightColor) + ) { + when (item) { + is ChannelScreenItem.RegularMessage -> { + RegularMessage( + item.message, + viewModel.channel, + drawerIsOpen = drawerIsOpen, + setDrawerGestureEnabled = { + setDrawerGestureEnabled(it) }, - onAvatarClick = {}, - onNameClick = {}, - canReply = false, - onReply = {}, - onAddReaction = {}, - mdAst = item.mdAst, + setDisableScroll = { + disableScroll = it + }, + showMessageBottomSheet = { + messageContextSheetTarget = it + messageContextSheetShown = true + }, + showReactBottomSheet = { + item.message.id?.let { + reactSheetTarget = it + reactSheetShown = true + } + }, + putTextAtCursorPosition = viewModel::putAtCursorPosition, + replyToMessage = viewModel::addReplyTo, + jumpToMessage = viewModel::requestJump, + scope = scope, + mdAst = item.mdAst ) } - } - is ChannelScreenItem.FailedMessage -> { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) { - Column { + is ChannelScreenItem.ProspectiveMessage -> { + Box(Modifier.alpha(0.5f)) { Message( message = item.message, - onMessageContextMenu = {}, + onMessageContextMenu = { + // TODO Context menu that allows you to cancel send + }, onAvatarClick = {}, onNameClick = {}, canReply = false, @@ -820,63 +925,106 @@ fun ChannelScreen( onAddReaction = {}, mdAst = item.mdAst, ) - Row { - UserAvatarWidthPlaceholder() - Text( - stringResource(R.string.message_failed_to_send), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error.copy( - alpha = 0.8f - ), - modifier = Modifier.padding( - top = 4.dp, - bottom = 4.dp, - start = 20.dp - ) + } + } + + is ChannelScreenItem.FailedMessage -> { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) { + Column { + Message( + message = item.message, + onMessageContextMenu = {}, + onAvatarClick = {}, + onNameClick = {}, + canReply = false, + onReply = {}, + onAddReaction = {}, + mdAst = item.mdAst, ) + Row { + UserAvatarWidthPlaceholder() + Text( + stringResource(R.string.message_failed_to_send), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error.copy( + alpha = 0.8f + ), + modifier = Modifier.padding( + top = 4.dp, + bottom = 4.dp, + start = 20.dp + ) + ) + } } } } - } - is ChannelScreenItem.SystemMessage -> { - SystemMessage(message = item.message) - } - - is ChannelScreenItem.DateDivider -> { - DateDivider(instant = item.instant) - } - - is ChannelScreenItem.LoadTrigger -> { - LaunchedEffect(Unit) { - Log.d( - "ChannelScreen", - "LoadTrigger: After ${item.after} Before ${item.before}" - ) + is ChannelScreenItem.SystemMessage -> { + SystemMessage(message = item.message) } - } - is ChannelScreenItem.Loading -> { - Column( - modifier = Modifier - .fillMaxWidth() - .shimmer(rememberShimmer(ShimmerBounds.Window)), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - MessageSkeleton(MessageSkeletonVariant.One) - MessageSkeleton(MessageSkeletonVariant.Two) - MessageSkeleton(MessageSkeletonVariant.Three) + is ChannelScreenItem.DateDivider -> { + DateDivider(instant = item.instant) + } + + is ChannelScreenItem.LoadTrigger -> { + LaunchedEffect(Unit) { + Log.d( + "ChannelScreen", + "LoadTrigger: After ${item.after} Before ${item.before}" + ) + } + } + + is ChannelScreenItem.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .shimmer(rememberShimmer(ShimmerBounds.Window)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MessageSkeleton(MessageSkeletonVariant.One) + MessageSkeleton(MessageSkeletonVariant.Two) + MessageSkeleton(MessageSkeletonVariant.Three) + } } } } } } - TypingIndicator( - users = viewModel.typingUsers, - serverId = viewModel.channel?.server - ) + androidx.compose.animation.AnimatedVisibility( + visible = viewModel.isJumpLoading, + modifier = Modifier.align(Alignment.Center), + enter = scaleIn( + animationSpec = StoatTweenFloat, + initialScale = 0.8f, + ) + fadeIn(animationSpec = StoatTweenFloat), + exit = scaleOut( + animationSpec = StoatTweenFloat, + targetScale = 0.8f, + ) + fadeOut(animationSpec = StoatTweenFloat), + ) { + LoadingIndicator() + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .zIndex(1f) + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.fillMaxWidth() + ) + TypingIndicator( + users = viewModel.typingUsers, + serverId = viewModel.channel?.server + ) + } androidx.compose.animation.AnimatedVisibility( !isScrolledToBottom.value, @@ -889,23 +1037,55 @@ fun ChannelScreen( targetOffsetY = { it } ) + fadeOut(animationSpec = StoatTweenFloat) ) { - SmallFloatingActionButton( + BadgedBox( modifier = Modifier .padding(bottom = scrollDownFABPadding) .align(Alignment.BottomCenter) .padding(16.dp), - onClick = { - scope.launch { - lazyListState.animateScrollToItem(0) + badge = { + androidx.compose.animation.AnimatedVisibility( + visible = viewModel.hasUnseenNewMessages, + modifier = Modifier.offset(x = (-4).dp, y = 0.dp), + enter = scaleIn( + animationSpec = StoatTweenFloat, + initialScale = 0.5f, + ) + fadeIn(animationSpec = StoatTweenFloat), + exit = scaleOut( + animationSpec = StoatTweenFloat, + targetScale = 0.5f, + ) + fadeOut(animationSpec = StoatTweenFloat), + ) { + Badge( + containerColor = MaterialTheme.colorScheme.primary + ) { + Text(stringResource(R.string._new)) + } } - }, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - containerColor = MaterialTheme.colorScheme.surfaceVariant + } ) { - Icon( - painter = painterResource(R.drawable.ic_south_24dp), - contentDescription = stringResource(R.string.scroll_to_bottom) - ) + SmallFloatingActionButton( + onClick = { + if ( + viewModel.canLoadNewer || + viewModel.hasUnseenNewMessages + ) { + viewModel.loadLatest(requestScrollToBottom = true) + } else { + scope.launch { + lazyListState.animateScrollToItem(0) + } + } + }, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + Icon( + painter = painterResource(R.drawable.ic_south_24dp), + contentDescription = stringResource( + R.string.scroll_to_bottom + ) + ) + } } } @@ -1335,4 +1515,4 @@ fun ChannelScreen( } } // -} \ No newline at end of file +} diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt index 38c4f82c..e4e9410f 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -46,11 +46,11 @@ import chat.stoat.callbacks.Action import chat.stoat.callbacks.ActionChannel import chat.stoat.callbacks.UiCallback import chat.stoat.callbacks.UiCallbacks +import chat.stoat.composables.markdown.prose.easyLineBreaks import chat.stoat.core.model.schemas.Channel import chat.stoat.core.model.schemas.Message import chat.stoat.internals.text.MessageProcessor import chat.stoat.internals.text.stripPUAChars -import chat.stoat.composables.markdown.prose.easyLineBreaks import chat.stoat.markdown.StoatMarkdownFlavour import chat.stoat.persistence.KVStorage import chat.stoat.screens.chat.ChatRouterDestination @@ -58,6 +58,7 @@ import chat.stoat.settings.providers.AgeGateUnlockedStorageProvider import com.mikepenz.markdown.model.State import com.mikepenz.markdown.model.parseMarkdownFlow import io.ktor.http.ContentType +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -70,6 +71,9 @@ import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.toJavaInstant +import logcat.LogPriority +import logcat.asLog +import logcat.logcat import java.time.ZoneId class ChannelScreenViewModel( @@ -98,8 +102,25 @@ class ChannelScreenViewModel( var draftReplyTo = mutableStateListOf() var attachmentUploadProgress by mutableStateOf(0f) - var endOfChannel by mutableStateOf(false) var didInitialChannelFetch by mutableStateOf(false) + var canLoadOlder by mutableStateOf(false) + private set + var canLoadNewer by mutableStateOf(false) + private set + var isInitialLoading by mutableStateOf(false) + private set + var isJumpLoading by mutableStateOf(false) + private set + var isLoadingOlder by mutableStateOf(false) + private set + var isLoadingNewer by mutableStateOf(false) + private set + var hasUnseenNewMessages by mutableStateOf(false) + private set + var scrollRequest by mutableStateOf(null) + private set + var jumpFailure by mutableStateOf(null) + private set var ensuredSelfMember by mutableStateOf(false) @@ -119,16 +140,26 @@ class ChannelScreenViewModel( } private var loadMessagesJob: Job? = null + private var requestSequence = 0L fun switchChannel(id: String) { // Reset state this.loadMessagesJob?.cancel() + requestSequence++ this.channelId = id this.items = mutableStateListOf(ChannelScreenItem.Loading) this.activePane = ChannelScreenActivePane.None this.typingUsers = mutableStateListOf() - this.endOfChannel = false this.didInitialChannelFetch = false + this.canLoadOlder = false + this.canLoadNewer = false + this.isInitialLoading = true + this.isJumpLoading = false + this.isLoadingOlder = false + this.isLoadingNewer = false + this.hasUnseenNewMessages = false + this.scrollRequest = null + this.jumpFailure = null this.ensuredSelfMember = false this.denyMessageField = false this.denyMessageFieldReasonResource = R.string.typing_blank @@ -157,7 +188,7 @@ class ChannelScreenViewModel( denyMessageFieldIfNeeded() } - this.loadMessages(50, markLastAsRead = true) + this.loadLatest(markLastAsRead = true) } suspend fun unlockAgeGate() { @@ -305,13 +336,14 @@ class ChannelScreenViewModel( if (!inFence && !inCodeSpan && ch == '@') { when { content.startsWith("everyone", i + 1) && - (i + 9 >= content.length || !content[i + 9].isLetterOrDigit()) -> { + (i + 9 >= content.length || !content[i + 9].isLetterOrDigit()) -> { sb.append("<@EVERYONE>") i += 9 continue } + content.startsWith("online", i + 1) && - (i + 7 >= content.length || !content[i + 7].isLetterOrDigit()) -> { + (i + 7 >= content.length || !content[i + 7].isLetterOrDigit()) -> { sb.append("<@ONLINE>") i += 7 continue @@ -413,10 +445,29 @@ class ChannelScreenViewModel( // the original content val content = MessageProcessor.processOutgoing(draftContent, channel?.server) val replyTo = draftReplyTo.toList() + val returnToLatestBeforeRenderingSend = canLoadNewer // First we upload (the next 5) attachments... viewModelScope.launch { isSending = true + if (returnToLatestBeforeRenderingSend) { + loadMessagesJob?.cancel() + requestSequence++ + isJumpLoading = false + isLoadingOlder = false + isLoadingNewer = false + try { + replaceWithLatest( + markLastAsRead = true, + requestScrollToBottom = true, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Failed to return to latest before sending: " + e.asLog() } + } + } + val attachmentIds = arrayListOf() val takenAttachments = this@ChannelScreenViewModel.draftAttachments.take(MAX_ATTACHMENTS_PER_MESSAGE) @@ -500,110 +551,234 @@ class ChannelScreenViewModel( } } - /** - * Load messages from the channel. If the channel is switched, the job will be cancelled. - * - * @param amount The amount of messages to load. - * @param before Load [amount] messages before this message ID. Do not use with [around] or [after]. - * @param after Load [amount] messages after this message ID. Do not use with [around] or [before]. - * @param around Load [amount] messages around this message ID. Do not use with [before] or [after]. - * @param ignoreExisting If true, messages that are already in the list will not be added again. Possible performance degradation. - */ - fun loadMessages( + private suspend fun fetchMessagePage( + channelId: String, amount: Int, before: String? = null, after: String? = null, - around: String? = null, - ignoreExisting: Boolean = false, - markLastAsRead: Boolean = false - ) { - val currentChannelId = channelId ?: return - loadMessagesJob = viewModelScope.launch { - try { - val messages = arrayListOf() + nearby: String? = null, + sort: String? = null, + ): List { + val response = fetchMessagesFromChannel( + channelId = channelId, + limit = amount, + includeUsers = true, + before = before, + after = after, + nearby = nearby, + sort = sort, + ) - fetchMessagesFromChannel( - currentChannelId, - amount, - true, - before, - after, - around - ).let { - if (it.messages.isNullOrEmpty() || it.messages!!.size < 50) { - endOfChannel = true - } - - it.users?.forEach { user -> - if (!StoatAPI.userCache.containsKey(user.id)) { - StoatAPI.userCache[user.id!!] = user - } - } - - it.messages?.forEach { message -> - addUserIfUnknown(message.author ?: return@forEach) - if (!StoatAPI.messageCache.containsKey(message.id)) { - StoatAPI.messageCache[message.id!!] = message - } - messages.add(message) - } - - it.members?.forEach { member -> - if (!StoatAPI.members.hasMember(member.id!!.server, member.id!!.user)) { - StoatAPI.members.setMember(member.id!!.server, member) - } - } - - if (markLastAsRead) { - ackMessage(messages.firstOrNull()?.id ?: return@launch) - } + response.users.orEmpty().forEach { user -> + user.id?.let { StoatAPI.userCache.putIfAbsent(it, user) } + } + response.members.orEmpty().forEach { member -> + member.id?.let { id -> + if (!StoatAPI.members.hasMember(id.server, id.user)) { + StoatAPI.members.setMember(id.server, member) } - - val newItems = messages.filter { - if (ignoreExisting) { - items.none { m -> - when (m) { - is ChannelScreenItem.RegularMessage -> m.message.id == it.id - is ChannelScreenItem.ProspectiveMessage -> m.message.id == it.id - is ChannelScreenItem.SystemMessage -> m.message.id == it.id - is ChannelScreenItem.FailedMessage -> m.message.id == it.id - else -> false - } - } - } else { - true - } - }.let { filtered -> - val result = mutableListOf() - for (msg in filtered) { - result.add( - when { - msg.system != null -> ChannelScreenItem.SystemMessage(msg) - else -> ChannelScreenItem.RegularMessage(msg, parseAst(msg.content)) - } - ) - } - result - } - - // Place items according to whether above/below/around was specified. - // TODO: Aditionally, place LoadTriggers at the beginning and end of the list. - val newItemsWithPosition = when { - before != null -> items + newItems - after != null -> newItems + items - // TODO around, which should place the new items in the middle of the list - else -> newItems - } - - updateItems(newItemsWithPosition) - - if (!didInitialChannelFetch) { - didInitialChannelFetch = true - } - } catch (e: Exception) { - Log.e("ChannelScreenViewModel", "Failed to fetch messages", e) } } + + val messages = normalizeByUlid(response.messages.orEmpty()) { it.id } + messages.forEach { message -> + message.author?.let { addUserIfUnknown(it) } + message.id?.let { StoatAPI.messageCache[it] = message } + } + return messages + } + + private suspend fun messagesToItems(messages: Iterable): List = + messages.map { message -> + if (message.system != null) { + ChannelScreenItem.SystemMessage(message) + } else { + ChannelScreenItem.RegularMessage(message, parseAst(message.content)) + } + } + + private fun loadedMessageIds(): List = items.mapNotNull { it.messageIdOrNull() } + + private suspend fun replaceWithLatest( + amount: Int = 50, + markLastAsRead: Boolean, + requestScrollToBottom: Boolean, + ) { + val expectedChannelId = channelId ?: return + val messages = fetchMessagePage(expectedChannelId, amount) + if (channelId != expectedChannelId) return + + updateItems(messagesToItems(messages)) + canLoadNewer = false + canLoadOlder = messages.size >= amount + hasUnseenNewMessages = false + didInitialChannelFetch = true + isInitialLoading = false + + if (markLastAsRead) { + messages.firstOrNull()?.id?.let { runCatching { ackMessage(it) } } + } + if (requestScrollToBottom) { + scrollRequest = ChannelScrollRequest.Bottom(++requestSequence) + } + } + + fun loadLatest( + amount: Int = 50, + markLastAsRead: Boolean = true, + requestScrollToBottom: Boolean = false, + ) { + loadMessagesJob?.cancel() + requestSequence++ + isJumpLoading = false + isLoadingOlder = false + isLoadingNewer = false + jumpFailure = null + isInitialLoading = !didInitialChannelFetch + loadMessagesJob = viewModelScope.launch { + try { + replaceWithLatest(amount, markLastAsRead, requestScrollToBottom) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + isInitialLoading = false + logcat(LogPriority.ERROR) { "Failed to fetch latest messages: " + e.asLog() } + } + } + } + + fun loadOlder(amount: Int = 50) { + if (!canLoadOlder || isLoadingOlder || isLoadingNewer || isJumpLoading) return + val expectedChannelId = channelId ?: return + val oldestId = loadedMessageIds().minOrNull() ?: return + + isLoadingOlder = true + loadMessagesJob = viewModelScope.launch { + try { + val messages = fetchMessagePage( + channelId = expectedChannelId, + amount = amount, + before = oldestId, + ) + if (channelId != expectedChannelId) return@launch + + updateItems(items + messagesToItems(messages)) + canLoadOlder = messages.size >= amount + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Failed to fetch older messages: " + e.asLog() } + } finally { + isLoadingOlder = false + } + } + } + + fun loadNewer(amount: Int = 50) { + if (!canLoadNewer || isLoadingOlder || isLoadingNewer || isJumpLoading) return + val expectedChannelId = channelId ?: return + val newestId = loadedMessageIds().maxOrNull() ?: return + + isLoadingNewer = true + loadMessagesJob = viewModelScope.launch { + try { + val messages = fetchMessagePage( + channelId = expectedChannelId, + amount = amount, + after = newestId, + sort = "Oldest", + ) + if (channelId != expectedChannelId) return@launch + + updateItems(items + messagesToItems(messages)) + canLoadNewer = messages.size >= amount + if (!canLoadNewer) { + hasUnseenNewMessages = false + loadedMessageIds().maxOrNull()?.let { runCatching { ackMessage(it) } } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Failed to fetch newer messages: " + e.asLog() } + } finally { + isLoadingNewer = false + } + } + } + + fun requestJump(messageId: String, amount: Int = 50) { + if (loadedMessageIds().contains(messageId)) { + loadMessagesJob?.cancel() + isJumpLoading = false + isLoadingOlder = false + isLoadingNewer = false + jumpFailure = null + scrollRequest = + ChannelScrollRequest.FocusMessage( + messageId = messageId, + animated = true, + requestId = ++requestSequence, + ) + return + } + + val expectedChannelId = channelId ?: return + loadMessagesJob?.cancel() + isLoadingOlder = false + isLoadingNewer = false + isJumpLoading = true + jumpFailure = null + val requestId = ++requestSequence + + loadMessagesJob = viewModelScope.launch { + try { + val messages = fetchMessagePage( + channelId = expectedChannelId, + amount = amount, + nearby = messageId, + ) + if (channelId != expectedChannelId || requestId != requestSequence) return@launch + if (messages.none { it.id == messageId }) { + jumpFailure = MessageJumpFailure(messageId, requestId) + return@launch + } + + val boundaries = calculateNearbyBoundaries( + messageIds = messages.mapNotNull { it.id }, + targetMessageId = messageId, + requestedLimit = amount, + ) + updateItems(messagesToItems(messages)) + canLoadNewer = boundaries.canLoadNewer + canLoadOlder = boundaries.canLoadOlder + hasUnseenNewMessages = false + didInitialChannelFetch = true + scrollRequest = ChannelScrollRequest.FocusMessage( + messageId = messageId, + animated = false, + requestId = requestId, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (channelId == expectedChannelId && requestId == requestSequence) { + jumpFailure = MessageJumpFailure(messageId, requestId) + } + logcat(LogPriority.ERROR) { "Failed to jump to message: " + e.asLog() } + } finally { + if (requestId == requestSequence) { + isJumpLoading = false + } + } + } + } + + fun consumeScrollRequest(requestId: Long) { + if (scrollRequest?.requestId == requestId) scrollRequest = null + } + + fun consumeJumpFailure(requestId: Long) { + if (jumpFailure?.requestId == requestId) jumpFailure = null } suspend fun ackMessage(messageId: String) { @@ -618,6 +793,12 @@ class ChannelScreenViewModel( if (it.channel != channel?.id) return@onEach // If we already have the message we are just catching up on the WebSocket connection. Skip if (items.any { m -> (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) || (m is ChannelScreenItem.SystemMessage && m.message.id == it.id) }) return@onEach + it.id?.let { messageId -> StoatAPI.messageCache[messageId] = it } + + if (canLoadNewer) { + hasUnseenNewMessages = true + return@onEach + } it.author?.let { userId -> if (StoatAPI.userCache[userId] == null) { @@ -796,7 +977,11 @@ class ChannelScreenViewModel( is RealtimeSocketFrames.Reconnected -> { Log.d("ChannelScreen", "Reconnected to WS.") - loadMessages(50, ignoreExisting = true) + if (canLoadNewer) { + hasUnseenNewMessages = true + } else { + loadLatest(markLastAsRead = true) + } typingUsers.clear() listenToWsEvents() } @@ -861,7 +1046,7 @@ class ChannelScreenViewModel( private suspend fun updateItems(newItems: List) { // Spec https://wiki.rvlt.gg/index.php/Text_Channel_(UI)#Message_Grouping_Algorithm - val innerItems = newItems.toMutableStateList() + val innerItems = normalizeByUlid(newItems) { it.messageIdOrNull() }.toMutableStateList() // Let L be the list of messages ordered from newest to oldest val allItemsThatAreMessages = innerItems.filterIsInstance() @@ -1005,4 +1190,4 @@ class ChannelScreenViewModel( showPhysicalKeyboardSpark = false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/MessageWindow.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/MessageWindow.kt new file mode 100644 index 00000000..abfd0090 --- /dev/null +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/MessageWindow.kt @@ -0,0 +1,58 @@ +package chat.stoat.screens.chat.views.channel + +internal data class NearbyBoundaries( + val canLoadNewer: Boolean, + val canLoadOlder: Boolean, +) + +sealed interface ChannelScrollRequest { + val requestId: Long + + data class FocusMessage( + val messageId: String, + val animated: Boolean, + override val requestId: Long, + ) : ChannelScrollRequest + + data class Bottom( + override val requestId: Long, + ) : ChannelScrollRequest +} + +data class MessageJumpFailure( + val messageId: String, + val requestId: Long, +) + +internal fun ChannelScreenItem.messageIdOrNull(): String? = when (this) { + is ChannelScreenItem.RegularMessage -> message.id + is ChannelScreenItem.ProspectiveMessage -> message.id + is ChannelScreenItem.FailedMessage -> message.id + is ChannelScreenItem.SystemMessage -> message.id + is ChannelScreenItem.DateDivider, + is ChannelScreenItem.LoadTrigger, + is ChannelScreenItem.Loading -> null +} + +internal fun normalizeByUlid( + values: Iterable, + idOf: (T) -> String?, +): List = values + .mapNotNull { value -> idOf(value)?.let { id -> id to value } } + .distinctBy { (id) -> id } + .sortedByDescending { (id) -> id } + .map { (_, value) -> value } + +internal fun calculateNearbyBoundaries( + messageIds: Iterable, + targetMessageId: String, + requestedLimit: Int, +): NearbyBoundaries { + val ids = messageIds.toList() + val sideCapacity = requestedLimit / 2 + 1 + + return NearbyBoundaries( + canLoadNewer = ids.count { it >= targetMessageId } >= sideCapacity, + canLoadOlder = ids.count { it < targetMessageId } >= sideCapacity, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27c4311b..2516793f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,8 @@ OK Cancel Share + New + Retry Let\'s go Fetching some info, hang in there… Hold your horses! You\'re doing that too often in a short amount of time. @@ -278,6 +280,7 @@ Blocked message Failed to send, long press for options + Couldn’t load that message Message cannot be displayed. Message cannot be displayed (%1$s). diff --git a/app/src/test/java/chat/stoat/screens/chat/views/channel/MessageWindowTest.kt b/app/src/test/java/chat/stoat/screens/chat/views/channel/MessageWindowTest.kt new file mode 100644 index 00000000..070bd86c --- /dev/null +++ b/app/src/test/java/chat/stoat/screens/chat/views/channel/MessageWindowTest.kt @@ -0,0 +1,48 @@ +package chat.stoat.screens.chat.views.channel + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MessageWindowTest { + @Test + fun normalizeByUlidSortsDescendingAndKeepsFirstDuplicate() { + data class Value(val id: String, val content: String) + + val normalized = normalizeByUlid( + listOf( + Value(id(2), "two"), + Value(id(4), "four"), + Value(id(2), "duplicate"), + Value(id(1), "one"), + Value(id(3), "three"), + ) + ) { it.id } + + assertEquals(listOf(id(4), id(3), id(2), id(1)), normalized.map { it.id }) + assertEquals("two", normalized.first { it.id == id(2) }.content) + } + + @Test + fun nearbyBoundariesAreOpenWhenBothSidesFillTheirCapacity() { + val target = 50 + val ids = ((target - 26)..(target + 25)).map(::id) + + assertEquals( + NearbyBoundaries(canLoadNewer = true, canLoadOlder = true), + calculateNearbyBoundaries(ids, id(target), requestedLimit = 50), + ) + } + + @Test + fun nearbyBoundariesCloseAtLatestAndOldestEdges() { + val target = 25 + val ids = (0..target).map(::id) + + assertEquals( + NearbyBoundaries(canLoadNewer = false, canLoadOlder = false), + calculateNearbyBoundaries(ids, id(target), requestedLimit = 50), + ) + } + + private fun id(value: Int): String = value.toString().padStart(26, '0') +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7b18611..ff0ab40d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,9 @@ media3 = "1.9.2" telephoto = "1.0.0-alpha02" haze = "1.7.2" chucker = "4.3.1" -androidx-test = "1.6.1" +androidx-test = "1.7.0" +espresso = "3.7.0" +junit4 = "4.13.2" livekit = "2.26.1" livekit-compose = "2.4.0" koin = "4.2.1" @@ -43,6 +45,7 @@ android-appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" android-material = { module = "com.google.android.material:material", version = "1.13.0" } android-test-core = { module = "androidx.test:runner", version.ref = "androidx-test" } android-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +android-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } android-datastore = { module = "androidx.datastore:datastore", version = "1.1.7" } android-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version = "1.1.7" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } @@ -60,6 +63,7 @@ compose-material-icons-core = { module = "androidx.compose.material:material-ico compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +junit4 = { module = "junit:junit", version.ref = "junit4" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } activity-compose = { module = "androidx.activity:activity-compose", version = "1.10.1" } @@ -123,4 +127,4 @@ aboutlibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", ve ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } google-services = { id = "com.google.gms.google-services", version.ref = "google-services" } sentry-android = { id = "io.sentry.android.gradle", version = "6.5.0" } -sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } \ No newline at end of file +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }