Implement caching, redesign tabs and make a robust reconnection system

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-02 17:26:09 +03:00
Unverified
parent ff1ef8872c
commit ee87a70f14
19 changed files with 828 additions and 120 deletions
+22 -7
View File
@@ -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")
}
}
}
@@ -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"
)
}
@@ -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<ConnectionStatus> = _status.asStateFlow()
private val _metadata = MutableStateFlow(ConnectionMetadata())
val metadata: StateFlow<ConnectionMetadata> = _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)
}
}
@@ -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<Int> = _lastSeq.asStateFlow()
private val _lastMissedCount = MutableStateFlow<Int?>(null)
val lastMissedCount: StateFlow<Int?> = _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
}
}
}
@@ -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<WebSocketMessage>
)
@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,
@@ -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)))
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) {
@@ -110,6 +146,14 @@ object WebSocketManager {
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
@@ -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)
}
}
}
}
@@ -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<Message> {
return loadMessages(conversationIdForPublic())
}
suspend fun replacePublicMessages(messages: List<Message>) {
replaceMessages(conversationIdForPublic(), messages)
}
suspend fun loadDmMessages(otherUserId: Int): List<Message> {
return loadMessages(conversationIdForDm(otherUserId))
}
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
replaceMessages(conversationIdForDm(otherUserId), messages)
}
private suspend fun loadMessages(conversationId: String): List<Message> =
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<Message>) {
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<DmConversation>) {
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<CachedConversation> =
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()
)
}
}
}
@@ -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())
}
}
@@ -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 -> {}
}
@@ -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)
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 = statusText,
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
}
}
}
@@ -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
@@ -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,6 +98,17 @@ class DmPanel(
override suspend fun loadMessages() {
setLoading(true)
try {
// First, hydrate from cache if available.
runCatching {
val cached = MessageCacheStore.loadDmMessages(otherUserId)
if (cached.isNotEmpty()) {
clearMessages()
addMessages(cached)
}
}
// Then refresh from network.
runCatching {
ApiClient.getDmHistory(otherUserId)
}.onSuccess { response ->
@@ -119,11 +131,16 @@ class DmPanel(
}
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)
}
} finally {
setLoading(false)
}
}
override suspend fun loadMoreMessages() {
// Not implemented yet
@@ -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)
}
}
@@ -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<List<CachedConversation>>(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 = {
AnimatedContent(
targetState = titleText,
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "chats_title"
) { text ->
Text(
text = stringResource(Res.string.chats),
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}")
}
}
)
}
}
}
}
@@ -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" }
)
}
}
}
}
@@ -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
)
}
@@ -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"
}
@@ -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 (?, ?, ?, ?, ?, ?, ?, ?);
@@ -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"
)
}
+6
View File
@@ -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" }
@@ -108,3 +113,4 @@ kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library"
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" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }