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
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user