feat: Enhance channel navigation and message handling
This commit introduces improvements to the channel navigation and message handling within the chat application. - Updated `ActionChannel` to include an optional `messageId` parameter in the `SwitchChannel` action for better context during channel switches. - Modified `ChatRouterScreen` to parse and handle the new `messageId` parameter when navigating to channels. - Enhanced `ChannelScreen` to load messages around a specified `messageId`, allowing users to jump directly to important messages. - Implemented highlighting for the targeted message in the `ChannelScreen` to improve user experience. - Updated `ChannelScreenViewModel` to support the new message loading logic based on the `messageId` parameter. - Refactored related components to ensure clean architecture principles are maintained and improve overall code organization.
This commit is contained in:
parent
7346bc393f
commit
072fcbf5eb
|
|
@ -5,7 +5,10 @@ import kotlinx.coroutines.channels.Channel
|
|||
|
||||
sealed class Action {
|
||||
data class OpenUserSheet(val userId: String, val serverId: String?) : Action()
|
||||
data class SwitchChannel(val channelId: String) : Action()
|
||||
data class SwitchChannel(
|
||||
val channelId: String,
|
||||
val messageId: String? = null,
|
||||
) : Action()
|
||||
data class LinkInfo(val url: String) : Action()
|
||||
data class EmoteInfo(val emoteId: String) : Action()
|
||||
data class MessageReactionInfo(val messageId: String, val emoji: String) : Action()
|
||||
|
|
|
|||
|
|
@ -108,7 +108,10 @@ sealed class ChatRouterDestination {
|
|||
data object Friends : ChatRouterDestination()
|
||||
data object Home : ChatRouterDestination()
|
||||
data object Discover : ChatRouterDestination()
|
||||
data class Channel(val channelId: String) : ChatRouterDestination()
|
||||
data class Channel(
|
||||
val channelId: String,
|
||||
val messageId: String? = null,
|
||||
) : ChatRouterDestination()
|
||||
data class ServersChannels(val serverID: String) : ChatRouterDestination()
|
||||
data class NoCurrentChannel(val serverId: String?) : ChatRouterDestination()
|
||||
|
||||
|
|
@ -126,7 +129,16 @@ sealed class ChatRouterDestination {
|
|||
)
|
||||
)
|
||||
|
||||
destination.startsWith("channel/") -> Channel(destination.removePrefix("channel/"))
|
||||
destination.startsWith("channel/") -> {
|
||||
val parts = destination.removePrefix("channel/").split("/")
|
||||
val channelId = parts.getOrNull(0)
|
||||
val messageId = parts.getOrNull(1)
|
||||
if (channelId != null) {
|
||||
Channel(channelId, messageId)
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
else -> default
|
||||
}
|
||||
}
|
||||
|
|
@ -446,7 +458,8 @@ fun ChatRouterScreen(
|
|||
|
||||
viewModel.setSaveDestination(
|
||||
ChatRouterDestination.Channel(
|
||||
action.channelId
|
||||
channelId = action.channelId,
|
||||
messageId = action.messageId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -891,6 +904,7 @@ fun ChannelNavigator(
|
|||
is ChatRouterDestination.Channel -> {
|
||||
ChannelScreen(
|
||||
channelId = dest.channelId,
|
||||
messageId = dest.messageId,
|
||||
backToChannelsScreen = {
|
||||
currentServer?.let {
|
||||
viewModel.setSaveDestination(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ import androidx.compose.animation.AnimatedContent
|
|||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
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.slideInVertically
|
||||
|
|
@ -49,6 +51,7 @@ import androidx.compose.foundation.layout.requiredHeight
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
|
|
@ -189,6 +192,7 @@ private const val NOT_ENOUGH_SPACE_FOR_PANES_THRESHOLD = 500
|
|||
@Composable
|
||||
fun ChannelScreen(
|
||||
channelId: String,
|
||||
messageId: String? = null,
|
||||
useDrawer: Boolean = false,
|
||||
backToChannelsScreen: (() -> Unit)?,
|
||||
useBackButton: Boolean = false,
|
||||
|
|
@ -223,8 +227,8 @@ fun ChannelScreen(
|
|||
// <editor-fold desc="Load/switch channel">
|
||||
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)
|
||||
|
||||
LaunchedEffect(channelId) {
|
||||
viewModel.switchChannel(channelId)
|
||||
LaunchedEffect(channelId, messageId) {
|
||||
viewModel.switchChannel(channelId, skipInitialLoad = messageId != null)
|
||||
}
|
||||
// </editor-fold>
|
||||
// <editor-fold desc="Keyboard height handling">
|
||||
|
|
@ -393,6 +397,14 @@ fun ChannelScreen(
|
|||
// <editor-fold desc="UI elements">
|
||||
val lazyListState = rememberLazyListState()
|
||||
var disableScroll by remember { mutableStateOf(false) }
|
||||
|
||||
// State to track the highlighted message ID and animation
|
||||
var highlightedMessageId by remember { mutableStateOf<String?>(null) }
|
||||
val highlightAlpha by animateFloatAsState(
|
||||
targetValue = if (highlightedMessageId != null) 0.3f else 0f,
|
||||
animationSpec = tween(durationMillis = 1500),
|
||||
finishedListener = { highlightedMessageId = null }
|
||||
)
|
||||
|
||||
val isScrolledToBottom = remember(lazyListState) {
|
||||
derivedStateOf {
|
||||
|
|
@ -421,24 +433,61 @@ fun ChannelScreen(
|
|||
// Load more messages when we reach the top of the list
|
||||
// TODO: Temp - use LoadTrigger instead
|
||||
|
||||
LaunchedEffect(isNearTop) {
|
||||
snapshotFlow { isNearTop.value }
|
||||
.distinctUntilChanged()
|
||||
.collect { isNearTop ->
|
||||
if (isNearTop) {
|
||||
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)
|
||||
messageId?.let { id ->
|
||||
LaunchedEffect(id) {
|
||||
Log.d("ChannelScreen", "Loading around message: $id")
|
||||
// Load 60 messages centered on the target (API returns around window)
|
||||
viewModel.loadMessages(amount = 60, around = id)
|
||||
|
||||
// Wait until the target message appears (poll with timeout)
|
||||
var attempts = 0
|
||||
var messageIndex = -1
|
||||
while (attempts < 30 && messageIndex == -1) { // ~3s max
|
||||
kotlinx.coroutines.delay(100)
|
||||
messageIndex = viewModel.items.indexOfFirst { item ->
|
||||
when (item) {
|
||||
is ChannelScreenItem.RegularMessage -> item.message.id == id
|
||||
is ChannelScreenItem.SystemMessage -> item.message.id == id
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (messageIndex != -1) {
|
||||
Log.d("ChannelScreen", "Target message located at index: $messageIndex (reverseLayout=true)")
|
||||
// Instantly jump to the message without long animation
|
||||
try {
|
||||
lazyListState.scrollToItem(index = messageIndex)
|
||||
highlightedMessageId = id
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreen", "Error scrolling to message", e)
|
||||
}
|
||||
} else {
|
||||
Log.w("ChannelScreen", "Target message not found after loading around window")
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
LaunchedEffect(isNearTop) {
|
||||
snapshotFlow { isNearTop.value }
|
||||
.distinctUntilChanged()
|
||||
.collect { isNearTop ->
|
||||
if (isNearTop) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// </editor-fold>
|
||||
// <editor-fold desc="Sheets">
|
||||
var channelInfoSheetShown by remember { mutableStateOf(false) }
|
||||
|
|
@ -703,21 +752,20 @@ fun ChannelScreen(
|
|||
}
|
||||
|
||||
items(
|
||||
viewModel.items.size,
|
||||
key = { index ->
|
||||
when (val item = viewModel.items[index]) {
|
||||
is ChannelScreenItem.RegularMessage -> item.message.id!!
|
||||
is ChannelScreenItem.ProspectiveMessage -> item.message.id!!
|
||||
is ChannelScreenItem.FailedMessage -> item.message.id!!
|
||||
is ChannelScreenItem.SystemMessage -> item.message.id!!
|
||||
is ChannelScreenItem.DateDivider -> item.instant.toEpochMilliseconds()
|
||||
is ChannelScreenItem.LoadTrigger -> index
|
||||
is ChannelScreenItem.Loading -> index
|
||||
items = viewModel.items,
|
||||
key = { item ->
|
||||
when (item) {
|
||||
is ChannelScreenItem.RegularMessage -> item.message.id ?: "regular_null_id"
|
||||
is ChannelScreenItem.ProspectiveMessage -> item.message.id ?: "prospective_null_id"
|
||||
is ChannelScreenItem.FailedMessage -> item.message.id ?: "failed_null_id"
|
||||
is ChannelScreenItem.SystemMessage -> item.message.id ?: "system_null_id"
|
||||
is ChannelScreenItem.DateDivider -> "date_${item.instant.toEpochMilliseconds()}"
|
||||
is ChannelScreenItem.LoadTrigger -> "loadtrigger_${item.after}_${item.before}"
|
||||
is ChannelScreenItem.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
contentType = { index ->
|
||||
when (viewModel.items.getOrNull(index)) {
|
||||
null -> null
|
||||
contentType = { item ->
|
||||
when (item) {
|
||||
is ChannelScreenItem.RegularMessage -> "RegularMessage"
|
||||
is ChannelScreenItem.ProspectiveMessage -> "ProspectiveMessage"
|
||||
is ChannelScreenItem.FailedMessage -> "FailedMessage"
|
||||
|
|
@ -727,32 +775,46 @@ fun ChannelScreen(
|
|||
is ChannelScreenItem.Loading -> "Loading"
|
||||
}
|
||||
}
|
||||
) { index ->
|
||||
when (val item = viewModel.items[index]) {
|
||||
) { item ->
|
||||
when (item) {
|
||||
is ChannelScreenItem.RegularMessage -> {
|
||||
RegularMessage(
|
||||
item.message,
|
||||
viewModel.channel,
|
||||
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
|
||||
)
|
||||
// Check if this message should be highlighted
|
||||
val isHighlighted = item.message.id == highlightedMessageId
|
||||
|
||||
// Apply highlight effect if needed
|
||||
Box(
|
||||
modifier = if (isHighlighted) {
|
||||
Modifier.background(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = highlightAlpha)
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
) {
|
||||
RegularMessage(
|
||||
item.message,
|
||||
viewModel.channel,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ChannelScreenItem.ProspectiveMessage -> {
|
||||
|
|
@ -803,7 +865,21 @@ fun ChannelScreen(
|
|||
}
|
||||
|
||||
is ChannelScreenItem.SystemMessage -> {
|
||||
SystemMessage(message = item.message)
|
||||
// Check if this message should be highlighted
|
||||
val isHighlighted = item.message.id == highlightedMessageId
|
||||
|
||||
// Apply highlight effect if needed
|
||||
Box(
|
||||
modifier = if (isHighlighted) {
|
||||
Modifier.background(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = highlightAlpha)
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
) {
|
||||
SystemMessage(message = item.message)
|
||||
}
|
||||
}
|
||||
|
||||
is ChannelScreenItem.DateDivider -> {
|
||||
|
|
@ -858,9 +934,10 @@ fun ChannelScreen(
|
|||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
// Clear the around-view and reload latest messages
|
||||
viewModel.reloadLatest()
|
||||
// Jump to bottom instantly (latest)
|
||||
scope.launch { lazyListState.scrollToItem(0) }
|
||||
},
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ class ChannelScreenViewModel @Inject constructor(
|
|||
|
||||
private var loadMessagesJob: Job? = null
|
||||
|
||||
fun switchChannel(id: String) {
|
||||
fun switchChannel(id: String, skipInitialLoad: Boolean = false) {
|
||||
// Reset state
|
||||
this.loadMessagesJob?.cancel()
|
||||
this.channel = RevoltAPI.channelCache[id]
|
||||
|
|
@ -150,6 +150,17 @@ class ChannelScreenViewModel @Inject constructor(
|
|||
denyMessageFieldIfNeeded()
|
||||
}
|
||||
|
||||
if (!skipInitialLoad) {
|
||||
this.loadMessages(50, markLastAsRead = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadLatest() {
|
||||
// Reset only message list state and fetch latest messages
|
||||
this.loadMessagesJob?.cancel()
|
||||
this.items = mutableStateListOf(ChannelScreenItem.Loading)
|
||||
this.endOfChannel = false
|
||||
this.didInitialChannelFetch = false
|
||||
this.loadMessages(50, markLastAsRead = true)
|
||||
}
|
||||
|
||||
|
|
@ -465,7 +476,16 @@ class ChannelScreenViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val newItems = messages.filter {
|
||||
// Ensure messages are ordered newest -> oldest consistently
|
||||
val sortedMessages = messages.sortedByDescending { m ->
|
||||
try {
|
||||
ULID.asTimestamp(m.id ?: "0")
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
val newItems = sortedMessages.filter {
|
||||
if (ignoreExisting) {
|
||||
items.none { m ->
|
||||
when (m) {
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ object InternalLinkHandler {
|
|||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
Log.d(TAG, "Sending SwitchChannel action for channelId: $channelId")
|
||||
ActionChannel.send(Action.SwitchChannel(channelId))
|
||||
ActionChannel.send(Action.SwitchChannel(channelId,messageId))
|
||||
Log.d(TAG, "SwitchChannel action sent successfully")
|
||||
|
||||
// TODO: If messageId is not null, add logic to scroll to the message
|
||||
|
|
|
|||
Loading…
Reference in New Issue