This commit is contained in:
Alex 2026-04-07 03:32:20 +00:00 committed by GitHub
commit ec3b195004
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 257 additions and 22 deletions

View File

@ -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()

View File

@ -80,6 +80,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
@ -118,6 +119,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(
@ -430,31 +451,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()
}
@ -562,6 +608,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
}

View File

@ -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("")
@ -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) {

View File

@ -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)
}
// </editor-fold>
// <editor-fold desc="Keyboard height handling">
@ -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 {
@ -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 -> {

View File

@ -105,6 +105,13 @@ class ChannelScreenViewModel @Inject constructor(
var ageGateUnlocked by mutableStateOf<Boolean?>(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<String?>(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<Message>()
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
}