mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement caching, redesign tabs and make a robust reconnection system
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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)))
|
||||
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) {
|
||||
@@ -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)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,32 +98,48 @@ class DmPanel(
|
||||
|
||||
override suspend fun loadMessages() {
|
||||
setLoading(true)
|
||||
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)
|
||||
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<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 }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
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}")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
Reference in New Issue
Block a user