Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/chat/stoat/callbacks/ActionChannel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
89 changes: 76 additions & 13 deletions app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,6 +120,26 @@ enum class JBMAnnotations(val tag: String, val clickable: Boolean) {

object JBMRegularExpressions {
val Timestamp = Regex("<t:([0-9]+?)(:[tTDfFR])?>")
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(
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
}
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ class ChatRouterViewModel @Inject constructor(
@ApplicationContext val context: Context
) : ViewModel() {
var currentDestination by mutableStateOf<ChatRouterDestination>(ChatRouterDestination.default)
var pendingScrollToMessageId by mutableStateOf<String?>(null)
var latestChangelogRead by mutableStateOf(true)
var latestChangelog by mutableStateOf("")
var latestChangelogBody by mutableStateOf("")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -856,6 +869,8 @@ fun ChatRouterScreen(
toggleDrawerLambda()
},
onEnterVoiceUI = onEnterVoiceUI,
pendingScrollToMessageId = viewModel.pendingScrollToMessageId,
consumePendingScroll = { viewModel.pendingScrollToMessageId = null },
)
}
} else {
Expand Down Expand Up @@ -908,6 +923,8 @@ fun ChatRouterScreen(
setDrawerGestureEnabled = {
useSidebarGesture = it
},
pendingScrollToMessageId = viewModel.pendingScrollToMessageId,
consumePendingScroll = { viewModel.pendingScrollToMessageId = null },
onEnterVoiceUI = onEnterVoiceUI,
)

Expand Down Expand Up @@ -985,6 +1002,8 @@ fun ChannelNavigator(
disableBackHandler: Boolean = false,
onEnterVoiceUI: (String) -> Unit = {},
setDrawerGestureEnabled: (Boolean) -> Unit = {},
pendingScrollToMessageId: String? = null,
consumePendingScroll: () -> Unit = {},
) {
val scope = rememberCoroutineScope()

Expand Down Expand Up @@ -1013,6 +1032,8 @@ fun ChannelNavigator(
is ChatRouterDestination.Channel -> {
ChannelScreen(
channelId = dest.channelId,
pendingScrollToMessageId = pendingScrollToMessageId,
consumePendingScroll = consumePendingScroll,
onToggleDrawer = {
scope.launch {
if (drawerState?.isOpen == true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -221,7 +225,7 @@ fun ChannelScreen(
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)

LaunchedEffect(channelId) {
viewModel.switchChannel(channelId)
viewModel.switchChannel(channelId, pendingScrollToMessageId)
}
// </editor-fold>
// <editor-fold desc="Keyboard height handling">
Expand Down Expand Up @@ -390,6 +394,56 @@ fun ChannelScreen(
// <editor-fold desc="UI elements">
val lazyListState = rememberLazyListState()
var disableScroll by remember { mutableStateOf(false) }
var highlightedMessageId by remember { mutableStateOf<String?>(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 {
Expand Down Expand Up @@ -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,
Expand All @@ -759,6 +829,7 @@ fun ChannelScreen(
replyToMessage = viewModel::addReplyTo,
scope = scope
)
}
}

is ChannelScreenItem.ProspectiveMessage -> {
Expand Down
Loading
Loading