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.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(WebSocketMessage(type = "ping", credentials = WebSocketCredentials(scheme = "Bearer", credentials = it)))
|
send(
|
||||||
|
WebSocketMessage(
|
||||||
|
type = "ping",
|
||||||
|
credentials = WebSocketCredentials(
|
||||||
|
scheme = "Bearer",
|
||||||
|
credentials = token
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kick off gap detection in the background; it will no-op if not needed.
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
UpdateSyncManager.runGapDetectionIfNeeded()
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("WebSocketManager", "Gap detection failed: ${it.message}", it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (frame in incoming) {
|
for (frame in incoming) {
|
||||||
@@ -110,6 +146,14 @@ object WebSocketManager {
|
|||||||
|
|
||||||
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
|
||||||
@@ -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)
|
||||||
|
}.orEmpty()
|
||||||
if (statusText.isNotEmpty()) {
|
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(
|
||||||
text = statusText,
|
text = "Updating...",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
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,6 +98,17 @@ class DmPanel(
|
|||||||
|
|
||||||
override suspend fun loadMessages() {
|
override suspend fun loadMessages() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// First, hydrate from cache if available.
|
||||||
|
runCatching {
|
||||||
|
val cached = MessageCacheStore.loadDmMessages(otherUserId)
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
clearMessages()
|
||||||
|
addMessages(cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then refresh from network.
|
||||||
runCatching {
|
runCatching {
|
||||||
ApiClient.getDmHistory(otherUserId)
|
ApiClient.getDmHistory(otherUserId)
|
||||||
}.onSuccess { response ->
|
}.onSuccess { response ->
|
||||||
@@ -119,11 +131,16 @@ class DmPanel(
|
|||||||
}
|
}
|
||||||
addMessages(messagesWithReplies)
|
addMessages(messagesWithReplies)
|
||||||
setHasMoreMessages(false)
|
setHasMoreMessages(false)
|
||||||
|
|
||||||
|
// Persist the most recent DM messages for offline use.
|
||||||
|
MessageCacheStore.replaceDmMessages(otherUserId, messagesWithReplies)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error)
|
Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun loadMoreMessages() {
|
override suspend fun loadMoreMessages() {
|
||||||
// Not implemented yet
|
// Not implemented yet
|
||||||
@@ -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 = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = titleText,
|
||||||
|
transitionSpec = {
|
||||||
|
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
||||||
|
(slideOutVertically { -it / 2 } + fadeOut())
|
||||||
|
},
|
||||||
|
label = "chats_title"
|
||||||
|
) { text ->
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.chats),
|
text = text,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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" }
|
||||||
@@ -108,3 +113,4 @@ kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library"
|
|||||||
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" }
|
||||||
Reference in New Issue
Block a user