feat: jump to message

This commit is contained in:
infi 2026-07-25 00:56:46 +02:00
parent 847fcaa23b
commit 1d237f0d8e
16 changed files with 1062 additions and 250 deletions

View File

@ -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")
}
}
}
}

View File

@ -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<String>())
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
}

View File

@ -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<String>().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<Any, Int> {
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
}
}

View File

@ -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<IntOffset> = tween(400, easing = EaseInOutExpo)
val StoatTweenSize: FiniteAnimationSpec<IntSize> = tween(400, easing = EaseInOutExpo)
val StoatTweenFloat: FiniteAnimationSpec<Float> = tween(400, easing = EaseInOutExpo)
val StoatTweenDp: FiniteAnimationSpec<Dp> = tween(400, easing = EaseInOutExpo)
val StoatTweenColour: FiniteAnimationSpec<Color> = tween(400, easing = EaseInOutExpo)
@ -691,6 +695,14 @@ fun AppEntrypoint(
}
) { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
val requestedMessageId =
backStackEntry.savedStateHandle.get<String>(
CHANNEL_MESSAGE_JUMP_MESSAGE_KEY
)?.takeIf {
backStackEntry.savedStateHandle.get<String>(
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<String>(
CHANNEL_MESSAGE_JUMP_CHANNEL_KEY
)
backStackEntry.savedStateHandle.remove<String>(
CHANNEL_MESSAGE_JUMP_MESSAGE_KEY
)
},
)
}

View File

@ -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()

View File

@ -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()
) {

View File

@ -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<String>, 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(

View File

@ -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(
}
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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<String>(
CHANNEL_MESSAGE_JUMP_CHANNEL_KEY
)
val requestedMessageId =
currentTopEntry?.savedStateHandle?.get<String>(
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<String>(
CHANNEL_MESSAGE_JUMP_CHANNEL_KEY
)
currentTopEntry?.savedStateHandle?.remove<String>(
CHANNEL_MESSAGE_JUMP_MESSAGE_KEY
)
},
)
}

View File

@ -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()
) {
// <editor-fold desc="State and effects">
@ -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()
}
// </editor-fold>
// <editor-fold desc="Keyboard height handling">
val imeTarget = WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current)
@ -389,15 +419,18 @@ fun ChannelScreen(
// </editor-fold>
// <editor-fold desc="UI elements">
val lazyListState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
var disableScroll by remember { mutableStateOf(false) }
var highlightedMessageId by remember { mutableStateOf<String?>(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)
}
}
// </editor-fold>
// <editor-fold desc="Sheets">
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(
}
}
// </editor-fold>
}
}

View File

@ -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<SendMessageReply>()
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<ChannelScrollRequest?>(null)
private set
var jumpFailure by mutableStateOf<MessageJumpFailure?>(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<String>()
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<Message>()
nearby: String? = null,
sort: String? = null,
): List<Message> {
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<ChannelScreenItem>()
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<Message>): List<ChannelScreenItem> =
messages.map { message ->
if (message.system != null) {
ChannelScreenItem.SystemMessage(message)
} else {
ChannelScreenItem.RegularMessage(message, parseAst(message.content))
}
}
private fun loadedMessageIds(): List<String> = 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<ChannelScreenItem>) {
// 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<ChannelScreenItem.RegularMessage>()
@ -1005,4 +1190,4 @@ class ChannelScreenViewModel(
showPhysicalKeyboardSpark = false
}
}
}
}

View File

@ -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 <T> normalizeByUlid(
values: Iterable<T>,
idOf: (T) -> String?,
): List<T> = values
.mapNotNull { value -> idOf(value)?.let { id -> id to value } }
.distinctBy { (id) -> id }
.sortedByDescending { (id) -> id }
.map { (_, value) -> value }
internal fun calculateNearbyBoundaries(
messageIds: Iterable<String>,
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,
)
}

View File

@ -6,6 +6,8 @@
<string name="ok">OK</string>
<string name="cancel">Cancel</string>
<string name="share">Share</string>
<string name="_new">New</string>
<string name="retry">Retry</string>
<string name="lets_go">Let\'s go</string>
<string name="loading">Fetching some info, hang in there…</string>
<string name="rate_limit_toast">Hold your horses! You\'re doing that too often in a short amount of time.</string>
@ -278,6 +280,7 @@
<string name="message_blocked">Blocked message</string>
<string name="message_failed_to_send">Failed to send, long press for options</string>
<string name="message_jump_failed">Couldnt load that message</string>
<string name="message_not_supported">Message cannot be displayed.</string>
<string name="message_not_supported_with_context">Message cannot be displayed (%1$s).</string>

View File

@ -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')
}

View File

@ -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" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }