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 import kotlin.time.ExperimentalTime
object WebSocketManager { object WebSocketManager {
private const val RECONNECT_DELAY_MS = 1_000L
// Config // Config
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -72,21 +74,26 @@ object WebSocketManager {
return session != null return session != null
} }
fun connect() { fun connect(forceRestart: Boolean = false) {
Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting") Logger.d(
"WebSocketManager",
"connect(forceRestart=$forceRestart) called. current session=${session != null}, connecting=$connecting"
)
if (forceRestart) {
connectionJob?.cancel()
connectionJob = null
} else {
val existingJob = connectionJob val existingJob = connectionJob
if (existingJob != null && existingJob.isActive) { if (existingJob != null && existingJob.isActive) {
Logger.d("WebSocketManager", "connect() ignored: connectionJob already running") Logger.d("WebSocketManager", "connect() ignored: connectionJob already running")
return return
} }
}
// Always reflect that we are trying to connect while this loop is active
ConnectionStateStore.onConnecting() ConnectionStateStore.onConnecting()
connectionJob = scope.launch { connectionJob = scope.launch {
var backoffMs = 1_000L
while (isActive) { while (isActive) {
Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive") Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive")
@@ -94,7 +101,7 @@ object WebSocketManager {
if (token.isNullOrEmpty()) { if (token.isNullOrEmpty()) {
Logger.d("WebSocketManager", "No auth token available; staying in CONNECTING and retrying later") Logger.d("WebSocketManager", "No auth token available; staying in CONNECTING and retrying later")
ConnectionStateStore.onConnecting() ConnectionStateStore.onConnecting()
delay(backoffMs.coerceAtMost(5_000L)) delay(RECONNECT_DELAY_MS)
continue continue
} }
@@ -112,7 +119,6 @@ object WebSocketManager {
) { ) {
session = this session = this
connecting = false connecting = false
backoffMs = 1_000L
Logger.d("WebSocketManager", "WebSocket connected. connecting set to false") Logger.d("WebSocketManager", "WebSocket connected. connecting set to false")
ConnectionStateStore.onConnected() ConnectionStateStore.onConnected()
@@ -188,9 +194,8 @@ object WebSocketManager {
ConnectionStateStore.onConnecting() ConnectionStateStore.onConnecting()
if (isActive) { if (isActive) {
Logger.d("WebSocketManager", "Reconnecting in ${backoffMs}ms...") Logger.d("WebSocketManager", "Reconnecting in ${RECONNECT_DELAY_MS}ms...")
delay(backoffMs) delay(RECONNECT_DELAY_MS)
backoffMs = (backoffMs * 2).coerceAtMost(15_000L)
} }
} }
} }
@@ -268,4 +273,19 @@ object WebSocketManager {
connecting = false connecting = false
Logger.d("WebSocketManager", "Disconnected. session set to null, connecting set to 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>) { 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> { suspend fun loadDmMessages(otherUserId: Int): List<Message> {
@@ -41,7 +55,89 @@ object MessageCacheStore {
} }
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) { 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> = 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.ApiClient
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.auth.RegisterScreen
@@ -47,6 +48,8 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
Config.initialize() Config.initialize()
} }
runCatching { NetworkConnectivity.ensureStarted() }
// Load persisted token and user data // Load persisted token and user data
ApiClient.loadPersistedData() 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) { suspend fun addMessage(message: Message) {
addMessageMutex.withLock { 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) { if (!messageExists) {
Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}") Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}")
updateState { currentState -> updateState { currentState ->
@@ -116,7 +123,14 @@ abstract class ChatPanel(
if (messages.isEmpty()) return if (messages.isEmpty()) return
addMessageMutex.withLock { addMessageMutex.withLock {
val existingIds = _state.messages.mapTo(mutableSetOf()) { it.id } 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()) { if (newOnes.isNotEmpty()) {
updateState { currentState -> updateState { currentState ->
val newMessages = (currentState.messages + newOnes).sortedBy { it.timestamp } 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 }) } 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 * Clear all messages
*/ */
@@ -185,23 +205,31 @@ abstract class ChatPanel(
val pending = pendingMessages.remove(tempId) val pending = pendingMessages.remove(tempId)
pending?.first?.cancel() pending?.first?.cancel()
// Replace temporary message with confirmed one
updateState { currentState -> updateState { currentState ->
currentState.copy( val withoutDupReal = if (confirmedMessage.id > 0) {
messages = currentState.messages.map { msg -> currentState.messages.filter { it.id != confirmedMessage.id }
// 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
} else { } 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 * Retry failed message
*/ */
@@ -209,42 +237,40 @@ abstract class ChatPanel(
suspend fun retryMessage(messageId: Int) { suspend fun retryMessage(messageId: Int) {
val message = _state.messages.find { it.id == messageId } ?: return 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 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 -> updateState { currentState ->
currentState.copy( currentState.copy(
messages = currentState.messages.map { msg -> messages = currentState.messages.map { msg ->
if (msg.id == messageId) { if (msg.id == messageId) newOptimistic else msg
tempMessage
} else {
msg
}
} }
) )
} }
// Set up timeout
val timeoutJob = scope.launch { val timeoutJob = scope.launch {
delay(10000) // 10 seconds delay(10000)
handleMessageTimeout(tempId) handleMessageTimeout(tempId)
} }
pendingMessages[tempId] = timeoutJob to tempMessage pendingMessages[tempId] = timeoutJob to newOptimistic
scope.launch(Dispatchers.Default) {
runCatching { persistOptimisticMessage(newOptimistic) }
}
// Retry sending
try { try {
// Extract content from message sendMessage(message.content, message.reply_to?.id, tempId)
sendMessage(message.content, message.reply_to?.id, message.client_message_id) } catch (_: Exception) {
} catch (e: Exception) {
timeoutJob.cancel() timeoutJob.cancel()
pendingMessages.remove(tempId) pendingMessages.remove(tempId)
// Mark as failed removeMessageByClientMessageId(tempId)
updateMessage(-1) { it.copy() } scope.launch(Dispatchers.Default) {
runCatching { removeOptimisticFromCache(newOptimistic) }
}
} }
} }
@@ -254,21 +280,6 @@ abstract class ChatPanel(
private fun handleMessageTimeout(tempId: String) { private fun handleMessageTimeout(tempId: String) {
val pending = pendingMessages.remove(tempId) val pending = pendingMessages.remove(tempId)
pending?.first?.cancel() 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 // Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
addMessage(tempMessage) val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId())
addMessage(optimistic)
// Set up timeout for failure // Set up timeout for failure
val timeoutJob = scope.launch { val timeoutJob = scope.launch {
@@ -311,19 +323,38 @@ abstract class ChatPanel(
} }
// Store pending message // Store pending message
pendingMessages[tempId] = timeoutJob to tempMessage pendingMessages[tempId] = timeoutJob to optimistic
scope.launch(Dispatchers.Default) {
runCatching { persistOptimisticMessage(optimistic) }
}
// Actually send the message // Actually send the message
try { try {
sendMessage(content, replyToId, tempId) sendMessage(content, replyToId, tempId)
// Message sent successfully - will be updated when WebSocket confirms // Message sent successfully - will be updated when WebSocket confirms
} catch (error: Exception) { } catch (error: Exception) {
// Remove the temporary message from display removeMessageByClientMessageId(tempId)
removeMessage(-1)
pendingMessages.remove(tempId) pendingMessages.remove(tempId)
timeoutJob.cancel() 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 * 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -85,6 +86,8 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.HapticFeedbackEvent import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback import ru.fromchat.ui.rememberHapticFeedback
@@ -142,6 +145,7 @@ fun ChatScreen(
val currentTypingUsers = panelState.typingUsers // Directly use from panelState val currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
LaunchedEffect(currentTypingUsers) { LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
} }
@@ -394,6 +398,7 @@ fun ChatScreen(
) )
val subtitleKey = when { val subtitleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating" connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus != ConnectionStatus.CONNECTED -> "connecting" connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
currentTypingUsers.isNotEmpty() -> "typing" currentTypingUsers.isNotEmpty() -> "typing"
@@ -429,12 +434,23 @@ fun ChatScreen(
) )
} }
key == "connecting" -> { key == "connecting" -> {
val st = MaterialTheme.typography.bodySmall
val col = MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = "Connecting...", text = "Connecting",
style = MaterialTheme.typography.bodySmall, style = st,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = col
modifier = Modifier.padding(top = 2.dp)
) )
ConnectingEllipsis(
fontSize = st.fontSize,
color = col,
baseStyle = st
)
}
} }
key == "typing" -> { key == "typing" -> {
TypingIndicator( TypingIndicator(
@@ -617,7 +633,9 @@ fun ChatScreen(
items( items(
items = panelState.messages, items = panelState.messages,
key = { it.uploadJobId ?: it.id.toString() } key = { msg ->
msg.client_message_id ?: "id_${msg.id}_${msg.timestamp}"
}
) { message -> ) { message ->
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) } var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
@@ -45,6 +45,19 @@ class PublicChatPanel(
ApiClient.sendMessage(content, replyToId, clientMessageId) 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() { override suspend fun loadMessages() {
if (messagesLoaded) return 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() { override suspend fun loadMessages() {
setLoading(true) setLoading(true)
try { try {
@@ -221,16 +234,22 @@ class DmPanel(
updateState { currentState -> updateState { currentState ->
val existingRealIndex = currentState.messages.indexOfFirst { it.id == envelope.id } 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 -> val exactOptimisticIndex = currentState.messages.indexOfFirst { message ->
message.user_id == currentUserId && message.user_id == currentUserId &&
message.pendingFileUri != null && message.pendingFileUri != null &&
envelope.clientMessageId != null && envelope.clientMessageId != null &&
(message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId) (message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId)
} }
val optimisticIndex = if (exactOptimisticIndex >= 0) { val optimisticIndex = when {
exactOptimisticIndex byClientIdIndex >= 0 -> byClientIdIndex
} else { exactOptimisticIndex >= 0 -> exactOptimisticIndex
currentState.messages.indexOfFirst { message -> else -> currentState.messages.indexOfFirst { message ->
message.id < 0 && message.id < 0 &&
message.user_id == currentUserId && message.user_id == currentUserId &&
(message.pendingFileUri != null) == hasAttachments (message.pendingFileUri != null) == hasAttachments
@@ -7,8 +7,11 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable 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.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -21,6 +24,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow 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.api.db.MessageCacheStore
import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_last_mesaage
import ru.fromchat.public_chat import ru.fromchat.public_chat
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -41,6 +47,7 @@ fun ChatsTab() {
val navController = LocalNavController.current val navController = LocalNavController.current
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -60,10 +67,11 @@ fun ChatsTab() {
} }
} }
val titleText = when (connectionStatus) { val titleKey = when {
ConnectionStatus.UPDATING -> "Updating..." !online -> "connecting"
ConnectionStatus.CONNECTING -> "Connecting..." connectionStatus == ConnectionStatus.UPDATING -> "updating"
ConnectionStatus.CONNECTED -> "FromChat" connectionStatus == ConnectionStatus.CONNECTING -> "connecting"
else -> "fromchat"
} }
Scaffold( Scaffold(
@@ -72,18 +80,50 @@ fun ChatsTab() {
MediumTopAppBar( MediumTopAppBar(
title = { title = {
AnimatedContent( AnimatedContent(
targetState = titleText, targetState = titleKey,
transitionSpec = { transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith (slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut()) (slideOutVertically { -it / 2 } + fadeOut())
}, },
label = "chats_title" 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, text = "Connecting",
style = style,
color = color,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis 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 scrollBehavior = scrollBehavior
@@ -65,6 +65,7 @@ import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile import ru.fromchat.api.UserProfile
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.scaleOnPress import ru.fromchat.ui.scaleOnPress
@@ -91,6 +92,8 @@ fun ProfileScreen(
onOpenSettings: () -> Unit = {} onOpenSettings: () -> Unit = {}
) { ) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val navController = LocalNavController.current
val hideBackButton = navController.currentDestination?.route == "chat"
val targetUserId = userId.takeIf { it != null && it > 0 } val targetUserId = userId.takeIf { it != null && it > 0 }
val fetchKey = targetUserId ?: 0 val fetchKey = targetUserId ?: 0
@@ -131,6 +134,7 @@ fun ProfileScreen(
MediumTopAppBar( MediumTopAppBar(
title = { Text("Profile") }, title = { Text("Profile") },
navigationIcon = { navigationIcon = {
if (!hideBackButton) {
Box( Box(
modifier = Modifier modifier = Modifier
.scaleOnPress(0.96f, onClick = onBack) .scaleOnPress(0.96f, onClick = onBack)
@@ -143,6 +147,7 @@ fun ProfileScreen(
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
}
}, },
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
@@ -1,32 +1,61 @@
package ru.fromchat.utils package ru.fromchat.utils
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.toLocalDateTime 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. * Returns a readable last-seen line in local time (24h clock), avoiding raw ISO dates.
*
* 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"
*/ */
@OptIn(ExperimentalTime::class)
fun formatLastSeen(online: Boolean, lastSeenIso: String?): String { fun formatLastSeen(online: Boolean, lastSeenIso: String?): String {
if (online) return "Online" if (online) return "Online"
val iso = lastSeenIso ?: return "" 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 timeZone = TimeZone.currentSystemDefault()
val lastLocal = instant.toLocalDateTime(timeZone) 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 hour = lastLocal.hour.toString().padStart(2, '0')
val minute = lastLocal.minute.toString().padStart(2, '0') val minute = lastLocal.minute.toString().padStart(2, '0')
val timePart = "$hour:$minute" 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 DELETE FROM message
WHERE conversationId = ?; WHERE conversationId = ?;
deleteMessageByClientMessageId:
DELETE FROM message
WHERE conversationId = ? AND clientMessageId = ?;
upsertMessage: upsertMessage:
INSERT OR REPLACE INTO message( INSERT OR REPLACE INTO message(
id, 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.
}
}