diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/net/NetworkConnectivity.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/net/NetworkConnectivity.android.kt new file mode 100644 index 0000000..a90f835 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/net/NetworkConnectivity.android.kt @@ -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 = _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) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt index 16f5f2b..ce8678b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt @@ -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" + ) - val existingJob = connectionJob - if (existingJob != null && existingJob.isActive) { - Logger.d("WebSocketManager", "connect() ignored: connectionJob already running") - return + 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) + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt index 30c6c57..3392323 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt @@ -33,7 +33,21 @@ object MessageCacheStore { } suspend fun replacePublicMessages(messages: List) { - 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 { @@ -41,7 +55,89 @@ object MessageCacheStore { } suspend fun replaceDmMessages(otherUserId: Int, messages: List) { - 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 = diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/net/NetworkConnectivity.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/net/NetworkConnectivity.kt new file mode 100644 index 0000000..e78ab02 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/net/NetworkConnectivity.kt @@ -0,0 +1,10 @@ +package ru.fromchat.net + +import kotlinx.coroutines.flow.StateFlow + +expect object NetworkConnectivity { + val isOnline: StateFlow + + /** Register OS callbacks once (Android: ConnectivityManager; iOS: best-effort). */ + fun ensureStarted() +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 6b9ec43..85eccd9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ConnectingEllipsis.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ConnectingEllipsis.kt new file mode 100644 index 0000000..a9f9e6f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ConnectingEllipsis.kt @@ -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) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 7140f28..9b843d2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -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 - } else { - msg - } - } - ) + val withoutDupReal = if (confirmedMessage.id > 0) { + currentState.messages.filter { it.id != confirmedMessage.id } + } else { + 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,20 +323,39 @@ 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 */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 709824f..b72068d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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" -> { - Text( - text = "Connecting...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp) - ) + val st = MaterialTheme.typography.bodySmall + val col = MaterialTheme.colorScheme.onSurfaceVariant + Row( + modifier = Modifier.padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + 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)) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt index d71977c..46cca24 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index 008dbd7..6c0703a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -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,19 +234,25 @@ 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 -> - message.id < 0 && - message.user_id == currentUserId && - (message.pendingFileUri != null) == hasAttachments + 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 } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt index 2731bc1..dab25a3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt @@ -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>(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 -> - Text( - text = text, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + ) { key -> + when (key) { + "connecting" -> { + val style = MaterialTheme.typography.headlineSmall + val color = MaterialTheme.colorScheme.onSurface + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + 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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 718537e..feecaeb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -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,17 +134,19 @@ fun ProfileScreen( MediumTopAppBar( title = { Text("Profile") }, navigationIcon = { - Box( - modifier = Modifier - .scaleOnPress(0.96f, onClick = onBack) - .padding(12.dp), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(24.dp) - ) + if (!hideBackButton) { + Box( + modifier = Modifier + .scaleOnPress(0.96f, onClick = onBack) + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(24.dp) + ) + } } }, scrollBehavior = scrollBehavior diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt index d1ade41..b0b9e17 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt @@ -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" + } + } } diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq index a0a160b..23b14bd 100644 --- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -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, diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/net/NetworkConnectivity.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/net/NetworkConnectivity.ios.kt new file mode 100644 index 0000000..6b26087 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/net/NetworkConnectivity.ios.kt @@ -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 = _isOnline.asStateFlow() + + actual fun ensureStarted() { + // No-op until native path monitoring is bound. + } +}