feat: jump to message
This commit is contained in:
parent
847fcaa23b
commit
1d237f0d8e
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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">Couldn’t 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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in New Issue