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..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 @@ -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 -> @@ -485,17 +535,46 @@ 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 { 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 }