From dda158a848d10cf1ae404afdf2e1f3572c684fd9 Mon Sep 17 00:00:00 2001 From: sanasol Date: Wed, 4 Mar 2026 22:20:35 +0100 Subject: [PATCH 1/2] feat: add jump-to-message navigation for internal links Signed-off-by: sanasol Signed-off-by: sanasol --- .../chat/stoat/callbacks/ActionChannel.kt | 1 + .../chat/stoat/markdown/jbm/JBMRenderer.kt | 89 ++++++++++++++++--- .../stoat/screens/chat/ChatRouterScreen.kt | 21 +++++ .../chat/views/channel/ChannelScreen.kt | 73 ++++++++++++++- .../views/channel/ChannelScreenViewModel.kt | 88 ++++++++++++++++-- 5 files changed, 250 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/chat/stoat/callbacks/ActionChannel.kt b/app/src/main/java/chat/stoat/callbacks/ActionChannel.kt index 2354f92a..d1d686f1 100644 --- a/app/src/main/java/chat/stoat/callbacks/ActionChannel.kt +++ b/app/src/main/java/chat/stoat/callbacks/ActionChannel.kt @@ -6,6 +6,7 @@ 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 SwitchChannelToMessage(val channelId: String, val messageId: String) : 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/stoat/markdown/jbm/JBMRenderer.kt b/app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt index 30ccf82b..3163d76c 100644 --- a/app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt +++ b/app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt @@ -81,6 +81,7 @@ import chat.stoat.api.internals.isUlid import chat.stoat.api.routes.custom.fetchEmoji import chat.stoat.core.model.schemas.isInviteUri import chat.stoat.api.settings.LoadedSettings +import chat.stoat.api.STOAT_WEB_APP import chat.stoat.callbacks.Action import chat.stoat.callbacks.ActionChannel import chat.stoat.composables.generic.RemoteImage @@ -119,6 +120,26 @@ enum class JBMAnnotations(val tag: String, val clickable: Boolean) { object JBMRegularExpressions { val Timestamp = Regex("") + val InternalLink = Regex("/(?:server/[A-Z0-9]+/)?channel/([A-Z0-9]+)(?:/([A-Z0-9]+))?") +} + +/** + * Resolve an internal URL to a pretty display string like "# General > 💬" + * Returns null if not an internal URL. + */ +fun resolveInternalLinkText(url: String): String? { + try { + val uri = url.toUri() + if (!url.startsWith(STOAT_WEB_APP)) return null + val path = uri.path ?: return null + val match = JBMRegularExpressions.InternalLink.find(path) ?: return null + val channelId = match.groupValues[1] + val messageId = match.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } + val channelName = StoatAPI.channelCache[channelId]?.name ?: "unknown" + return if (messageId != null) " # $channelName \u203A \uD83D\uDCAC " else " # $channelName " + } catch (e: Exception) { + return null + } } data class JBMColors( @@ -431,31 +452,56 @@ private fun annotateText( node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION } ?: node.children.firstOrNull { it.type == MarkdownElementTypes.AUTOLINK } + val linkUrl = linkDestinationChild?.getTextInNode(sourceText).toString() + .removeSurrounding("<", ">") + val prettyText = resolveInternalLinkText(linkUrl) + pushStringAnnotation( tag = JBMAnnotations.URL.tag, - annotation = linkDestinationChild?.getTextInNode(sourceText).toString() - .removeSurrounding("<", ">") + annotation = linkUrl ) - pushStyle(SpanStyle(color = state.colors.clickable)) - linkTextChild?.children - ?.drop(1) // l-bracket - ?.dropLast(1) // r-bracket - ?.forEach { - append(annotateText(state, it)) - } + if (prettyText != null) { + pushStyle( + SpanStyle( + color = state.colors.clickable, + background = state.colors.clickableBackground + ) + ) + append(prettyText) + } else { + pushStyle(SpanStyle(color = state.colors.clickable)) + linkTextChild?.children + ?.drop(1) // l-bracket + ?.dropLast(1) // r-bracket + ?.forEach { + append(annotateText(state, it)) + } + } pop() pop() } GFMTokenTypes.GFM_AUTOLINK, MarkdownTokenTypes.AUTOLINK -> { + val urlText = node.getTextInNode(sourceText).toString() + .removeSurrounding("<", ">") + val prettyText = resolveInternalLinkText(urlText) pushStringAnnotation( tag = JBMAnnotations.URL.tag, - annotation = node.getTextInNode(sourceText).toString() - .removeSurrounding("<", ">") + annotation = urlText ) - pushStyle(SpanStyle(color = state.colors.clickable)) - append(node.getTextInNode(sourceText)) + if (prettyText != null) { + pushStyle( + SpanStyle( + color = state.colors.clickable, + background = state.colors.clickableBackground + ) + ) + append(prettyText) + } else { + pushStyle(SpanStyle(color = state.colors.clickable)) + append(node.getTextInNode(sourceText)) + } pop() pop() } @@ -563,6 +609,23 @@ private fun JBMText(node: ASTNode, modifier: Modifier) { } return@handler true } + // Handle internal channel/message links + if (item.startsWith(STOAT_WEB_APP)) { + val path = uri.path ?: "" + val channelMatch = Regex("/(?:server/[A-Z0-9]+/)?channel/([A-Z0-9]+)(?:/([A-Z0-9]+))?").find(path) + if (channelMatch != null) { + val channelId = channelMatch.groupValues[1] + val messageId = channelMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } + scope.launch { + if (messageId != null) { + ActionChannel.send(Action.SwitchChannelToMessage(channelId, messageId)) + } else { + ActionChannel.send(Action.SwitchChannel(channelId)) + } + } + return@handler true + } + } } catch (e: Exception) { // no-op } diff --git a/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt index 474c5f31..c6a890ba 100644 --- a/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt @@ -152,6 +152,7 @@ class ChatRouterViewModel @Inject constructor( @ApplicationContext val context: Context ) : ViewModel() { var currentDestination by mutableStateOf(ChatRouterDestination.default) + var pendingScrollToMessageId by mutableStateOf(null) var latestChangelogRead by mutableStateOf(true) var latestChangelog by mutableStateOf("") var latestChangelogBody by mutableStateOf("") @@ -422,6 +423,18 @@ fun ChatRouterScreen( viewModel.setSaveDestination(ChatRouterDestination.Channel(action.channelId)) } + is Action.SwitchChannelToMessage -> { + val resolvedChannel = StoatAPI.channelCache[action.channelId] + + if (resolvedChannel == null) { + showChannelUnavailableAlert = true + return@let + } + + viewModel.pendingScrollToMessageId = action.messageId + viewModel.setSaveDestination(ChatRouterDestination.Channel(action.channelId)) + } + is Action.LinkInfo -> { linkInfoSheetUrl = action.url showLinkInfoSheet = true @@ -856,6 +869,8 @@ fun ChatRouterScreen( toggleDrawerLambda() }, onEnterVoiceUI = onEnterVoiceUI, + pendingScrollToMessageId = viewModel.pendingScrollToMessageId, + consumePendingScroll = { viewModel.pendingScrollToMessageId = null }, ) } } else { @@ -908,6 +923,8 @@ fun ChatRouterScreen( setDrawerGestureEnabled = { useSidebarGesture = it }, + pendingScrollToMessageId = viewModel.pendingScrollToMessageId, + consumePendingScroll = { viewModel.pendingScrollToMessageId = null }, onEnterVoiceUI = onEnterVoiceUI, ) @@ -985,6 +1002,8 @@ fun ChannelNavigator( disableBackHandler: Boolean = false, onEnterVoiceUI: (String) -> Unit = {}, setDrawerGestureEnabled: (Boolean) -> Unit = {}, + pendingScrollToMessageId: String? = null, + consumePendingScroll: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -1013,6 +1032,8 @@ fun ChannelNavigator( is ChatRouterDestination.Channel -> { ChannelScreen( channelId = dest.channelId, + pendingScrollToMessageId = pendingScrollToMessageId, + consumePendingScroll = consumePendingScroll, onToggleDrawer = { scope.launch { if (drawerState?.isOpen == true) { diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index f413c0ab..bb6323d2 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.InlineTextContent @@ -86,6 +87,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.draw.drawWithContent import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -190,6 +192,8 @@ private const val NOT_ENOUGH_SPACE_FOR_PANES_THRESHOLD = 500 @Composable fun ChannelScreen( channelId: String, + pendingScrollToMessageId: String? = null, + consumePendingScroll: () -> Unit = {}, onToggleDrawer: () -> Unit, useDrawer: Boolean, useBackButton: Boolean = false, @@ -221,7 +225,7 @@ fun ChannelScreen( val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember) LaunchedEffect(channelId) { - viewModel.switchChannel(channelId) + viewModel.switchChannel(channelId, pendingScrollToMessageId) } // // @@ -390,6 +394,56 @@ fun ChannelScreen( // val lazyListState = rememberLazyListState() var disableScroll by remember { mutableStateOf(false) } + var highlightedMessageId by remember { mutableStateOf(null) } + // Pulse phase: 0f = no highlight, cycles between 0.1-0.35 during pulse, settles at 0.15 + val highlightPulseAlpha = remember { androidx.compose.animation.core.Animatable(0f) } + + // Jump to linked message: scroll if found, otherwise load around it + LaunchedEffect(pendingScrollToMessageId, viewModel.items.size) { + val msgId = pendingScrollToMessageId ?: return@LaunchedEffect + if (viewModel.items.size <= 1) return@LaunchedEffect // still loading + + val index = viewModel.items.indexOfFirst { item -> + when (item) { + is ChannelScreenItem.RegularMessage -> item.message.id == msgId + is ChannelScreenItem.SystemMessage -> item.message.id == msgId + else -> false + } + } + if (index >= 0) { + highlightedMessageId = msgId + // In reverseLayout, scrollToItem places item at viewport bottom. + // First scroll to make item visible, then scroll back to center it. + lazyListState.scrollToItem(index + 1) + val viewportHeight = lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset + lazyListState.animateScrollBy(-viewportHeight / 2f) + consumePendingScroll() + } else if (!viewModel.isInMiddleOfHistory) { + viewModel.jumpToMessage(msgId) + } else { + consumePendingScroll() + } + } + + // Pulse highlight then hold, then fade out + LaunchedEffect(highlightedMessageId) { + if (highlightedMessageId != null) { + // Pulse 3 times (bright → dim) + repeat(3) { + highlightPulseAlpha.animateTo(0.55f, androidx.compose.animation.core.tween(300)) + highlightPulseAlpha.animateTo(0.20f, androidx.compose.animation.core.tween(300)) + } + // Settle at steady highlight + highlightPulseAlpha.animateTo(0.30f, androidx.compose.animation.core.tween(300)) + // Hold for 3 seconds + kotlinx.coroutines.delay(3000) + // Fade out + highlightPulseAlpha.animateTo(0f, androidx.compose.animation.core.tween(1000)) + highlightedMessageId = null + } else { + highlightPulseAlpha.snapTo(0f) + } + } val isScrolledToBottom = remember(lazyListState) { derivedStateOf { @@ -735,6 +789,22 @@ fun ChannelScreen( return@items } + val isHighlighted = highlightedMessageId != null && highlightedMessageId == item.message.id + val pulseAlpha = if (isHighlighted) highlightPulseAlpha.value else 0f + val highlightColor = MaterialTheme.colorScheme.primary + Box( + modifier = Modifier + .fillMaxWidth() + .drawWithContent { + drawContent() + if (pulseAlpha > 0f) { + drawRect( + color = highlightColor, + alpha = pulseAlpha + ) + } + } + ) { RegularMessage( item.message, viewModel.channel, @@ -759,6 +829,7 @@ fun ChannelScreen( replyToMessage = viewModel::addReplyTo, scope = scope ) + } } is ChannelScreenItem.ProspectiveMessage -> { diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt index 5dd82f28..ac571556 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -105,6 +105,13 @@ class ChannelScreenViewModel @Inject constructor( var ageGateUnlocked by mutableStateOf(null) var showGeoGate by mutableStateOf(false) + // When true, we're viewing history around a target message (not at latest) + var isInMiddleOfHistory by mutableStateOf(false) + // The newest message ID in current view (for loading newer messages) + var newestLoadedMessageId by mutableStateOf(null) + // Whether we've reached the latest messages when scrolling forward + var reachedLatest by mutableStateOf(true) + init { viewModelScope.launch { keyboardHeight = kvStorage.getInt("keyboardHeight") ?: 900 // reasonable default for now @@ -113,13 +120,14 @@ class ChannelScreenViewModel @Inject constructor( private var loadMessagesJob: Job? = null - fun switchChannel(id: String) { + fun switchChannel(id: String, targetMessageId: String? = null) { // Reset state this.loadMessagesJob?.cancel() this.channel = StoatAPI.channelCache[id] - this.items = mutableStateListOf(ChannelScreenItem.Loading) + this.items.clear() + this.items.add(ChannelScreenItem.Loading) this.activePane = ChannelScreenActivePane.None - this.typingUsers = mutableStateListOf() + this.typingUsers.clear() this.endOfChannel = false this.didInitialChannelFetch = false this.ensuredSelfMember = false @@ -140,8 +148,8 @@ class ChannelScreenViewModel @Inject constructor( viewModelScope.launch { putDraftContent(kvStorage.get("draftContent/$id") ?: "", true) } - this.draftAttachments = mutableStateListOf() - this.draftReplyTo = mutableStateListOf() + this.draftAttachments.clear() + this.draftReplyTo.clear() this.attachmentUploadProgress = 0f viewModelScope.launch { @@ -149,9 +157,46 @@ class ChannelScreenViewModel @Inject constructor( denyMessageFieldIfNeeded() } + if (targetMessageId != null) { + this.isInMiddleOfHistory = true + this.reachedLatest = false + this.newestLoadedMessageId = null + this.loadMessages(50, around = targetMessageId) + } else { + this.isInMiddleOfHistory = false + this.reachedLatest = true + this.newestLoadedMessageId = null + this.loadMessages(50, markLastAsRead = true) + } + } + + fun jumpToMessage(messageId: String) { + this.loadMessagesJob?.cancel() + this.items.clear() + this.items.add(ChannelScreenItem.Loading) + this.endOfChannel = false + this.isInMiddleOfHistory = true + this.reachedLatest = false + this.newestLoadedMessageId = null + this.loadMessages(50, around = messageId) + } + + fun returnToLatest() { + this.loadMessagesJob?.cancel() + this.items.clear() + this.items.add(ChannelScreenItem.Loading) + this.endOfChannel = false + this.isInMiddleOfHistory = false + this.reachedLatest = true + this.newestLoadedMessageId = null this.loadMessages(50, markLastAsRead = true) } + fun loadNewerMessages() { + val afterId = newestLoadedMessageId ?: return + loadMessages(50, after = afterId) + } + suspend fun unlockAgeGate() { AgeGateUnlockedStorageProvider.setAgeGateUnlocked(true) ageGateUnlocked = true @@ -435,8 +480,13 @@ class ChannelScreenViewModel @Inject constructor( val messages = arrayListOf() fetchMessagesFromChannel(channelId, amount, true, before, after, around).let { - if (it.messages.isNullOrEmpty() || it.messages!!.size < 50) { - endOfChannel = true + if (around == null && (it.messages.isNullOrEmpty() || it.messages!!.size < amount)) { + if (after != null) { + reachedLatest = true + isInMiddleOfHistory = false + } else { + endOfChannel = true + } } it.users?.forEach { user -> @@ -490,12 +540,34 @@ class ChannelScreenViewModel @Inject constructor( 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 + around != null -> { + // Sort newest-first for reverseLayout LazyColumn + newItems.sortedByDescending { item -> + when (item) { + is ChannelScreenItem.RegularMessage -> item.message.id + is ChannelScreenItem.SystemMessage -> item.message.id + else -> "" + } + } + } else -> newItems } updateItems(newItemsWithPosition) + // Track newest loaded message for forward pagination + if (isInMiddleOfHistory) { + newestLoadedMessageId = newItemsWithPosition.firstOrNull { + 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 + } + } + } + if (!didInitialChannelFetch) { didInitialChannelFetch = true } From bfcec338c8a40cb553693e1f12d9f290598e6205 Mon Sep 17 00:00:00 2001 From: sanasol Date: Wed, 4 Mar 2026 23:05:40 +0100 Subject: [PATCH 2/2] fix: prevent channel wipe on WS reconnect race condition When the WS connects (including first connection), pushReconnectEvent() triggers loadMessages(50, ignoreExisting=true). If the initial load already fetched the same messages, ignoreExisting filters them all out, producing an empty list that updateItems() uses to clear the channel. Skip the update entirely when ignoreExisting yields no new messages. Signed-off-by: sanasol --- .../screens/chat/views/channel/ChannelScreenViewModel.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt index ac571556..98467e00 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -535,6 +535,13 @@ class ChannelScreenViewModel @Inject constructor( } } + // Skip update if ignoreExisting filtered out all messages (e.g. WS reconnect + // fetched the same messages already loaded) — otherwise updateItems([]) wipes the list + if (ignoreExisting && newItems.isEmpty()) { + if (!didInitialChannelFetch) didInitialChannelFetch = true + return@launch + } + // 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 {