Fix caching

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-02 18:01:05 +03:00
Unverified
parent ee87a70f14
commit c1960f2aa0
15 changed files with 528 additions and 116 deletions
@@ -0,0 +1,47 @@
package ru.fromchat.net
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.api.WebSocketManager
actual object NetworkConnectivity {
private val _isOnline = MutableStateFlow(true)
actual val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
private var callback: ConnectivityManager.NetworkCallback? = null
@Suppress("DEPRECATION")
private fun computeOnline(cm: ConnectivityManager): Boolean {
val n = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(n) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
actual fun ensureStarted() {
if (callback != null) return
val context = UtilsLibrary.context
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
_isOnline.value = computeOnline(cm)
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isOnline.value = true
WebSocketManager.onNetworkAvailable()
}
override fun onLost(network: Network) {
_isOnline.value = false
WebSocketManager.onNetworkLost()
}
}
callback = cb
cm.registerNetworkCallback(NetworkRequest.Builder().build(), cb)
}
}
@@ -30,6 +30,8 @@ import kotlin.time.Clock
import kotlin.time.ExperimentalTime
object WebSocketManager {
private const val RECONNECT_DELAY_MS = 1_000L
// Config
private val scope = CoroutineScope(Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true }
@@ -72,21 +74,26 @@ object WebSocketManager {
return session != null
}
fun connect() {
Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting")
fun connect(forceRestart: Boolean = false) {
Logger.d(
"WebSocketManager",
"connect(forceRestart=$forceRestart) called. current session=${session != null}, connecting=$connecting"
)
if (forceRestart) {
connectionJob?.cancel()
connectionJob = null
} else {
val existingJob = connectionJob
if (existingJob != null && existingJob.isActive) {
Logger.d("WebSocketManager", "connect() ignored: connectionJob already running")
return
}
}
// Always reflect that we are trying to connect while this loop is active
ConnectionStateStore.onConnecting()
connectionJob = scope.launch {
var backoffMs = 1_000L
while (isActive) {
Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive")
@@ -94,7 +101,7 @@ object WebSocketManager {
if (token.isNullOrEmpty()) {
Logger.d("WebSocketManager", "No auth token available; staying in CONNECTING and retrying later")
ConnectionStateStore.onConnecting()
delay(backoffMs.coerceAtMost(5_000L))
delay(RECONNECT_DELAY_MS)
continue
}
@@ -112,7 +119,6 @@ object WebSocketManager {
) {
session = this
connecting = false
backoffMs = 1_000L
Logger.d("WebSocketManager", "WebSocket connected. connecting set to false")
ConnectionStateStore.onConnected()
@@ -188,9 +194,8 @@ object WebSocketManager {
ConnectionStateStore.onConnecting()
if (isActive) {
Logger.d("WebSocketManager", "Reconnecting in ${backoffMs}ms...")
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(15_000L)
Logger.d("WebSocketManager", "Reconnecting in ${RECONNECT_DELAY_MS}ms...")
delay(RECONNECT_DELAY_MS)
}
}
}
@@ -268,4 +273,19 @@ object WebSocketManager {
connecting = false
Logger.d("WebSocketManager", "Disconnected. session set to null, connecting set to false")
}
/** OS reported loss of network: fail fast and show connecting until back online. */
fun onNetworkLost() {
Logger.d("WebSocketManager", "onNetworkLost")
connectionJob?.cancel()
connectionJob = null
disconnect()
ConnectionStateStore.onConnecting()
}
/** OS reported network available: restart the 1s reconnect loop immediately. */
fun onNetworkAvailable() {
Logger.d("WebSocketManager", "onNetworkAvailable")
connect(forceRestart = true)
}
}
@@ -33,7 +33,21 @@ object MessageCacheStore {
}
suspend fun replacePublicMessages(messages: List<Message>) {
replaceMessages(conversationIdForPublic(), messages)
val pending = loadPublicMessages().filter { it.id < 0 }
val stillPending = pending.filter { p ->
val cid = p.client_message_id
cid == null || messages.none { it.client_message_id == cid }
}
val merged = (messages + stillPending)
.distinctBy { msg ->
when {
msg.id > 0 -> "i:${msg.id}"
msg.client_message_id != null -> "c:${msg.client_message_id}"
else -> "i:${msg.id}"
}
}
.sortedBy { it.timestamp }
replaceMessages(conversationIdForPublic(), merged)
}
suspend fun loadDmMessages(otherUserId: Int): List<Message> {
@@ -41,7 +55,89 @@ object MessageCacheStore {
}
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
replaceMessages(conversationIdForDm(otherUserId), messages)
val convId = conversationIdForDm(otherUserId)
val pending = loadDmMessages(otherUserId).filter { it.id < 0 }
val stillPending = pending.filter { p ->
val cid = p.client_message_id
cid == null || messages.none { it.client_message_id == cid }
}
val merged = (messages + stillPending)
.distinctBy { msg ->
when {
msg.id > 0 -> "i:${msg.id}"
msg.client_message_id != null -> "c:${msg.client_message_id}"
else -> "i:${msg.id}"
}
}
.sortedBy { it.timestamp }
replaceMessages(convId, merged)
}
suspend fun upsertPublicMessage(message: Message) {
upsertSingle(conversationIdForPublic(), message)
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
upsertSingle(conversationIdForDm(otherUserId), message)
}
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
}
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
}
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
confirmMessage(conversationIdForPublic(), clientMessageId, confirmed)
}
suspend fun confirmDmMessage(otherUserId: Int, clientMessageId: String, confirmed: Message) {
confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed)
}
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(conversationId, clientMessageId)
}
}
private suspend fun upsertSingle(conversationId: String, msg: Message) {
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.upsertMessage(
id = msg.id.toLong(),
conversationId = conversationId,
userId = msg.user_id.toLong(),
content = msg.content,
timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L,
isEdited = if (msg.is_edited) 1L else 0L,
replyToId = msg.reply_to?.id?.toLong(),
clientMessageId = msg.client_message_id,
deletedFlag = 0L
)
}
}
private suspend fun confirmMessage(conversationId: String, clientMessageId: String, confirmed: Message) {
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessageByClientMessageId(conversationId, clientMessageId)
db.messageDatabaseQueries.upsertMessage(
id = confirmed.id.toLong(),
conversationId = conversationId,
userId = confirmed.user_id.toLong(),
content = confirmed.content,
timestamp = confirmed.timestamp,
isRead = if (confirmed.is_read) 1L else 0L,
isEdited = if (confirmed.is_edited) 1L else 0L,
replyToId = confirmed.reply_to?.id?.toLong(),
clientMessageId = confirmed.client_message_id,
deletedFlag = 0L
)
}
}
}
private suspend fun loadMessages(conversationId: String): List<Message> =
@@ -0,0 +1,10 @@
package ru.fromchat.net
import kotlinx.coroutines.flow.StateFlow
expect object NetworkConnectivity {
val isOnline: StateFlow<Boolean>
/** Register OS callbacks once (Android: ConnectivityManager; iOS: best-effort). */
fun ensureStarted()
}
@@ -24,6 +24,7 @@ import androidx.navigation.compose.rememberNavController
import ru.fromchat.api.ApiClient
import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.WebSocketManager
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen
@@ -47,6 +48,8 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
Config.initialize()
}
runCatching { NetworkConnectivity.ensureStarted() }
// Load persisted token and user data
ApiClient.loadPersistedData()
@@ -0,0 +1,60 @@
package ru.fromchat.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.TextUnit
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
/**
* Animated "..." that grows to three dots, clears, and repeats. Uses [LocalTextStyle] when
* [fontSize] / [color] are left default so it matches surrounding typography.
*/
@Composable
fun ConnectingEllipsis(
modifier: Modifier = Modifier,
fontSize: TextUnit = LocalTextStyle.current.fontSize,
color: Color = LocalTextStyle.current.color,
baseStyle: TextStyle = LocalTextStyle.current,
stepMs: Long = 440,
) {
val merged = remember(fontSize, color, baseStyle) {
baseStyle.merge(TextStyle(fontSize = fontSize, color = color))
}
var visibleDots by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (isActive) {
for (n in 1..3) {
visibleDots = n
delay(stepMs)
}
visibleDots = 0
delay(stepMs / 2)
}
}
Row(modifier = modifier, verticalAlignment = Alignment.Bottom) {
repeat(3) { i ->
AnimatedVisibility(
visible = i < visibleDots,
enter = fadeIn(),
exit = fadeOut()
) {
Text(text = ".", style = merged)
}
}
}
}
@@ -94,7 +94,14 @@ abstract class ChatPanel(
*/
suspend fun addMessage(message: Message) {
addMessageMutex.withLock {
val messageExists = _state.messages.any { it.id == message.id }
val messageExists = when {
message.id > 0 -> _state.messages.any { it.id == message.id }
else -> {
val cid = message.client_message_id
if (cid != null) _state.messages.any { it.client_message_id == cid }
else _state.messages.any { it.id == message.id }
}
}
if (!messageExists) {
Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}")
updateState { currentState ->
@@ -116,7 +123,14 @@ abstract class ChatPanel(
if (messages.isEmpty()) return
addMessageMutex.withLock {
val existingIds = _state.messages.mapTo(mutableSetOf()) { it.id }
val newOnes = messages.filter { it.id !in existingIds }
val existingClientIds = _state.messages.mapNotNullTo(mutableSetOf()) { it.client_message_id }
val newOnes = messages.filter { msg ->
when {
msg.id > 0 -> msg.id !in existingIds
msg.client_message_id != null -> msg.client_message_id !in existingClientIds
else -> msg.id !in existingIds
}
}
if (newOnes.isNotEmpty()) {
updateState { currentState ->
val newMessages = (currentState.messages + newOnes).sortedBy { it.timestamp }
@@ -150,6 +164,12 @@ abstract class ChatPanel(
updateState { it.copy(messages = it.messages.filter { msg -> msg.id != messageId }) }
}
protected fun removeMessageByClientMessageId(clientMessageId: String) {
updateState {
it.copy(messages = it.messages.filter { msg -> msg.client_message_id != clientMessageId })
}
}
/**
* Clear all messages
*/
@@ -185,23 +205,31 @@ abstract class ChatPanel(
val pending = pendingMessages.remove(tempId)
pending?.first?.cancel()
// Replace temporary message with confirmed one
updateState { currentState ->
currentState.copy(
messages = currentState.messages.map { msg ->
// Check if this is the temp message (negative ID)
if (msg.id < 0) {
// Try to match by content or other criteria
// For now, we'll replace based on tempId stored in pendingMessages
confirmedMessage
val withoutDupReal = if (confirmedMessage.id > 0) {
currentState.messages.filter { it.id != confirmedMessage.id }
} else {
msg
}
}
)
currentState.messages
}
val hadTemp = withoutDupReal.any { it.client_message_id == tempId }
val mapped = withoutDupReal.map { msg ->
if (msg.client_message_id == tempId) confirmedMessage else msg
}
val messages = when {
hadTemp -> mapped
confirmedMessage.id > 0 && mapped.none { it.id == confirmedMessage.id } ->
mapped + confirmedMessage
else -> mapped
}
currentState.copy(messages = messages)
}
scope.launch(Dispatchers.Default) {
runCatching { onOptimisticMessageConfirmed(tempId, confirmedMessage) }
}
}
protected open suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {}
/**
* Retry failed message
*/
@@ -209,42 +237,40 @@ abstract class ChatPanel(
suspend fun retryMessage(messageId: Int) {
val message = _state.messages.find { it.id == messageId } ?: return
// Create new temp ID for retry
val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}"
val newOptimistic = message.copy(
id = uniqueOptimisticMessageId(),
client_message_id = tempId
)
// Create temp message for retry
val tempMessage = message.copy(id = -1)
// Update message to sending state
updateState { currentState ->
currentState.copy(
messages = currentState.messages.map { msg ->
if (msg.id == messageId) {
tempMessage
} else {
msg
}
if (msg.id == messageId) newOptimistic else msg
}
)
}
// Set up timeout
val timeoutJob = scope.launch {
delay(10000) // 10 seconds
delay(10000)
handleMessageTimeout(tempId)
}
pendingMessages[tempId] = timeoutJob to tempMessage
pendingMessages[tempId] = timeoutJob to newOptimistic
scope.launch(Dispatchers.Default) {
runCatching { persistOptimisticMessage(newOptimistic) }
}
// Retry sending
try {
// Extract content from message
sendMessage(message.content, message.reply_to?.id, message.client_message_id)
} catch (e: Exception) {
sendMessage(message.content, message.reply_to?.id, tempId)
} catch (_: Exception) {
timeoutJob.cancel()
pendingMessages.remove(tempId)
// Mark as failed
updateMessage(-1) { it.copy() }
removeMessageByClientMessageId(tempId)
scope.launch(Dispatchers.Default) {
runCatching { removeOptimisticFromCache(newOptimistic) }
}
}
}
@@ -254,21 +280,6 @@ abstract class ChatPanel(
private fun handleMessageTimeout(tempId: String) {
val pending = pendingMessages.remove(tempId)
pending?.first?.cancel()
// Mark message as failed
updateState { currentState ->
currentState.copy(
messages = currentState.messages.map { msg ->
if (msg.id < 0) {
// Mark as failed - we'll need to add a status field to Message
// For now, just keep it
msg
} else {
msg
}
}
)
}
}
/**
@@ -301,8 +312,9 @@ abstract class ChatPanel(
}
)
// Add message immediately
addMessage(tempMessage)
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId())
addMessage(optimistic)
// Set up timeout for failure
val timeoutJob = scope.launch {
@@ -311,19 +323,38 @@ abstract class ChatPanel(
}
// Store pending message
pendingMessages[tempId] = timeoutJob to tempMessage
pendingMessages[tempId] = timeoutJob to optimistic
scope.launch(Dispatchers.Default) {
runCatching { persistOptimisticMessage(optimistic) }
}
// Actually send the message
try {
sendMessage(content, replyToId, tempId)
// Message sent successfully - will be updated when WebSocket confirms
} catch (error: Exception) {
// Remove the temporary message from display
removeMessage(-1)
removeMessageByClientMessageId(tempId)
pendingMessages.remove(tempId)
timeoutJob.cancel()
scope.launch(Dispatchers.Default) {
runCatching { removeOptimisticFromCache(optimistic) }
}
}
}
private suspend fun uniqueOptimisticMessageId(): Int = addMessageMutex.withLock {
var id: Int
do {
id = -kotlin.random.Random.nextInt(1, Int.MAX_VALUE)
} while (_state.messages.any { it.id == id })
id
}
/** Persist optimistic row for offline / process death; no-op by default. */
protected open suspend fun persistOptimisticMessage(message: Message) {}
protected open suspend fun removeOptimisticFromCache(message: Message) {}
/**
* Clean up pending messages
@@ -12,6 +12,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -85,6 +86,8 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback
@@ -142,6 +145,7 @@ fun ChatScreen(
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState()
val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
@@ -394,6 +398,7 @@ fun ChatScreen(
)
val subtitleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
currentTypingUsers.isNotEmpty() -> "typing"
@@ -429,12 +434,23 @@ fun ChatScreen(
)
}
key == "connecting" -> {
val st = MaterialTheme.typography.bodySmall
val col = MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Connecting...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
text = "Connecting",
style = st,
color = col
)
ConnectingEllipsis(
fontSize = st.fontSize,
color = col,
baseStyle = st
)
}
}
key == "typing" -> {
TypingIndicator(
@@ -617,7 +633,9 @@ fun ChatScreen(
items(
items = panelState.messages,
key = { it.uploadJobId ?: it.id.toString() }
key = { msg ->
msg.client_message_id ?: "id_${msg.id}_${msg.timestamp}"
}
) { message ->
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
@@ -45,6 +45,19 @@ class PublicChatPanel(
ApiClient.sendMessage(content, replyToId, clientMessageId)
}
override suspend fun persistOptimisticMessage(message: Message) {
MessageCacheStore.upsertPublicMessage(message)
}
override suspend fun removeOptimisticFromCache(message: Message) {
val cid = message.client_message_id ?: return
MessageCacheStore.deletePublicMessageByClientMessageId(cid)
}
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed)
}
override suspend fun loadMessages() {
if (messagesLoaded) return
@@ -96,6 +96,19 @@ class DmPanel(
)
}
override suspend fun persistOptimisticMessage(message: Message) {
MessageCacheStore.upsertDmMessage(otherUserId, message)
}
override suspend fun removeOptimisticFromCache(message: Message) {
val cid = message.client_message_id ?: return
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, cid)
}
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
MessageCacheStore.confirmDmMessage(otherUserId, clientMessageId, confirmed)
}
override suspend fun loadMessages() {
setLoading(true)
try {
@@ -221,16 +234,22 @@ class DmPanel(
updateState { currentState ->
val existingRealIndex = currentState.messages.indexOfFirst { it.id == envelope.id }
val byClientIdIndex = currentState.messages.indexOfFirst { message ->
message.id < 0 &&
message.user_id == currentUserId &&
envelope.clientMessageId != null &&
(message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId)
}
val exactOptimisticIndex = currentState.messages.indexOfFirst { message ->
message.user_id == currentUserId &&
message.pendingFileUri != null &&
envelope.clientMessageId != null &&
(message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId)
}
val optimisticIndex = if (exactOptimisticIndex >= 0) {
exactOptimisticIndex
} else {
currentState.messages.indexOfFirst { message ->
val optimisticIndex = when {
byClientIdIndex >= 0 -> byClientIdIndex
exactOptimisticIndex >= 0 -> exactOptimisticIndex
else -> currentState.messages.indexOfFirst { message ->
message.id < 0 &&
message.user_id == currentUserId &&
(message.pendingFileUri != null) == hasAttachments
@@ -7,8 +7,11 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ListItem
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
@@ -21,6 +24,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow
@@ -33,6 +37,8 @@ import ru.fromchat.api.db.CachedConversation
import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.chat_last_mesaage
import ru.fromchat.public_chat
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class)
@@ -41,6 +47,7 @@ fun ChatsTab() {
val navController = LocalNavController.current
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
LaunchedEffect(Unit) {
@@ -60,10 +67,11 @@ fun ChatsTab() {
}
}
val titleText = when (connectionStatus) {
ConnectionStatus.UPDATING -> "Updating..."
ConnectionStatus.CONNECTING -> "Connecting..."
ConnectionStatus.CONNECTED -> "FromChat"
val titleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus == ConnectionStatus.CONNECTING -> "connecting"
else -> "fromchat"
}
Scaffold(
@@ -72,18 +80,50 @@ fun ChatsTab() {
MediumTopAppBar(
title = {
AnimatedContent(
targetState = titleText,
targetState = titleKey,
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "chats_title"
) { text ->
) { key ->
when (key) {
"connecting" -> {
val style = MaterialTheme.typography.headlineSmall
val color = MaterialTheme.colorScheme.onSurface
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = text,
text = "Connecting",
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
ConnectingEllipsis(
fontSize = style.fontSize,
color = color,
baseStyle = style
)
}
}
"updating" -> {
Text(
text = "Updating...",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
else -> {
Text(
text = "FromChat",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
},
scrollBehavior = scrollBehavior
@@ -65,6 +65,7 @@ import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.scaleOnPress
@@ -91,6 +92,8 @@ fun ProfileScreen(
onOpenSettings: () -> Unit = {}
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val navController = LocalNavController.current
val hideBackButton = navController.currentDestination?.route == "chat"
val targetUserId = userId.takeIf { it != null && it > 0 }
val fetchKey = targetUserId ?: 0
@@ -131,6 +134,7 @@ fun ProfileScreen(
MediumTopAppBar(
title = { Text("Profile") },
navigationIcon = {
if (!hideBackButton) {
Box(
modifier = Modifier
.scaleOnPress(0.96f, onClick = onBack)
@@ -143,6 +147,7 @@ fun ProfileScreen(
modifier = Modifier.size(24.dp)
)
}
}
},
scrollBehavior = scrollBehavior
)
@@ -1,32 +1,61 @@
package ru.fromchat.utils
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
private val monthShortEn = listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
)
/**
* Returns a user-friendly string for online status / last seen from an ISO-8601 timestamp.
*
* Online users show "Online". Offline users show a 24-hour time in the user's local timezone:
* - Same-day timestamps: "Last seen HH:mm"
* - Older timestamps: "Last seen YYYY-MM-DD HH:mm"
* Returns a readable last-seen line in local time (24h clock), avoiding raw ISO dates.
*/
@OptIn(ExperimentalTime::class)
fun formatLastSeen(online: Boolean, lastSeenIso: String?): String {
if (online) return "Online"
val iso = lastSeenIso ?: return ""
val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return "Last seen $iso"
val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return "Last seen recently"
val timeZone = TimeZone.currentSystemDefault()
val lastLocal = instant.toLocalDateTime(timeZone)
val nowDate = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds())
.toLocalDateTime(timeZone).date
val lastDate = lastLocal.date
val year = lastLocal.date.year
val month = lastLocal.date.monthNumber.toString().padStart(2, '0')
val day = lastLocal.date.dayOfMonth.toString().padStart(2, '0')
val hour = lastLocal.hour.toString().padStart(2, '0')
val minute = lastLocal.minute.toString().padStart(2, '0')
val timePart = "$hour:$minute"
return "Last seen $year-$month-$day $timePart"
val yesterday = nowDate.minus(DatePeriod(days = 1))
val daysBetween = nowDate.toEpochDays() - lastDate.toEpochDays()
return when {
lastDate == nowDate -> "Last seen today at $timePart"
lastDate == yesterday -> "Last seen yesterday at $timePart"
daysBetween in 2..6 -> {
val label = lastDate.dayOfWeek.name
.lowercase()
.split("_")
.joinToString(" ") { word ->
word.replaceFirstChar { c -> c.titlecase() }
}
"Last seen $label at $timePart"
}
lastDate.year == nowDate.year -> {
val mon = monthShortEn.getOrElse(lastDate.monthNumber - 1) { "" }
"Last seen ${lastDate.dayOfMonth} $mon at $timePart"
}
else -> {
val mon = monthShortEn.getOrElse(lastDate.monthNumber - 1) { "" }
"Last seen ${lastDate.dayOfMonth} $mon ${lastDate.year} at $timePart"
}
}
}
@@ -48,6 +48,10 @@ deleteMessagesForConversation:
DELETE FROM message
WHERE conversationId = ?;
deleteMessageByClientMessageId:
DELETE FROM message
WHERE conversationId = ? AND clientMessageId = ?;
upsertMessage:
INSERT OR REPLACE INTO message(
id,
@@ -0,0 +1,17 @@
package ru.fromchat.net
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* iOS: wired later to NWPathMonitor; default [true] avoids false "offline" when APIs are absent.
*/
actual object NetworkConnectivity {
private val _isOnline = MutableStateFlow(true)
actual val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
actual fun ensureStarted() {
// No-op until native path monitoring is bound.
}
}