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.compose.compiler)
alias(libs.plugins.kotlin.multiplatform.library) alias(libs.plugins.kotlin.multiplatform.library)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.sqldelight)
} }
kotlin { 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 { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
@@ -76,17 +70,38 @@ kotlin {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3) implementation(libs.coil.network.ktor3)
// SQLDelight runtime
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines.extensions)
implementation(project(":utils:shared")) implementation(project(":utils:shared"))
implementation(libs.krypto) implementation(libs.krypto)
implementation(libs.cryptography.core) implementation(libs.cryptography.core)
implementation(libs.cryptography.provider.optimal) 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 { iosMain.dependencies {
implementation(libs.jetbrains.kotlinx.io.bytestring) implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.darwin) implementation(libs.ktor.client.darwin)
implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.5") 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 package ru.fromchat.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -8,6 +9,18 @@ data class WebSocketUpdatesData(
val updates: List<WebSocketMessage> 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 @Serializable
data class ReactionUpdateData( data class ReactionUpdateData(
val message_id: Int, val message_id: Int,
@@ -16,6 +16,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -49,6 +50,7 @@ object WebSocketManager {
// State // State
@Volatile private var connecting = false @Volatile private var connecting = false
@Volatile private var session: DefaultClientWebSocketSession? = null @Volatile private var session: DefaultClientWebSocketSession? = null
@Volatile private var connectionJob: Job? = null
/** /**
* Check if WebSocket is connected * Check if WebSocket is connected
@@ -72,19 +74,36 @@ object WebSocketManager {
fun connect() { fun connect() {
Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting") 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 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) { while (isActive) {
Logger.d("WebSocketManager", "Connection loop active. isActive=$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 { try {
val wsUrl = Config.webSocketUrl val wsUrl = Config.webSocketUrl
Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl") Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl")
connecting = true
ConnectionStateStore.onConnecting()
ApiClient.http.webSocket( ApiClient.http.webSocket(
method = HttpMethod.Get, method = HttpMethod.Get,
request = { request = {
@@ -93,12 +112,29 @@ 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()
// Send ping message immediately after connection for authentication // Send ping message immediately after connection for authentication
ApiClient.token?.let { Logger.d("WebSocketManager", "Sending WebSocket ping for authentication")
Logger.d("WebSocketManager", "Sending WebSocket ping for authentication") send(
send(WebSocketMessage(type = "ping", credentials = WebSocketCredentials(scheme = "Bearer", credentials = it))) 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) { for (frame in incoming) {
@@ -107,9 +143,17 @@ object WebSocketManager {
try { try {
val jsonTree = json.parseToJsonElement(text) val jsonTree = json.parseToJsonElement(text)
val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content
val msg = when (messageType) { val msg = when (messageType) {
"updates" -> { "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( WebSocketMessage(
type = "updates", type = "updates",
data = jsonTree data = jsonTree
@@ -126,7 +170,7 @@ object WebSocketManager {
json.decodeFromString<WebSocketMessage>(text) json.decodeFromString<WebSocketMessage>(text)
} }
} }
globalHandlers.forEach { it(msg) } globalHandlers.forEach { it(msg) }
_messages.emit(msg) _messages.emit(msg)
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -137,14 +181,17 @@ object WebSocketManager {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w("WebSocketManager", "An error occurred during WebSocket connection: ${e.message}", e) 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 { } finally {
Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false") Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false")
session = null session = null
connecting = false 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.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
@@ -49,6 +50,11 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
// Load persisted token and user data // Load persisted token and user data
ApiClient.loadPersistedData() 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 // Now determine start destination based on loaded token
val hasToken = ApiClient.token?.isNotEmpty() == true val hasToken = ApiClient.token?.isNotEmpty() == true
startDestination = when { startDestination = when {
@@ -64,12 +70,11 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
// Connect WebSocket when app comes to foreground // Ensure WebSocket connection loop is running when app comes to foreground
WebSocketManager.connect() WebSocketManager.connect()
} }
Lifecycle.Event.ON_PAUSE -> { Lifecycle.Event.ON_PAUSE -> {
// Disconnect WebSocket when app goes to background // No-op for connection lifecycle: WebSocketManager keeps trying to reconnect
WebSocketManager.disconnect()
} }
else -> {} else -> {}
} }
@@ -5,6 +5,8 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -74,6 +76,8 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentUploadJob import ru.fromchat.api.AttachmentUploadJob
import ru.fromchat.api.AttachmentUploadQueue import ru.fromchat.api.AttachmentUploadQueue
import ru.fromchat.api.ConnectionStateStore
import ru.fromchat.api.ConnectionStatus
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.UserStatusStore import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
@@ -137,6 +141,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()
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 }}")
} }
@@ -388,30 +393,66 @@ fun ChatScreen(
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
AnimatedContent( val subtitleKey = when {
targetState = currentTypingUsers.isNotEmpty(), connectionStatus == ConnectionStatus.UPDATING -> "updating"
transitionSpec = { connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
fadeIn() togetherWith fadeOut() currentTypingUsers.isNotEmpty() -> "typing"
}, panelState.profileUserId != null -> {
label = "typing_status"
) { hasTyping ->
if (hasTyping) {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
)
} else if (panelState.profileUserId != null) {
val userStatus = statusMap[panelState.profileUserId] val userStatus = statusMap[panelState.profileUserId]
if (userStatus != null) { val statusText = userStatus?.let {
val statusText = formatLastSeen(userStatus.online, userStatus.lastSeen) formatLastSeen(it.online, it.lastSeen)
if (statusText.isNotEmpty()) { }.orEmpty()
Text( if (statusText.isNotEmpty()) {
text = statusText, "presence:$statusText"
style = MaterialTheme.typography.bodySmall, } else {
color = MaterialTheme.colorScheme.onSurfaceVariant, ""
modifier = Modifier.padding(top = 2.dp) }
) }
} 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
} }
} }
} }
@@ -9,6 +9,7 @@ import ru.fromchat.api.ReactionUpdateData
import ru.fromchat.api.TypingUpdateData import ru.fromchat.api.TypingUpdateData
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
class PublicChatPanel( class PublicChatPanel(
@@ -49,17 +50,31 @@ class PublicChatPanel(
setLoading(true) setLoading(true)
try { 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) val response = ApiClient.getMessages(limit = 50)
if (response.messages.isNotEmpty()) { if (response.messages.isNotEmpty()) {
clearMessages() clearMessages()
response.messages.forEach { message -> response.messages.forEach { message ->
addMessage(message) addMessage(message)
} }
// Persist fresh messages to cache for offline use.
MessageCacheStore.replacePublicMessages(response.messages)
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
messagesLoaded = true messagesLoaded = true
} catch (_: Exception) { } catch (_: Exception) {
// Handle error // Keep whatever cached state we have; no-op on error.
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -110,17 +125,24 @@ class PublicChatPanel(
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id) DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { editedMsg } updateMessage(editedMsg.id) { editedMsg }
// Update cache to reflect edit
MessageCacheStore.replacePublicMessages(_state.messages)
} }
"messageDeleted" -> { "messageDeleted" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data) val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
DecryptedImageCache.invalidateForMessage(deletedData.message_id) DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id) removeMessage(deletedData.message_id)
// Mark deleted in cache
MessageCacheStore.markMessageDeleted("public", deletedData.message_id)
MessageCacheStore.replacePublicMessages(_state.messages)
} }
"reactionUpdate" -> { "reactionUpdate" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data) val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
handleReactionUpdate(reactionUpdate) handleReactionUpdate(reactionUpdate)
// Re-write cache so reactions are updated
MessageCacheStore.replacePublicMessages(_state.messages)
} }
"typing" -> { "typing" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
@@ -21,6 +21,7 @@ import ru.fromchat.core.Logger
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder import ru.fromchat.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.crypto.DmCiphertextCorruptedException import ru.fromchat.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.AvatarInfo
import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.ChatPanel
import ru.fromchat.ui.chat.DecryptedImageCache import ru.fromchat.ui.chat.DecryptedImageCache
@@ -97,32 +98,48 @@ class DmPanel(
override suspend fun loadMessages() { override suspend fun loadMessages() {
setLoading(true) setLoading(true)
runCatching { try {
ApiClient.getDmHistory(otherUserId) // First, hydrate from cache if available.
}.onSuccess { response -> runCatching {
clearMessages() val cached = MessageCacheStore.loadDmMessages(otherUserId)
val decryptedForLog = mutableListOf<Pair<Int, String>>() if (cached.isNotEmpty()) {
val messages = response.messages.map { envelope -> clearMessages()
val outcome = decryptDmEnvelopeForUi(envelope) addMessages(cached)
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") // Then refresh from network.
runCatching {
ApiClient.getDmHistory(otherUserId)
}.onSuccess { response ->
clearMessages()
val decryptedForLog = mutableListOf<Pair<Int, String>>()
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 } } finally {
val messagesWithReplies = messages.map { msg -> setLoading(false)
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)
} }
setLoading(false)
} }
override suspend fun loadMoreMessages() { override suspend fun loadMoreMessages() {
@@ -191,6 +208,9 @@ class DmPanel(
val replyTo = _state.messages.find { it.id == envelope.replyToId } val replyTo = _state.messages.find { it.id == envelope.replyToId }
updateMessage(envelope.id) { it.copy(reply_to = replyTo) } 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 isContentCorrupted = outcome.isCorrupted
) )
} }
// Persist edit to cache
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
} }
} }
@@ -1,5 +1,11 @@
package ru.fromchat.ui.main 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.clickable
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -9,13 +15,23 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable 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.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
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res 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.chat_last_mesaage
import ru.fromchat.chats
import ru.fromchat.public_chat import ru.fromchat.public_chat
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
@@ -24,17 +40,51 @@ import ru.fromchat.ui.LocalNavController
fun ChatsTab() { fun ChatsTab() {
val navController = LocalNavController.current val navController = LocalNavController.current
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() 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( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
title = { title = {
Text( AnimatedContent(
text = stringResource(Res.string.chats), targetState = titleText,
maxLines = 1, transitionSpec = {
overflow = TextOverflow.Ellipsis (slideInVertically { it / 2 } + fadeIn()) togetherWith
) (slideOutVertically { -it / 2 } + fadeOut())
},
label = "chats_title"
) { text ->
Text(
text = text,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}, },
scrollBehavior = scrollBehavior 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.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts 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.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
@@ -25,12 +25,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.chats import ru.fromchat.chats
import ru.fromchat.coming_soon import ru.fromchat.coming_soon
import ru.fromchat.contacts import ru.fromchat.contacts
import ru.fromchat.dms
import ru.fromchat.settings import ru.fromchat.settings
import ru.fromchat.utils.exclude import ru.fromchat.utils.exclude
import ru.fromchat.ui.profile.ProfileScreen
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
@Composable @Composable
@@ -52,18 +53,18 @@ fun MainScreen(onLogout: () -> Unit = {}) {
label = { Text(stringResource(Res.string.contacts)) }, label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) } 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( NavigationBarItem(
selected = selectedTab == "settings", selected = selectedTab == "settings",
onClick = { selectedTab = "settings" }, onClick = { selectedTab = "settings" },
label = { Text(stringResource(Res.string.settings)) }, label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) } 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), contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
@@ -79,12 +80,19 @@ fun MainScreen(onLogout: () -> Unit = {}) {
"contacts" -> { "contacts" -> {
Text(stringResource(Res.string.coming_soon)) Text(stringResource(Res.string.coming_soon))
} }
"dms" -> {
Text(stringResource(Res.string.coming_soon))
}
"settings" -> { "settings" -> {
SettingsTab(onLogout = onLogout) 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.CalendarMonth
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -86,7 +87,8 @@ fun ProfileScreen(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarKey: Any? = null, sharedAvatarKey: Any? = null,
initialDisplayName: String? = null initialDisplayName: String? = null,
onOpenSettings: () -> Unit = {}
) { ) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val targetUserId = userId.takeIf { it != null && it > 0 } val targetUserId = userId.takeIf { it != null && it > 0 }
@@ -239,6 +241,7 @@ fun ProfileScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify" val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify"
val isOwnProfile = ApiClient.user?.id == profile.id
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -267,13 +270,23 @@ fun ProfileScreen(
.padding(start = 16.dp, end = 16.dp, bottom = 20.dp), .padding(start = 16.dp, end = 16.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(12.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( Card(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.scaleOnPress( .scaleOnPress(
scale = 0.90f, scale = 0.90f,
interactionSource = chatSource, interactionSource = primarySource,
clipShape = MaterialTheme.shapes.extraLarge clipShape = MaterialTheme.shapes.extraLarge
), ),
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
@@ -283,9 +296,9 @@ fun ProfileScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable( .clickable(
interactionSource = chatSource, interactionSource = primarySource,
indication = LocalIndication.current, indication = LocalIndication.current,
onClick = { onChat(profile.id) } onClick = primaryClick
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -294,13 +307,13 @@ fun ProfileScreen(
modifier = Modifier.padding(vertical = 16.dp) modifier = Modifier.padding(vertical = 16.dp)
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Chat, imageVector = primaryIcon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
text = "Chat", text = primaryLabel,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -1,35 +1,32 @@
package ru.fromchat.utils package ru.fromchat.utils
import kotlin.time.Clock import kotlinx.datetime.Instant
import kotlin.time.Instant import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
/** /**
* Returns a user-friendly string for online status / last seen from an ISO-8601 timestamp. * 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 { 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 $iso"
val nowSeconds = Clock.System.now().toEpochMilliseconds() / 1000L
val pastSeconds = instant.epochSeconds val timeZone = TimeZone.currentSystemDefault()
val diffSeconds = nowSeconds - pastSeconds val lastLocal = instant.toLocalDateTime(timeZone)
val diffMinutes = diffSeconds / 60L
val diffHours = diffSeconds / 3600L val year = lastLocal.date.year
val diffDays = diffSeconds / 86400L val month = lastLocal.date.monthNumber.toString().padStart(2, '0')
val relative = when { val day = lastLocal.date.dayOfMonth.toString().padStart(2, '0')
diffMinutes < 1L -> "just now" val hour = lastLocal.hour.toString().padStart(2, '0')
diffMinutes < 60L -> if (diffMinutes == 1L) "1 min ago" else "$diffMinutes min ago" val minute = lastLocal.minute.toString().padStart(2, '0')
diffHours < 24L -> if (diffHours == 1L) "1 hour ago" else "$diffHours hours ago" val timePart = "$hour:$minute"
diffDays < 2L -> "yesterday"
else -> { return "Last seen $year-$month-$day $timePart"
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"
} }
@@ -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"
)
}
+7 -1
View File
@@ -39,6 +39,7 @@ tweetnaclJava = "1.1.3"
androidxWork = "2.11.2" androidxWork = "2.11.2"
cryptography-kotlin = "0.5.0" cryptography-kotlin = "0.5.0"
krypto = "4.0.10" krypto = "4.0.10"
sqldelight = "2.3.2"
[libraries] [libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } 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-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" } 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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" } kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }