Merge bfcec338c8 into cc721e9b53
This commit is contained in:
commit
ec3b195004
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue