Merge bfcec338c8 into cc721e9b53
This commit is contained in:
commit
ec3b195004
|
|
@ -6,6 +6,7 @@ import kotlinx.coroutines.channels.Channel
|
||||||
sealed class Action {
|
sealed class Action {
|
||||||
data class OpenUserSheet(val userId: String, val serverId: String?) : Action()
|
data class OpenUserSheet(val userId: String, val serverId: String?) : Action()
|
||||||
data class SwitchChannel(val channelId: 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 LinkInfo(val url: String) : Action()
|
||||||
data class EmoteInfo(val emoteId: String) : Action()
|
data class EmoteInfo(val emoteId: String) : Action()
|
||||||
data class MessageReactionInfo(val messageId: String, val emoji: 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.api.routes.custom.fetchEmoji
|
||||||
import chat.stoat.core.model.schemas.isInviteUri
|
import chat.stoat.core.model.schemas.isInviteUri
|
||||||
import chat.stoat.api.settings.LoadedSettings
|
import chat.stoat.api.settings.LoadedSettings
|
||||||
|
import chat.stoat.api.STOAT_WEB_APP
|
||||||
import chat.stoat.callbacks.Action
|
import chat.stoat.callbacks.Action
|
||||||
import chat.stoat.callbacks.ActionChannel
|
import chat.stoat.callbacks.ActionChannel
|
||||||
import chat.stoat.composables.generic.RemoteImage
|
import chat.stoat.composables.generic.RemoteImage
|
||||||
|
|
@ -118,6 +119,26 @@ enum class JBMAnnotations(val tag: String, val clickable: Boolean) {
|
||||||
|
|
||||||
object JBMRegularExpressions {
|
object JBMRegularExpressions {
|
||||||
val Timestamp = Regex("<t:([0-9]+?)(:[tTDfFR])?>")
|
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(
|
data class JBMColors(
|
||||||
|
|
@ -430,31 +451,56 @@ private fun annotateText(
|
||||||
node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION }
|
node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION }
|
||||||
?: node.children.firstOrNull { it.type == MarkdownElementTypes.AUTOLINK }
|
?: node.children.firstOrNull { it.type == MarkdownElementTypes.AUTOLINK }
|
||||||
|
|
||||||
|
val linkUrl = linkDestinationChild?.getTextInNode(sourceText).toString()
|
||||||
|
.removeSurrounding("<", ">")
|
||||||
|
val prettyText = resolveInternalLinkText(linkUrl)
|
||||||
|
|
||||||
pushStringAnnotation(
|
pushStringAnnotation(
|
||||||
tag = JBMAnnotations.URL.tag,
|
tag = JBMAnnotations.URL.tag,
|
||||||
annotation = linkDestinationChild?.getTextInNode(sourceText).toString()
|
annotation = linkUrl
|
||||||
.removeSurrounding("<", ">")
|
|
||||||
)
|
)
|
||||||
pushStyle(SpanStyle(color = state.colors.clickable))
|
if (prettyText != null) {
|
||||||
linkTextChild?.children
|
pushStyle(
|
||||||
?.drop(1) // l-bracket
|
SpanStyle(
|
||||||
?.dropLast(1) // r-bracket
|
color = state.colors.clickable,
|
||||||
?.forEach {
|
background = state.colors.clickableBackground
|
||||||
append(annotateText(state, it))
|
)
|
||||||
}
|
)
|
||||||
|
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()
|
||||||
pop()
|
pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
GFMTokenTypes.GFM_AUTOLINK,
|
GFMTokenTypes.GFM_AUTOLINK,
|
||||||
MarkdownTokenTypes.AUTOLINK -> {
|
MarkdownTokenTypes.AUTOLINK -> {
|
||||||
|
val urlText = node.getTextInNode(sourceText).toString()
|
||||||
|
.removeSurrounding("<", ">")
|
||||||
|
val prettyText = resolveInternalLinkText(urlText)
|
||||||
pushStringAnnotation(
|
pushStringAnnotation(
|
||||||
tag = JBMAnnotations.URL.tag,
|
tag = JBMAnnotations.URL.tag,
|
||||||
annotation = node.getTextInNode(sourceText).toString()
|
annotation = urlText
|
||||||
.removeSurrounding("<", ">")
|
|
||||||
)
|
)
|
||||||
pushStyle(SpanStyle(color = state.colors.clickable))
|
if (prettyText != null) {
|
||||||
append(node.getTextInNode(sourceText))
|
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()
|
||||||
pop()
|
pop()
|
||||||
}
|
}
|
||||||
|
|
@ -562,6 +608,23 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
|
||||||
}
|
}
|
||||||
return@handler true
|
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) {
|
} catch (e: Exception) {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ class ChatRouterViewModel @Inject constructor(
|
||||||
@ApplicationContext val context: Context
|
@ApplicationContext val context: Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
var currentDestination by mutableStateOf<ChatRouterDestination>(ChatRouterDestination.default)
|
var currentDestination by mutableStateOf<ChatRouterDestination>(ChatRouterDestination.default)
|
||||||
|
var pendingScrollToMessageId by mutableStateOf<String?>(null)
|
||||||
var latestChangelogRead by mutableStateOf(true)
|
var latestChangelogRead by mutableStateOf(true)
|
||||||
var latestChangelog by mutableStateOf("")
|
var latestChangelog by mutableStateOf("")
|
||||||
var latestChangelogBody by mutableStateOf("")
|
var latestChangelogBody by mutableStateOf("")
|
||||||
|
|
@ -422,6 +423,18 @@ fun ChatRouterScreen(
|
||||||
viewModel.setSaveDestination(ChatRouterDestination.Channel(action.channelId))
|
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 -> {
|
is Action.LinkInfo -> {
|
||||||
linkInfoSheetUrl = action.url
|
linkInfoSheetUrl = action.url
|
||||||
showLinkInfoSheet = true
|
showLinkInfoSheet = true
|
||||||
|
|
@ -856,6 +869,8 @@ fun ChatRouterScreen(
|
||||||
toggleDrawerLambda()
|
toggleDrawerLambda()
|
||||||
},
|
},
|
||||||
onEnterVoiceUI = onEnterVoiceUI,
|
onEnterVoiceUI = onEnterVoiceUI,
|
||||||
|
pendingScrollToMessageId = viewModel.pendingScrollToMessageId,
|
||||||
|
consumePendingScroll = { viewModel.pendingScrollToMessageId = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -908,6 +923,8 @@ fun ChatRouterScreen(
|
||||||
setDrawerGestureEnabled = {
|
setDrawerGestureEnabled = {
|
||||||
useSidebarGesture = it
|
useSidebarGesture = it
|
||||||
},
|
},
|
||||||
|
pendingScrollToMessageId = viewModel.pendingScrollToMessageId,
|
||||||
|
consumePendingScroll = { viewModel.pendingScrollToMessageId = null },
|
||||||
onEnterVoiceUI = onEnterVoiceUI,
|
onEnterVoiceUI = onEnterVoiceUI,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -985,6 +1002,8 @@ fun ChannelNavigator(
|
||||||
disableBackHandler: Boolean = false,
|
disableBackHandler: Boolean = false,
|
||||||
onEnterVoiceUI: (String) -> Unit = {},
|
onEnterVoiceUI: (String) -> Unit = {},
|
||||||
setDrawerGestureEnabled: (Boolean) -> Unit = {},
|
setDrawerGestureEnabled: (Boolean) -> Unit = {},
|
||||||
|
pendingScrollToMessageId: String? = null,
|
||||||
|
consumePendingScroll: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -1013,6 +1032,8 @@ fun ChannelNavigator(
|
||||||
is ChatRouterDestination.Channel -> {
|
is ChatRouterDestination.Channel -> {
|
||||||
ChannelScreen(
|
ChannelScreen(
|
||||||
channelId = dest.channelId,
|
channelId = dest.channelId,
|
||||||
|
pendingScrollToMessageId = pendingScrollToMessageId,
|
||||||
|
consumePendingScroll = consumePendingScroll,
|
||||||
onToggleDrawer = {
|
onToggleDrawer = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (drawerState?.isOpen == true) {
|
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.requiredHeight
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.text.InlineTextContent
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
|
@ -86,6 +87,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -190,6 +192,8 @@ private const val NOT_ENOUGH_SPACE_FOR_PANES_THRESHOLD = 500
|
||||||
@Composable
|
@Composable
|
||||||
fun ChannelScreen(
|
fun ChannelScreen(
|
||||||
channelId: String,
|
channelId: String,
|
||||||
|
pendingScrollToMessageId: String? = null,
|
||||||
|
consumePendingScroll: () -> Unit = {},
|
||||||
onToggleDrawer: () -> Unit,
|
onToggleDrawer: () -> Unit,
|
||||||
useDrawer: Boolean,
|
useDrawer: Boolean,
|
||||||
useBackButton: Boolean = false,
|
useBackButton: Boolean = false,
|
||||||
|
|
@ -221,7 +225,7 @@ fun ChannelScreen(
|
||||||
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)
|
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)
|
||||||
|
|
||||||
LaunchedEffect(channelId) {
|
LaunchedEffect(channelId) {
|
||||||
viewModel.switchChannel(channelId)
|
viewModel.switchChannel(channelId, pendingScrollToMessageId)
|
||||||
}
|
}
|
||||||
// </editor-fold>
|
// </editor-fold>
|
||||||
// <editor-fold desc="Keyboard height handling">
|
// <editor-fold desc="Keyboard height handling">
|
||||||
|
|
@ -390,6 +394,56 @@ fun ChannelScreen(
|
||||||
// <editor-fold desc="UI elements">
|
// <editor-fold desc="UI elements">
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
var disableScroll by remember { mutableStateOf(false) }
|
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) {
|
val isScrolledToBottom = remember(lazyListState) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
|
@ -735,6 +789,22 @@ fun ChannelScreen(
|
||||||
return@items
|
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(
|
RegularMessage(
|
||||||
item.message,
|
item.message,
|
||||||
viewModel.channel,
|
viewModel.channel,
|
||||||
|
|
@ -759,6 +829,7 @@ fun ChannelScreen(
|
||||||
replyToMessage = viewModel::addReplyTo,
|
replyToMessage = viewModel::addReplyTo,
|
||||||
scope = scope
|
scope = scope
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is ChannelScreenItem.ProspectiveMessage -> {
|
is ChannelScreenItem.ProspectiveMessage -> {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,13 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
var ageGateUnlocked by mutableStateOf<Boolean?>(null)
|
var ageGateUnlocked by mutableStateOf<Boolean?>(null)
|
||||||
var showGeoGate by mutableStateOf(false)
|
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 {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
keyboardHeight = kvStorage.getInt("keyboardHeight") ?: 900 // reasonable default for now
|
keyboardHeight = kvStorage.getInt("keyboardHeight") ?: 900 // reasonable default for now
|
||||||
|
|
@ -113,13 +120,14 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
|
|
||||||
private var loadMessagesJob: Job? = null
|
private var loadMessagesJob: Job? = null
|
||||||
|
|
||||||
fun switchChannel(id: String) {
|
fun switchChannel(id: String, targetMessageId: String? = null) {
|
||||||
// Reset state
|
// Reset state
|
||||||
this.loadMessagesJob?.cancel()
|
this.loadMessagesJob?.cancel()
|
||||||
this.channel = StoatAPI.channelCache[id]
|
this.channel = StoatAPI.channelCache[id]
|
||||||
this.items = mutableStateListOf(ChannelScreenItem.Loading)
|
this.items.clear()
|
||||||
|
this.items.add(ChannelScreenItem.Loading)
|
||||||
this.activePane = ChannelScreenActivePane.None
|
this.activePane = ChannelScreenActivePane.None
|
||||||
this.typingUsers = mutableStateListOf()
|
this.typingUsers.clear()
|
||||||
this.endOfChannel = false
|
this.endOfChannel = false
|
||||||
this.didInitialChannelFetch = false
|
this.didInitialChannelFetch = false
|
||||||
this.ensuredSelfMember = false
|
this.ensuredSelfMember = false
|
||||||
|
|
@ -140,8 +148,8 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
putDraftContent(kvStorage.get("draftContent/$id") ?: "", true)
|
putDraftContent(kvStorage.get("draftContent/$id") ?: "", true)
|
||||||
}
|
}
|
||||||
this.draftAttachments = mutableStateListOf()
|
this.draftAttachments.clear()
|
||||||
this.draftReplyTo = mutableStateListOf()
|
this.draftReplyTo.clear()
|
||||||
this.attachmentUploadProgress = 0f
|
this.attachmentUploadProgress = 0f
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -149,9 +157,46 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
denyMessageFieldIfNeeded()
|
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)
|
this.loadMessages(50, markLastAsRead = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadNewerMessages() {
|
||||||
|
val afterId = newestLoadedMessageId ?: return
|
||||||
|
loadMessages(50, after = afterId)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun unlockAgeGate() {
|
suspend fun unlockAgeGate() {
|
||||||
AgeGateUnlockedStorageProvider.setAgeGateUnlocked(true)
|
AgeGateUnlockedStorageProvider.setAgeGateUnlocked(true)
|
||||||
ageGateUnlocked = true
|
ageGateUnlocked = true
|
||||||
|
|
@ -435,8 +480,13 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
val messages = arrayListOf<Message>()
|
val messages = arrayListOf<Message>()
|
||||||
|
|
||||||
fetchMessagesFromChannel(channelId, amount, true, before, after, around).let {
|
fetchMessagesFromChannel(channelId, amount, true, before, after, around).let {
|
||||||
if (it.messages.isNullOrEmpty() || it.messages!!.size < 50) {
|
if (around == null && (it.messages.isNullOrEmpty() || it.messages!!.size < amount)) {
|
||||||
endOfChannel = true
|
if (after != null) {
|
||||||
|
reachedLatest = true
|
||||||
|
isInMiddleOfHistory = false
|
||||||
|
} else {
|
||||||
|
endOfChannel = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it.users?.forEach { user ->
|
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.
|
// Place items according to whether above/below/around was specified.
|
||||||
// TODO: Aditionally, place LoadTriggers at the beginning and end of the list.
|
// TODO: Aditionally, place LoadTriggers at the beginning and end of the list.
|
||||||
val newItemsWithPosition = when {
|
val newItemsWithPosition = when {
|
||||||
before != null -> items + newItems
|
before != null -> items + newItems
|
||||||
after != null -> newItems + items
|
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
|
else -> newItems
|
||||||
}
|
}
|
||||||
|
|
||||||
updateItems(newItemsWithPosition)
|
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) {
|
if (!didInitialChannelFetch) {
|
||||||
didInitialChannelFetch = true
|
didInitialChannelFetch = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue