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