From ee87a70f146bec44886aa2ed9ceaa5dfd94c32a9 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 2 Apr 2026 17:26:09 +0300 Subject: [PATCH] Implement caching, redesign tabs and make a robust reconnection system Signed-off-by: denis0001-dev --- app/shared/build.gradle.kts | 29 +++- .../api/db/MessageDatabaseDriver.android.kt | 15 ++ .../ru/fromchat/api/ConnectionStateStore.kt | 50 +++++++ .../ru/fromchat/api/UpdateSyncManager.kt | 128 ++++++++++++++++ .../kotlin/ru/fromchat/api/WebSocketData.kt | 13 ++ .../ru/fromchat/api/WebSocketManager.kt | 75 ++++++++-- .../ru/fromchat/api/db/MessageCacheStore.kt | 139 ++++++++++++++++++ .../api/db/MessageDatabaseProvider.kt | 16 ++ .../commonMain/kotlin/ru/fromchat/ui/App.kt | 11 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 87 ++++++++--- .../ru/fromchat/ui/chat/PublicChatPanel.kt | 24 ++- .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 69 ++++++--- .../kotlin/ru/fromchat/ui/main/ChatsTab.kt | 83 ++++++++++- .../kotlin/ru/fromchat/ui/main/MainScreen.kt | 30 ++-- .../ru/fromchat/ui/profile/ProfileScreen.kt | 27 +++- .../kotlin/ru/fromchat/utils/RelativeTime.kt | 45 +++--- .../ru/fromchat/db/MessageDatabase.sq | 86 +++++++++++ .../api/db/MessageDatabaseDriver.ios.kt | 13 ++ gradle/libs.versions.toml | 8 +- 19 files changed, 828 insertions(+), 120 deletions(-) create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/ConnectionStateStore.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt create mode 100644 app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 0c76f78..bc8d7ee 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.multiplatform.library) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.sqldelight) } kotlin { @@ -36,13 +37,6 @@ kotlin { } } - androidMain.dependencies { - implementation(libs.ktor.client.okhttp) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.work.runtime.ktx) - implementation(libs.tweetnacl.java) - } - commonMain.dependencies { implementation(libs.compose.runtime) implementation(libs.compose.foundation) @@ -76,17 +70,38 @@ kotlin { implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) + // SQLDelight runtime + implementation(libs.sqldelight.runtime) + implementation(libs.sqldelight.coroutines.extensions) + implementation(project(":utils:shared")) implementation(libs.krypto) implementation(libs.cryptography.core) implementation(libs.cryptography.provider.optimal) } + androidMain.dependencies { + implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.tweetnacl.java) + implementation(libs.sqldelight.driver.android) + } + iosMain.dependencies { implementation(libs.jetbrains.kotlinx.io.bytestring) implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.ktor.client.darwin) implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.5") + implementation(libs.sqldelight.driver.native) + } + } +} + +sqldelight { + databases { + create("MessageDatabase") { + packageName.set("ru.fromchat.db") } } } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.android.kt new file mode 100644 index 0000000..ded8ce5 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.android.kt @@ -0,0 +1,15 @@ +package ru.fromchat.api.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import com.pr0gramm3r101.utils.UtilsLibrary +import ru.fromchat.db.MessageDatabase + +actual fun provideMessageDatabaseDriver(): SqlDriver { + return AndroidSqliteDriver( + schema = MessageDatabase.Schema, + context = UtilsLibrary.context, + name = "message_database.db" + ) +} + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ConnectionStateStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ConnectionStateStore.kt new file mode 100644 index 0000000..34d2e40 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ConnectionStateStore.kt @@ -0,0 +1,50 @@ +package ru.fromchat.api + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Global connection state for the realtime WebSocket layer. + * + * The app never exposes a terminal "disconnected" state – it always keeps + * trying to reconnect, so the public states are: + * - CONNECTING: transport trying to establish or re-establish connection. + * - UPDATING: connection is up and we are fetching missed updates. + * - CONNECTED: healthy, up-to-date connection. + */ +enum class ConnectionStatus { + CONNECTING, + UPDATING, + CONNECTED +} + +data class ConnectionMetadata( + val lastSeq: Int = 0, + val lastMissedCount: Int? = null +) + +object ConnectionStateStore { + private val _status = MutableStateFlow(ConnectionStatus.CONNECTING) + val status: StateFlow = _status.asStateFlow() + + private val _metadata = MutableStateFlow(ConnectionMetadata()) + val metadata: StateFlow = _metadata.asStateFlow() + + fun onConnecting() { + _status.value = ConnectionStatus.CONNECTING + } + + fun onConnected() { + _status.value = ConnectionStatus.CONNECTED + } + + fun onUpdating(start: Boolean) { + _status.value = if (start) ConnectionStatus.UPDATING else ConnectionStatus.CONNECTED + } + + fun updateSeqAndMissed(lastSeq: Int, missedCount: Int?) { + _metadata.value = ConnectionMetadata(lastSeq = lastSeq, lastMissedCount = missedCount) + } +} + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt new file mode 100644 index 0000000..bb6e88e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt @@ -0,0 +1,128 @@ +package ru.fromchat.api + +import com.pr0gramm3r101.utils.settings.settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ru.fromchat.core.Logger +import kotlin.concurrent.Volatile + +/** + * Tracks the last seen WebSocket update sequence for the current user and + * persists it between sessions so we can ask the backend for missed updates. + */ +object UpdateSyncManager { + private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_" + + private val _lastSeq = MutableStateFlow(0) + val lastSeq: StateFlow = _lastSeq.asStateFlow() + + private val _lastMissedCount = MutableStateFlow(null) + val lastMissedCount: StateFlow = _lastMissedCount.asStateFlow() + + @Volatile + private var gapDetectionInProgress: Boolean = false + + suspend fun initializeFromStorage(currentUserId: Int?) { + val userId = currentUserId ?: return + val key = KEY_UPDATES_LAST_SEQ_PREFIX + userId + val stored = runCatching { settings.getInt(key, 0) }.getOrDefault(0) + Logger.d("UpdateSyncManager", "Loaded lastSeq=$stored for userId=$userId") + _lastSeq.value = stored + ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null) + } + + fun onUpdatesBatch(seq: Int) { + if (seq <= 0) return + val currentUserId = ApiClient.user?.id ?: return + val newSeq = seq.coerceAtLeast(_lastSeq.value) + if (newSeq == _lastSeq.value) return + + _lastSeq.value = newSeq + ConnectionStateStore.updateSeqAndMissed(lastSeq = newSeq, missedCount = _lastMissedCount.value) + + val key = KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId + GlobalScope.launch(Dispatchers.Default) { + runCatching { + settings.putInt(key, newSeq) + }.onFailure { + Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$newSeq for userId=$currentUserId: ${it.message}", it) + } + } + } + + fun updateMissedCount(missedCount: Int?) { + _lastMissedCount.value = missedCount + ConnectionStateStore.updateSeqAndMissed(lastSeq = _lastSeq.value, missedCount = missedCount) + } + + /** + * Ask the backend for missed updates between our lastSeq and the current sequence. + * This call is idempotent while in progress and will no-op if there is no active + * WebSocket session or no authenticated user. + */ + suspend fun runGapDetectionIfNeeded() { + if (gapDetectionInProgress) { + Logger.d("UpdateSyncManager", "Gap detection already in progress, ignoring request") + return + } + + val token = ApiClient.token + if (token.isNullOrEmpty()) { + Logger.d("UpdateSyncManager", "No auth token; skipping gap detection") + return + } + + gapDetectionInProgress = true + val startSeq = _lastSeq.value + + try { + Logger.d("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq") + if (startSeq > 0) { + ConnectionStateStore.onUpdating(start = true) + } + + val requestMessage = WebSocketMessage( + type = "getUpdates", + credentials = WebSocketCredentials( + scheme = "Bearer", + credentials = token + ), + data = ApiClient.json.encodeToJsonElement( + GetUpdatesRequest.serializer(), + GetUpdatesRequest(lastSeq = startSeq) + ) + ) + + val response = WebSocketManager.request(requestMessage) + val data = response?.data + + if (data != null) { + runCatching { + val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data) + Logger.d( + "UpdateSyncManager", + "Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}" + ) + onUpdatesBatch(parsed.lastSeq) + updateMissedCount(parsed.missedCount) + }.onFailure { + Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it) + } + } else { + Logger.d("UpdateSyncManager", "No data returned from getUpdates; treating as no-op") + } + } catch (t: Throwable) { + Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t) + } finally { + if (startSeq > 0) { + ConnectionStateStore.onUpdating(start = false) + } + gapDetectionInProgress = false + } + } +} + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt index 7297bbf..3b6859a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt @@ -1,5 +1,6 @@ package ru.fromchat.api +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -8,6 +9,18 @@ data class WebSocketUpdatesData( val updates: List ) +@Serializable +data class GetUpdatesRequest( + @SerialName("lastSeq") val lastSeq: Int +) + +@Serializable +data class GetUpdatesResponse( + val status: String, + @SerialName("lastSeq") val lastSeq: Int, + @SerialName("missedCount") val missedCount: Int +) + @Serializable data class ReactionUpdateData( val message_id: Int, 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 1505a30..16f5f2b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.isActive +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json @@ -49,6 +50,7 @@ object WebSocketManager { // State @Volatile private var connecting = false @Volatile private var session: DefaultClientWebSocketSession? = null + @Volatile private var connectionJob: Job? = null /** * Check if WebSocket is connected @@ -72,19 +74,36 @@ object WebSocketManager { fun connect() { Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting") - if (connecting) { - Logger.d("WebSocketManager", "connect() ignored: already connecting") + + val existingJob = connectionJob + if (existingJob != null && existingJob.isActive) { + Logger.d("WebSocketManager", "connect() ignored: connectionJob already running") return } - connecting = true - Logger.d("WebSocketManager", "connecting set to true") - scope.launch { + // 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") + + val token = ApiClient.token + if (token.isNullOrEmpty()) { + Logger.d("WebSocketManager", "No auth token available; staying in CONNECTING and retrying later") + ConnectionStateStore.onConnecting() + delay(backoffMs.coerceAtMost(5_000L)) + continue + } + try { val wsUrl = Config.webSocketUrl Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl") + connecting = true + ConnectionStateStore.onConnecting() + ApiClient.http.webSocket( method = HttpMethod.Get, request = { @@ -93,12 +112,29 @@ object WebSocketManager { ) { session = this connecting = false + backoffMs = 1_000L Logger.d("WebSocketManager", "WebSocket connected. connecting set to false") + ConnectionStateStore.onConnected() // Send ping message immediately after connection for authentication - ApiClient.token?.let { - Logger.d("WebSocketManager", "Sending WebSocket ping for authentication") - send(WebSocketMessage(type = "ping", credentials = WebSocketCredentials(scheme = "Bearer", credentials = it))) + Logger.d("WebSocketManager", "Sending WebSocket ping for authentication") + send( + WebSocketMessage( + type = "ping", + credentials = WebSocketCredentials( + scheme = "Bearer", + credentials = token + ) + ) + ) + + // Kick off gap detection in the background; it will no-op if not needed. + scope.launch { + runCatching { + UpdateSyncManager.runGapDetectionIfNeeded() + }.onFailure { + Logger.w("WebSocketManager", "Gap detection failed: ${it.message}", it) + } } for (frame in incoming) { @@ -107,9 +143,17 @@ object WebSocketManager { try { val jsonTree = json.parseToJsonElement(text) val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content - + val msg = when (messageType) { "updates" -> { + // Track sequence for missed-update detection + runCatching { + val updatesData = json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), jsonTree) + UpdateSyncManager.onUpdatesBatch(updatesData.seq) + }.onFailure { + Logger.w("WebSocketManager", "Failed to decode updates envelope for seq tracking: ${it.message}", it) + } + WebSocketMessage( type = "updates", data = jsonTree @@ -126,7 +170,7 @@ object WebSocketManager { json.decodeFromString(text) } } - + globalHandlers.forEach { it(msg) } _messages.emit(msg) } catch (e: Throwable) { @@ -137,14 +181,17 @@ object WebSocketManager { } } catch (e: Throwable) { Logger.w("WebSocketManager", "An error occurred during WebSocket connection: ${e.message}", e) - connecting = false - session = null - Logger.d("WebSocketManager", "Reconnecting in 3 seconds...") - delay(3000) } finally { Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false") session = null connecting = false + ConnectionStateStore.onConnecting() + + if (isActive) { + Logger.d("WebSocketManager", "Reconnecting in ${backoffMs}ms...") + delay(backoffMs) + backoffMs = (backoffMs * 2).coerceAtMost(15_000L) + } } } } 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 new file mode 100644 index 0000000..30c6c57 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt @@ -0,0 +1,139 @@ +package ru.fromchat.api.db + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.fromchat.api.DmConversation +import ru.fromchat.api.Message +import ru.fromchat.db.Conversation +import ru.fromchat.db.MessageDatabase +import ru.fromchat.db.Message as DbMessage + +/** + * Simple repository wrapping [MessageDatabase] for caching messages and conversations. + * + * This is intentionally minimal – it focuses on the flows the app needs for + * public chat and DMs rather than trying to mirror the entire backend schema. + */ +data class CachedConversation( + val id: String, + val otherUserId: Int, + val displayName: String, + val lastMessagePreview: String?, + val unreadCount: Int +) + +object MessageCacheStore { + private val db: MessageDatabase get() = MessageDatabaseProvider.database + + private fun conversationIdForPublic(): String = "public" + private fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId" + + suspend fun loadPublicMessages(): List { + return loadMessages(conversationIdForPublic()) + } + + suspend fun replacePublicMessages(messages: List) { + replaceMessages(conversationIdForPublic(), messages) + } + + suspend fun loadDmMessages(otherUserId: Int): List { + return loadMessages(conversationIdForDm(otherUserId)) + } + + suspend fun replaceDmMessages(otherUserId: Int, messages: List) { + replaceMessages(conversationIdForDm(otherUserId), messages) + } + + private suspend fun loadMessages(conversationId: String): List = + withContext(Dispatchers.Default) { + db.messageDatabaseQueries + .selectMessagesByConversation(conversationId) + .executeAsList() + .map { row: DbMessage -> + Message( + id = row.id.toInt(), + user_id = row.userId.toInt(), + content = row.content, + timestamp = row.timestamp, + is_read = row.isRead != 0L, + is_edited = row.isEdited != 0L, + username = "", // Filled from network; cache focuses on content & ordering. + profile_picture = null, + verified = null, + reply_to = null, + client_message_id = row.clientMessageId, + reactions = null, + files = null + ) + } + } + + private suspend fun replaceMessages(conversationId: String, messages: List) { + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.transaction { + db.messageDatabaseQueries.deleteMessagesForConversation(conversationId) + messages.forEach { msg -> + 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 + ) + } + } + } + } + + suspend fun markMessageDeleted(conversationId: String, messageId: Int) { + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.markMessageDeleted( + id = messageId.toLong(), + conversationId = conversationId + ) + } + } + + suspend fun replaceDmConversations(conversations: List) { + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.transaction { + conversations.forEach { conv -> + val conversationId = conversationIdForDm(conv.user.id) + db.messageDatabaseQueries.upsertConversation( + id = conversationId, + type = "dm", + otherUserId = conv.user.id.toLong(), + displayName = conv.user.username, + lastMessageId = conv.lastMessage.id.toLong(), + lastMessagePreview = null, // Encrypted on backend; preview handled in chat UI. + unreadCount = conv.unreadCount.toLong(), + updatedAt = conv.lastMessage.timestamp + ) + } + } + } + } + + suspend fun loadCachedDmConversations(): List = + withContext(Dispatchers.Default) { + db.messageDatabaseQueries + .selectConversations() + .executeAsList() + .filter { row: Conversation -> row.type == "dm" } + .map { row: Conversation -> + CachedConversation( + id = row.id, + otherUserId = row.otherUserId?.toInt() ?: 0, + displayName = row.displayName ?: "", + lastMessagePreview = row.lastMessagePreview, + unreadCount = row.unreadCount.toInt() + ) + } + } +} + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt new file mode 100644 index 0000000..c272234 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt @@ -0,0 +1,16 @@ +package ru.fromchat.api.db + +import app.cash.sqldelight.db.SqlDriver +import ru.fromchat.db.MessageDatabase + +/** + * Platform-agnostic access to the SQLDelight [MessageDatabase]. + */ +expect fun provideMessageDatabaseDriver(): SqlDriver + +object MessageDatabaseProvider { + val database: MessageDatabase by lazy { + MessageDatabase(provideMessageDatabaseDriver()) + } +} + 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 d4f32ae..6b9ec43 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -22,6 +22,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import ru.fromchat.api.ApiClient +import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.WebSocketManager import ru.fromchat.core.config.Config import ru.fromchat.ui.auth.LoginScreen @@ -49,6 +50,11 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { // Load persisted token and user data ApiClient.loadPersistedData() + // Initialize update sync state for the current user (if any) + runCatching { + UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) + } + // Now determine start destination based on loaded token val hasToken = ApiClient.token?.isNotEmpty() == true startDestination = when { @@ -64,12 +70,11 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_RESUME -> { - // Connect WebSocket when app comes to foreground + // Ensure WebSocket connection loop is running when app comes to foreground WebSocketManager.connect() } Lifecycle.Event.ON_PAUSE -> { - // Disconnect WebSocket when app goes to background - WebSocketManager.disconnect() + // No-op for connection lifecycle: WebSocketManager keeps trying to reconnect } else -> {} } 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 6e24af4..709824f 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 @@ -5,6 +5,8 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -74,6 +76,8 @@ import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.api.AttachmentUploadJob import ru.fromchat.api.AttachmentUploadQueue +import ru.fromchat.api.ConnectionStateStore +import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.Message import ru.fromchat.api.UserStatusStore import ru.fromchat.api.WebSocketManager @@ -137,6 +141,7 @@ fun ChatScreen( val currentTypingUsers = panelState.typingUsers // Directly use from panelState val statusMap by UserStatusStore.status.collectAsState() + val connectionStatus by ConnectionStateStore.status.collectAsState() LaunchedEffect(currentTypingUsers) { Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") } @@ -388,30 +393,66 @@ fun ChatScreen( style = MaterialTheme.typography.titleLarge ) - AnimatedContent( - targetState = currentTypingUsers.isNotEmpty(), - transitionSpec = { - fadeIn() togetherWith fadeOut() - }, - label = "typing_status" - ) { hasTyping -> - if (hasTyping) { - TypingIndicator( - typingUsers = currentTypingUsers.map { it.username }, - modifier = Modifier.padding(top = 2.dp) - ) - } else if (panelState.profileUserId != null) { + val subtitleKey = when { + connectionStatus == ConnectionStatus.UPDATING -> "updating" + connectionStatus != ConnectionStatus.CONNECTED -> "connecting" + currentTypingUsers.isNotEmpty() -> "typing" + panelState.profileUserId != null -> { val userStatus = statusMap[panelState.profileUserId] - if (userStatus != null) { - val statusText = formatLastSeen(userStatus.online, userStatus.lastSeen) - if (statusText.isNotEmpty()) { - Text( - text = statusText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp) - ) - } + val statusText = userStatus?.let { + formatLastSeen(it.online, it.lastSeen) + }.orEmpty() + if (statusText.isNotEmpty()) { + "presence:$statusText" + } else { + "" + } + } + else -> "" + } + + AnimatedContent( + targetState = subtitleKey, + transitionSpec = { + (slideInVertically { it / 2 } + fadeIn()) togetherWith + (slideOutVertically { -it / 2 } + fadeOut()) + }, + label = "chat_subtitle" + ) { key -> + when { + key == "updating" -> { + Text( + text = "Updating...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + key == "connecting" -> { + Text( + text = "Connecting...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + key == "typing" -> { + TypingIndicator( + typingUsers = currentTypingUsers.map { it.username }, + modifier = Modifier.padding(top = 2.dp) + ) + } + key.startsWith("presence:") -> { + val text = key.removePrefix("presence:") + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + else -> { + // No subtitle for this state } } } 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 6dbed51..d71977c 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 @@ -9,6 +9,7 @@ import ru.fromchat.api.ReactionUpdateData import ru.fromchat.api.TypingUpdateData import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketUpdatesData +import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.core.Logger class PublicChatPanel( @@ -49,17 +50,31 @@ class PublicChatPanel( setLoading(true) try { + // First, try to show cached messages immediately for offline / fast startup. + runCatching { + val cached = MessageCacheStore.loadPublicMessages() + if (cached.isNotEmpty()) { + clearMessages() + cached.forEach { message -> + addMessage(message) + } + } + } + + // Then refresh from network when available. val response = ApiClient.getMessages(limit = 50) if (response.messages.isNotEmpty()) { clearMessages() response.messages.forEach { message -> addMessage(message) } + // Persist fresh messages to cache for offline use. + MessageCacheStore.replacePublicMessages(response.messages) } setHasMoreMessages(false) // TODO: Implement has_more from API messagesLoaded = true } catch (_: Exception) { - // Handle error + // Keep whatever cached state we have; no-op on error. } finally { setLoading(false) } @@ -110,17 +125,24 @@ class PublicChatPanel( val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) DecryptedImageCache.invalidateForMessage(editedMsg.id) updateMessage(editedMsg.id) { editedMsg } + // Update cache to reflect edit + MessageCacheStore.replacePublicMessages(_state.messages) } "messageDeleted" -> { val data = updateMessage.data ?: return val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data) DecryptedImageCache.invalidateForMessage(deletedData.message_id) removeMessage(deletedData.message_id) + // Mark deleted in cache + MessageCacheStore.markMessageDeleted("public", deletedData.message_id) + MessageCacheStore.replacePublicMessages(_state.messages) } "reactionUpdate" -> { val data = updateMessage.data ?: return val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data) handleReactionUpdate(reactionUpdate) + // Re-write cache so reactions are updated + MessageCacheStore.replacePublicMessages(_state.messages) } "typing" -> { val data = updateMessage.data ?: 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 20d4f83..008dbd7 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 @@ -21,6 +21,7 @@ import ru.fromchat.core.Logger import ru.fromchat.crypto.CorruptedDmMessagePlaceholder import ru.fromchat.crypto.DmCiphertextCorruptedException import ru.fromchat.crypto.decryptEnvelope +import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.DecryptedImageCache @@ -97,32 +98,48 @@ class DmPanel( override suspend fun loadMessages() { setLoading(true) - runCatching { - ApiClient.getDmHistory(otherUserId) - }.onSuccess { response -> - clearMessages() - val decryptedForLog = mutableListOf>() - val messages = response.messages.map { envelope -> - val outcome = decryptDmEnvelopeForUi(envelope) - decryptedForLog.add(envelope.id to outcome.plaintext) - createMessage(envelope, outcome.plaintext, outcome.isCorrupted) + try { + // First, hydrate from cache if available. + runCatching { + val cached = MessageCacheStore.loadDmMessages(otherUserId) + if (cached.isNotEmpty()) { + clearMessages() + addMessages(cached) + } } - decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> - Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") + + // Then refresh from network. + runCatching { + ApiClient.getDmHistory(otherUserId) + }.onSuccess { response -> + clearMessages() + val decryptedForLog = mutableListOf>() + val messages = response.messages.map { envelope -> + val outcome = decryptDmEnvelopeForUi(envelope) + decryptedForLog.add(envelope.id to outcome.plaintext) + createMessage(envelope, outcome.plaintext, outcome.isCorrupted) + } + decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> + Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") + } + val replyToMap = messages.associateBy { it.id } + val messagesWithReplies = messages.map { msg -> + val envelope = response.messages.find { it.id == msg.id } + if (envelope?.replyToId != null) { + msg.copy(reply_to = replyToMap[envelope.replyToId]) + } else msg + } + addMessages(messagesWithReplies) + setHasMoreMessages(false) + + // Persist the most recent DM messages for offline use. + MessageCacheStore.replaceDmMessages(otherUserId, messagesWithReplies) + }.onFailure { error -> + Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error) } - val replyToMap = messages.associateBy { it.id } - val messagesWithReplies = messages.map { msg -> - val envelope = response.messages.find { it.id == msg.id } - if (envelope?.replyToId != null) { - msg.copy(reply_to = replyToMap[envelope.replyToId]) - } else msg - } - addMessages(messagesWithReplies) - setHasMoreMessages(false) - }.onFailure { error -> - Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error) + } finally { + setLoading(false) } - setLoading(false) } override suspend fun loadMoreMessages() { @@ -191,6 +208,9 @@ class DmPanel( val replyTo = _state.messages.find { it.id == envelope.replyToId } updateMessage(envelope.id) { it.copy(reply_to = replyTo) } } + + // Persist updated DM thread to cache + MessageCacheStore.replaceDmMessages(otherUserId, _state.messages) } } } @@ -274,6 +294,9 @@ class DmPanel( isContentCorrupted = outcome.isCorrupted ) } + + // Persist edit to cache + MessageCacheStore.replaceDmMessages(otherUserId, _state.messages) } } 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 00e8ce6..2731bc1 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 @@ -1,5 +1,11 @@ package ru.fromchat.ui.main +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api @@ -9,13 +15,23 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.api.ApiClient +import ru.fromchat.api.ConnectionStateStore +import ru.fromchat.api.ConnectionStatus +import ru.fromchat.api.db.CachedConversation +import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.chat_last_mesaage -import ru.fromchat.chats import ru.fromchat.public_chat import ru.fromchat.ui.LocalNavController @@ -24,17 +40,51 @@ import ru.fromchat.ui.LocalNavController fun ChatsTab() { val navController = LocalNavController.current val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val connectionStatus by ConnectionStateStore.status.collectAsState() + var dmConversations by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + // Load cached DM conversations first for instant offline display. + runCatching { + dmConversations = MessageCacheStore.loadCachedDmConversations() + } + + // Then refresh from network and update cache + state. + runCatching { + ApiClient.getDmConversations() + }.onSuccess { conversations -> + runCatching { + MessageCacheStore.replaceDmConversations(conversations) + dmConversations = MessageCacheStore.loadCachedDmConversations() + } + } + } + + val titleText = when (connectionStatus) { + ConnectionStatus.UPDATING -> "Updating..." + ConnectionStatus.CONNECTING -> "Connecting..." + ConnectionStatus.CONNECTED -> "FromChat" + } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MediumTopAppBar( title = { - Text( - text = stringResource(Res.string.chats), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + AnimatedContent( + targetState = titleText, + transitionSpec = { + (slideInVertically { it / 2 } + fadeIn()) togetherWith + (slideOutVertically { -it / 2 } + fadeOut()) + }, + label = "chats_title" + ) { text -> + Text( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } }, scrollBehavior = scrollBehavior ) @@ -50,6 +100,27 @@ fun ChatsTab() { } ) } + + items(dmConversations.size) { index -> + val conv = dmConversations[index] + ListItem( + headlineContent = { Text(conv.displayName.ifBlank { "User ${conv.otherUserId}" }) }, + supportingContent = { + val preview = conv.lastMessagePreview ?: "Direct messages" + Text(preview) + }, + trailingContent = { + if (conv.unreadCount > 0) { + Text("+${conv.unreadCount}") + } + }, + modifier = Modifier.clickable { + if (conv.otherUserId != 0) { + navController.navigate("dm/${conv.otherUserId}") + } + } + ) + } } } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index 4e5a902..1cade73 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.Contacts -import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -25,12 +25,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.api.ApiClient import ru.fromchat.chats import ru.fromchat.coming_soon import ru.fromchat.contacts -import ru.fromchat.dms import ru.fromchat.settings import ru.fromchat.utils.exclude +import ru.fromchat.ui.profile.ProfileScreen @Suppress("AssignedValueIsNeverRead") @Composable @@ -52,18 +53,18 @@ fun MainScreen(onLogout: () -> Unit = {}) { label = { Text(stringResource(Res.string.contacts)) }, icon = { Icon(Icons.Filled.Contacts, contentDescription = null) } ) - NavigationBarItem( - selected = selectedTab == "dms", - onClick = { selectedTab = "dms" }, - label = { Text(stringResource(Res.string.dms)) }, - icon = { Icon(Icons.Filled.Mail, contentDescription = null) } - ) NavigationBarItem( selected = selectedTab == "settings", onClick = { selectedTab = "settings" }, label = { Text(stringResource(Res.string.settings)) }, icon = { Icon(Icons.Filled.Settings, contentDescription = null) } ) + NavigationBarItem( + selected = selectedTab == "profile", + onClick = { selectedTab = "profile" }, + label = { Text("Profile") }, + icon = { Icon(Icons.Filled.Person, contentDescription = null) } + ) } }, contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), @@ -79,12 +80,19 @@ fun MainScreen(onLogout: () -> Unit = {}) { "contacts" -> { Text(stringResource(Res.string.coming_soon)) } - "dms" -> { - Text(stringResource(Res.string.coming_soon)) - } "settings" -> { SettingsTab(onLogout = onLogout) } + "profile" -> { + val currentUserId = ApiClient.user?.id + ProfileScreen( + userId = currentUserId, + onBack = {}, + onChat = { _ -> }, + modifier = Modifier.fillMaxSize(), + onOpenSettings = { selectedTab = "settings" } + ) + } } } } 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 ac69b1e..718537e 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 @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Verified import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -86,7 +87,8 @@ fun ProfileScreen( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedAvatarKey: Any? = null, - initialDisplayName: String? = null + initialDisplayName: String? = null, + onOpenSettings: () -> Unit = {} ) { val clipboardManager: ClipboardManager = LocalClipboardManager.current val targetUserId = userId.takeIf { it != null && it > 0 } @@ -239,6 +241,7 @@ fun ProfileScreen( val scope = rememberCoroutineScope() val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify" + val isOwnProfile = ApiClient.user?.id == profile.id Row( verticalAlignment = Alignment.CenterVertically, @@ -267,13 +270,23 @@ fun ProfileScreen( .padding(start = 16.dp, end = 16.dp, bottom = 20.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - val chatSource = remember { MutableInteractionSource() } + val primarySource = remember { MutableInteractionSource() } + val primaryClick: () -> Unit + val primaryIcon = if (isOwnProfile) Icons.Filled.Settings else Icons.AutoMirrored.Filled.Chat + val primaryLabel = if (isOwnProfile) "Settings" else "Chat" + + primaryClick = if (isOwnProfile) { + onOpenSettings + } else { + { onChat(profile.id) } + } + Card( modifier = Modifier .weight(1f) .scaleOnPress( scale = 0.90f, - interactionSource = chatSource, + interactionSource = primarySource, clipShape = MaterialTheme.shapes.extraLarge ), shape = MaterialTheme.shapes.extraLarge, @@ -283,9 +296,9 @@ fun ProfileScreen( modifier = Modifier .fillMaxWidth() .clickable( - interactionSource = chatSource, + interactionSource = primarySource, indication = LocalIndication.current, - onClick = { onChat(profile.id) } + onClick = primaryClick ), contentAlignment = Alignment.Center ) { @@ -294,13 +307,13 @@ fun ProfileScreen( modifier = Modifier.padding(vertical = 16.dp) ) { Icon( - imageVector = Icons.AutoMirrored.Filled.Chat, + imageVector = primaryIcon, contentDescription = null, modifier = Modifier.size(28.dp) ) Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Chat", + text = primaryLabel, style = MaterialTheme.typography.bodyMedium ) } 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 11b64a4..d1ade41 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt @@ -1,35 +1,32 @@ package ru.fromchat.utils -import kotlin.time.Clock -import kotlin.time.Instant +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime /** * Returns a user-friendly string for online status / last seen from an ISO-8601 timestamp. - * E.g. "Online", "Last seen just now", "Last seen 5 min ago", "Last seen yesterday". + * + * 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" */ 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 nowSeconds = Clock.System.now().toEpochMilliseconds() / 1000L - val pastSeconds = instant.epochSeconds - val diffSeconds = nowSeconds - pastSeconds - val diffMinutes = diffSeconds / 60L - val diffHours = diffSeconds / 3600L - val diffDays = diffSeconds / 86400L - val relative = when { - diffMinutes < 1L -> "just now" - diffMinutes < 60L -> if (diffMinutes == 1L) "1 min ago" else "$diffMinutes min ago" - diffHours < 24L -> if (diffHours == 1L) "1 hour ago" else "$diffHours hours ago" - diffDays < 2L -> "yesterday" - else -> { - val parts = iso.split("T").firstOrNull()?.split("-") ?: return "Last seen $iso" - if (parts.size == 3) { - val (_, m, d) = parts - val month = listOf("", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec").getOrNull(m.toIntOrNull() ?: 0) ?: m - "$month $d" - } else "Last seen $iso" - } - } - return "Last seen $relative" + + val timeZone = TimeZone.currentSystemDefault() + val lastLocal = instant.toLocalDateTime(timeZone) + + 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" } + diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq new file mode 100644 index 0000000..a0a160b --- /dev/null +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -0,0 +1,86 @@ +CREATE TABLE conversation ( + id TEXT NOT NULL PRIMARY KEY, + type TEXT NOT NULL, -- "public" or "dm" + otherUserId INTEGER, + displayName TEXT, + lastMessageId INTEGER, + lastMessagePreview TEXT, + unreadCount INTEGER NOT NULL DEFAULT 0, + updatedAt TEXT +); + +CREATE TABLE message ( + id INTEGER NOT NULL, + conversationId TEXT NOT NULL, + userId INTEGER NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + isRead INTEGER NOT NULL, + isEdited INTEGER NOT NULL, + replyToId INTEGER, + clientMessageId TEXT, + deletedFlag INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (id, conversationId) +); + +CREATE INDEX message_conversation_index ON message(conversationId, timestamp); + +CREATE TABLE attachment ( + id INTEGER NOT NULL, + messageId INTEGER NOT NULL, + conversationId TEXT NOT NULL, + remotePath TEXT, + localPath TEXT, + status TEXT NOT NULL, -- "PENDING", "DOWNLOADING", "READY" + blurhash TEXT, + aspectRatio REAL, + size INTEGER, + PRIMARY KEY (id, messageId, conversationId) +); + +selectMessagesByConversation: +SELECT * +FROM message +WHERE conversationId = ? AND deletedFlag = 0 +ORDER BY timestamp ASC; + +deleteMessagesForConversation: +DELETE FROM message +WHERE conversationId = ?; + +upsertMessage: +INSERT OR REPLACE INTO message( + id, + conversationId, + userId, + content, + timestamp, + isRead, + isEdited, + replyToId, + clientMessageId, + deletedFlag +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +markMessageDeleted: +UPDATE message +SET deletedFlag = 1 +WHERE id = ? AND conversationId = ?; + +selectConversations: +SELECT * +FROM conversation +ORDER BY updatedAt DESC; + +upsertConversation: +INSERT OR REPLACE INTO conversation( + id, + type, + otherUserId, + displayName, + lastMessageId, + lastMessagePreview, + unreadCount, + updatedAt +) VALUES (?, ?, ?, ?, ?, ?, ?, ?); + diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt new file mode 100644 index 0000000..9506d85 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt @@ -0,0 +1,13 @@ +package ru.fromchat.api.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver +import ru.fromchat.db.MessageDatabase + +actual fun provideMessageDatabaseDriver(): SqlDriver { + return NativeSqliteDriver( + schema = MessageDatabase.Schema, + name = "message_database.db" + ) +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27a5d5b..f7f1f9b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ tweetnaclJava = "1.1.3" androidxWork = "2.11.2" cryptography-kotlin = "0.5.0" krypto = "4.0.10" +sqldelight = "2.3.2" [libraries] androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } @@ -98,6 +99,10 @@ krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" } cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography-kotlin" } cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-kotlin" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" } +sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } +sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -107,4 +112,5 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } -google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } \ No newline at end of file +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } \ No newline at end of file