diff --git a/app/src/main/java/chat/peptide/callbacks/ActionChannel.kt b/app/src/main/java/chat/peptide/callbacks/ActionChannel.kt
index bce8cff7..a674e917 100644
--- a/app/src/main/java/chat/peptide/callbacks/ActionChannel.kt
+++ b/app/src/main/java/chat/peptide/callbacks/ActionChannel.kt
@@ -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()
diff --git a/app/src/main/java/chat/peptide/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/peptide/screens/chat/ChatRouterScreen.kt
index bd2d2e4c..99f892f4 100644
--- a/app/src/main/java/chat/peptide/screens/chat/ChatRouterScreen.kt
+++ b/app/src/main/java/chat/peptide/screens/chat/ChatRouterScreen.kt
@@ -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(
diff --git a/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt
index 856e47fe..2f757f33 100644
--- a/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt
+++ b/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt
@@ -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(
//
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)
- LaunchedEffect(channelId) {
- viewModel.switchChannel(channelId)
+ LaunchedEffect(channelId, messageId) {
+ viewModel.switchChannel(channelId, skipInitialLoad = messageId != null)
}
//
//
@@ -393,6 +397,14 @@ fun ChannelScreen(
//
val lazyListState = rememberLazyListState()
var disableScroll by remember { mutableStateOf(false) }
+
+ // State to track the highlighted message ID and animation
+ var highlightedMessageId by remember { mutableStateOf(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)
+ }
+ }
+ }
}
+
//
//
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
diff --git a/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreenViewModel.kt
index 9a245855..b648e3c2 100644
--- a/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreenViewModel.kt
+++ b/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreenViewModel.kt
@@ -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) {
diff --git a/app/src/main/java/chat/peptide/utils/InternalLinkHandler.kt b/app/src/main/java/chat/peptide/utils/InternalLinkHandler.kt
index f09e703e..d0925d21 100644
--- a/app/src/main/java/chat/peptide/utils/InternalLinkHandler.kt
+++ b/app/src/main/java/chat/peptide/utils/InternalLinkHandler.kt
@@ -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