mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user