mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -19,6 +19,9 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
android:resource="@drawable/logo" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class App: Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
UtilsLibrary.init(this)
|
UtilsLibrary.init(this)
|
||||||
|
ru.fromchat.notifications.ChatNotificationDismissals.install(this)
|
||||||
|
|
||||||
WebSocketManager.addGlobalMessageHandler { msg ->
|
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ object NotificationHelper {
|
|||||||
notify(
|
notify(
|
||||||
SUMMARY_NOTIFICATION_ID,
|
SUMMARY_NOTIFICATION_ID,
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.logo_big)
|
.setSmallIcon(R.drawable.logo)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
@@ -506,7 +506,7 @@ object NotificationHelper {
|
|||||||
notify(
|
notify(
|
||||||
SUMMARY_NOTIFICATION_ID,
|
SUMMARY_NOTIFICATION_ID,
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.logo_big)
|
.setSmallIcon(R.drawable.logo)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
NotificationCompat.MessagingStyle(
|
NotificationCompat.MessagingStyle(
|
||||||
Person.Builder().setName("FromChat").build()
|
Person.Builder().setName("FromChat").build()
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
|
||||||
|
actual object ChatNotificationDismissals {
|
||||||
|
/** Must match [ru.fromchat.notifications.NotificationHelper] summary id in app:android. */
|
||||||
|
private const val SUMMARY_NOTIFICATION_ID = 1_000_000
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
fun install(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun dismissAllMessageNotifications() {
|
||||||
|
val context = appContext ?: return
|
||||||
|
runCatching {
|
||||||
|
NotificationManagerCompat.from(context).cancel(SUMMARY_NOTIFICATION_ID)
|
||||||
|
Logger.d("ChatNotificationDismissals", "Cancelled message notifications")
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("ChatNotificationDismissals", "Failed to cancel notifications: ${it.message}", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ import ru.fromchat.api.schema.calls.LiveKitTokenResponse
|
|||||||
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
||||||
import ru.fromchat.api.schema.messages.MarkReadRequest
|
import ru.fromchat.api.schema.messages.MarkReadRequest
|
||||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||||
|
import ru.fromchat.api.schema.messages.dm.DmArchiveRequest
|
||||||
import ru.fromchat.api.schema.messages.dm.DmConversation
|
import ru.fromchat.api.schema.messages.dm.DmConversation
|
||||||
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
|
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
|
||||||
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
||||||
@@ -558,12 +559,18 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
|
|
||||||
suspend fun getProfileById(userId: Int): UserProfile =
|
suspend fun getProfileById(userId: Int, force: Boolean = false): UserProfile {
|
||||||
http
|
if (!force) {
|
||||||
|
ProfileCache.get(userId)?.takeIf {
|
||||||
|
!it.isClientPreviewOnly && ProfileCache.hasFreshFullProfile(userId)
|
||||||
|
}?.let { return it }
|
||||||
|
}
|
||||||
|
return http
|
||||||
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
|
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getProfileByUsername(username: String): UserProfile =
|
suspend fun getProfileByUsername(username: String): UserProfile =
|
||||||
http
|
http
|
||||||
@@ -681,6 +688,13 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun archiveDmConversation(otherUserId: Int, archived: Boolean = true) {
|
||||||
|
http.post("${ServerConfig.apiBaseUrl}/dm/conversations/$otherUserId/archive") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(DmArchiveRequest(archived = archived))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun searchUsers(query: String): List<User> {
|
suspend fun searchUsers(query: String): List<User> {
|
||||||
val trimmed = query.trim()
|
val trimmed = query.trim()
|
||||||
if (trimmed.length < 2) return emptyList()
|
if (trimmed.length < 2) return emptyList()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||||
@@ -16,14 +15,14 @@ import ru.fromchat.api.local.db.store.ConnectionStatus
|
|||||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||||
import ru.fromchat.api.local.db.store.MessageRepository
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.messages.PublicInboxCoordinator
|
||||||
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
|
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
|
||||||
import ru.fromchat.api.schema.messages.Message
|
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat
|
* Keeps the chats tab list in sync: DM conversations from the server and public-chat
|
||||||
* message for list previews (without opening each chat first).
|
* previews (cache is filled by the updates pipeline / history rebuild).
|
||||||
*/
|
*/
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
object ChatListSync {
|
object ChatListSync {
|
||||||
@@ -58,7 +57,14 @@ object ChatListSync {
|
|||||||
suspend fun syncFromNetwork() {
|
suspend fun syncFromNetwork() {
|
||||||
if (!canSync()) return
|
if (!canSync()) return
|
||||||
syncDmConversations()
|
syncDmConversations()
|
||||||
syncPublicChatPreview()
|
// Preview only — never the sole catch-up path for missed messages.
|
||||||
|
refreshPublicChatPreviewFromLatest()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used by tooLong rebuild before per-chat history fetch. */
|
||||||
|
suspend fun syncDmConversationsForRebuild() {
|
||||||
|
if (!canSync()) return
|
||||||
|
syncDmConversations()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canSync(): Boolean {
|
private fun canSync(): Boolean {
|
||||||
@@ -78,12 +84,13 @@ object ChatListSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPublicChatPreview() {
|
private suspend fun refreshPublicChatPreviewFromLatest() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val response = ApiClient.getMessages(limit = 1)
|
val response = ApiClient.getMessages(limit = 1)
|
||||||
val latest = response.messages.maxByOrNull { message ->
|
val latest = response.messages.maxByOrNull { message ->
|
||||||
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
|
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
|
||||||
} ?: return@runCatching
|
} ?: return@runCatching
|
||||||
|
// Upsert only — does not wipe older cached messages.
|
||||||
MessageRepository.upsertPublicMessage(latest)
|
MessageRepository.upsertPublicMessage(latest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,16 +107,14 @@ object ChatListSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"newMessage" -> message.data?.let { element ->
|
"newMessage" -> message.data?.let { element ->
|
||||||
scope.launch { ingestPublicMessage(element) }
|
scope.launch { PublicInboxCoordinator.processNew(element) }
|
||||||
|
}
|
||||||
|
"messageEdited" -> message.data?.let { element ->
|
||||||
|
scope.launch { PublicInboxCoordinator.processEdited(element) }
|
||||||
|
}
|
||||||
|
"messageDeleted" -> message.data?.let { element ->
|
||||||
|
scope.launch { PublicInboxCoordinator.processDeleted(element) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ingestPublicMessage(element: JsonElement) {
|
|
||||||
val newMsg = runCatching {
|
|
||||||
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
|
|
||||||
}.getOrNull() ?: return
|
|
||||||
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
|
||||||
MessageRepository.upsertPublicMessage(newMsg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
package ru.fromchat.api
|
package ru.fromchat.api
|
||||||
|
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||||
|
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||||
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.messages.DmInboundMessageProcessor
|
||||||
|
import ru.fromchat.api.local.messages.UpdatesBatchApplier
|
||||||
|
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
|
||||||
|
import ru.fromchat.api.schema.messages.Message
|
||||||
|
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
|
import ru.fromchat.api.schema.websocket.requests.AckUpdatesRequest
|
||||||
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
|
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
|
||||||
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
|
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks the last seen WebSocket update sequence for the current user and
|
* Per-device update cursor: advance + ack only after batches are applied successfully.
|
||||||
* persists it between sessions so we can ask the backend for missed updates.
|
|
||||||
*/
|
*/
|
||||||
object UpdateSyncManager {
|
object UpdateSyncManager {
|
||||||
private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_"
|
private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_"
|
||||||
|
private const val HISTORY_PAGE_SIZE = 50
|
||||||
|
private const val MAX_HISTORY_PAGES = 20
|
||||||
|
|
||||||
private val _lastSeq = MutableStateFlow(0)
|
private val _lastSeq = MutableStateFlow(0)
|
||||||
val lastSeq: StateFlow<Int> = _lastSeq.asStateFlow()
|
val lastSeq: StateFlow<Int> = _lastSeq.asStateFlow()
|
||||||
@@ -30,6 +42,8 @@ object UpdateSyncManager {
|
|||||||
private val _lastMissedCount = MutableStateFlow<Int?>(null)
|
private val _lastMissedCount = MutableStateFlow<Int?>(null)
|
||||||
val lastMissedCount: StateFlow<Int?> = _lastMissedCount.asStateFlow()
|
val lastMissedCount: StateFlow<Int?> = _lastMissedCount.asStateFlow()
|
||||||
|
|
||||||
|
private val applyMutex = Mutex()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var gapDetectionInProgress: Boolean = false
|
private var gapDetectionInProgress: Boolean = false
|
||||||
|
|
||||||
@@ -42,22 +56,15 @@ object UpdateSyncManager {
|
|||||||
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
|
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
/**
|
||||||
fun onUpdatesBatch(seq: Int) {
|
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
|
||||||
if (seq <= 0) return
|
*/
|
||||||
val currentUserId = ApiClient.user?.id ?: return
|
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
|
||||||
val newSeq = seq.coerceAtLeast(_lastSeq.value)
|
applyMutex.withLock {
|
||||||
if (newSeq == _lastSeq.value) return
|
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
|
||||||
|
if (seq > _lastSeq.value) {
|
||||||
_lastSeq.value = newSeq
|
persistLastSeq(seq)
|
||||||
ConnectionStateStore.updateSeqAndMissed(lastSeq = newSeq, missedCount = _lastMissedCount.value)
|
sendAck(seq)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,9 +88,8 @@ object UpdateSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the backend for missed updates between our lastSeq and the current sequence.
|
* Catch up from [lastSeq]: chunked getUpdates, or tooLong → history rebuild.
|
||||||
* This call is idempotent while in progress and will no-op if there is no active
|
* Does not advance the cursor until apply/rebuild succeeds.
|
||||||
* WebSocket session or no authenticated user.
|
|
||||||
*/
|
*/
|
||||||
suspend fun runGapDetectionIfNeeded() {
|
suspend fun runGapDetectionIfNeeded() {
|
||||||
if (gapDetectionInProgress) {
|
if (gapDetectionInProgress) {
|
||||||
@@ -98,52 +104,158 @@ object UpdateSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gapDetectionInProgress = true
|
gapDetectionInProgress = true
|
||||||
val startSeq = _lastSeq.value
|
ConnectionStateStore.onUpdating(start = true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.i("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
|
var rounds = 0
|
||||||
if (startSeq > 0) {
|
while (rounds < 100) {
|
||||||
ConnectionStateStore.onUpdating(start = true)
|
rounds++
|
||||||
}
|
val startSeq = _lastSeq.value
|
||||||
|
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
|
||||||
|
|
||||||
val requestMessage = WebSocketMessage(
|
val response = requestGetUpdates(token, startSeq) ?: break
|
||||||
type = "getUpdates",
|
Logger.i(
|
||||||
credentials = WebSocketCredentials(
|
"UpdateSyncManager",
|
||||||
scheme = "Bearer",
|
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
|
||||||
credentials = token
|
"missed=${response.missedCount}, hasMore=${response.hasMore}",
|
||||||
),
|
|
||||||
data = ApiClient.json.encodeToJsonElement(
|
|
||||||
GetUpdatesRequest.serializer(),
|
|
||||||
GetUpdatesRequest(lastSeq = startSeq)
|
|
||||||
)
|
)
|
||||||
)
|
updateMissedCount(response.missedCount)
|
||||||
|
|
||||||
val response = WebSocketManager.request(requestMessage)
|
when (response.status) {
|
||||||
val data = response?.data
|
"tooLong" -> {
|
||||||
|
val ok = rebuildStateFromHistory()
|
||||||
if (data != null) {
|
if (ok) {
|
||||||
runCatching {
|
persistLastSeq(response.lastSeq)
|
||||||
val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
sendAck(response.lastSeq)
|
||||||
Logger.i(
|
} else {
|
||||||
"UpdateSyncManager",
|
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
|
||||||
"Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}"
|
}
|
||||||
)
|
break
|
||||||
onUpdatesBatch(parsed.lastSeq)
|
}
|
||||||
updateMissedCount(parsed.missedCount)
|
"ok" -> {
|
||||||
}.onFailure {
|
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
|
||||||
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
|
persistLastSeq(response.lastSeq)
|
||||||
|
sendAck(response.lastSeq)
|
||||||
|
}
|
||||||
|
if (!response.hasMore) break
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Logger.d("UpdateSyncManager", "No data returned from getUpdates; treating as no-op")
|
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
|
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
|
||||||
} finally {
|
} finally {
|
||||||
if (startSeq > 0) {
|
ConnectionStateStore.onUpdating(start = false)
|
||||||
ConnectionStateStore.onUpdating(start = false)
|
|
||||||
}
|
|
||||||
gapDetectionInProgress = false
|
gapDetectionInProgress = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private suspend fun requestGetUpdates(token: String, lastSeq: Int): GetUpdatesResponse? {
|
||||||
|
val requestMessage = WebSocketMessage(
|
||||||
|
type = "getUpdates",
|
||||||
|
credentials = WebSocketCredentials(
|
||||||
|
scheme = "Bearer",
|
||||||
|
credentials = token,
|
||||||
|
),
|
||||||
|
data = ApiClient.json.encodeToJsonElement(
|
||||||
|
GetUpdatesRequest.serializer(),
|
||||||
|
GetUpdatesRequest(lastSeq = lastSeq),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val response = WebSocketManager.request(requestMessage)
|
||||||
|
val data = response?.data ?: return null
|
||||||
|
return runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendAck(seq: Int) {
|
||||||
|
val token = ApiClient.token ?: return
|
||||||
|
if (seq <= 0) return
|
||||||
|
runCatching {
|
||||||
|
WebSocketManager.request(
|
||||||
|
WebSocketMessage(
|
||||||
|
type = "ackUpdates",
|
||||||
|
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
|
||||||
|
data = ApiClient.json.encodeToJsonElement(
|
||||||
|
AckUpdatesRequest.serializer(),
|
||||||
|
AckUpdatesRequest(lastSeq = seq),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persistLastSeq(seq: Int) {
|
||||||
|
val currentUserId = ApiClient.user?.id ?: return
|
||||||
|
if (seq <= _lastSeq.value) return
|
||||||
|
_lastSeq.value = seq
|
||||||
|
ConnectionStateStore.updateSeqAndMissed(lastSeq = seq, missedCount = _lastMissedCount.value)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
runCatching {
|
||||||
|
settings.putInt(KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId, seq)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$seq: ${it.message}", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun rebuildStateFromHistory(): Boolean = withContext(Dispatchers.Default) {
|
||||||
|
Logger.i("UpdateSyncManager", "Rebuilding local state from history (tooLong)")
|
||||||
|
runCatching {
|
||||||
|
ChatListSync.syncDmConversationsForRebuild()
|
||||||
|
rebuildPublicHistory()
|
||||||
|
rebuildDmHistories()
|
||||||
|
true
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("UpdateSyncManager", "rebuildStateFromHistory failed: ${it.message}", it)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rebuildPublicHistory() {
|
||||||
|
val collected = LinkedHashMap<Int, Message>()
|
||||||
|
var beforeId: Int? = null
|
||||||
|
repeat(MAX_HISTORY_PAGES) {
|
||||||
|
val page = ApiClient.getMessages(limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
|
||||||
|
if (page.messages.isEmpty()) return@repeat
|
||||||
|
page.messages.forEach { msg ->
|
||||||
|
ProfileCache.mergePreviewFromPublicMessage(msg)
|
||||||
|
collected[msg.id] = msg
|
||||||
|
}
|
||||||
|
val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
|
||||||
|
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
|
||||||
|
beforeId = oldest.id
|
||||||
|
}
|
||||||
|
val ordered = collected.values.sortedBy {
|
||||||
|
parseMessageTimestampMillis(it.timestamp) ?: 0L
|
||||||
|
}
|
||||||
|
MessageRepository.replacePublicMessages(ordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rebuildDmHistories() {
|
||||||
|
val conversations = MessageRepository.loadCachedDmConversations()
|
||||||
|
for (conversation in conversations) {
|
||||||
|
val otherId = conversation.otherUserId
|
||||||
|
MessageCacheStore.clearDmMessages(otherId)
|
||||||
|
var beforeId: Int? = null
|
||||||
|
repeat(MAX_HISTORY_PAGES) {
|
||||||
|
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
|
||||||
|
if (page.messages.isEmpty()) return@repeat
|
||||||
|
for (envelope in page.messages) {
|
||||||
|
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
|
||||||
|
DmInboundMessageProcessor.processNew(element)
|
||||||
|
}
|
||||||
|
val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
|
||||||
|
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
|
||||||
|
beforeId = oldest.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import ru.fromchat.api.local.db.store.ConnectionStateStore
|
|||||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -234,12 +233,11 @@ object WebSocketManager {
|
|||||||
|
|
||||||
val msg = when (messageType) {
|
val msg = when (messageType) {
|
||||||
"updates" -> {
|
"updates" -> {
|
||||||
|
// Apply add/edit/delete before advancing; ack happens inside.
|
||||||
runCatching {
|
runCatching {
|
||||||
val updatesData = json.decodeFromJsonElement(
|
UpdateSyncManager.onUpdatesEnvelope(jsonTree)
|
||||||
WebSocketUpdatesData.serializer(), jsonTree)
|
|
||||||
UpdateSyncManager.onUpdatesBatch(updatesData.seq)
|
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
logW("Failed to decode updates envelope for seq tracking: ${it.message}", it)
|
logW("Failed to apply updates envelope: ${it.message}", it)
|
||||||
}
|
}
|
||||||
|
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
|
|||||||
+2
-1
@@ -539,7 +539,7 @@ object MessageCacheStore {
|
|||||||
.filter { it.type == "dm" }
|
.filter { it.type == "dm" }
|
||||||
localDm.forEach { row ->
|
localDm.forEach { row ->
|
||||||
val otherId = row.otherUserId?.toInt() ?: return@forEach
|
val otherId = row.otherUserId?.toInt() ?: return@forEach
|
||||||
if (otherId !in serverOtherUserIds) {
|
if (otherId !in serverOtherUserIds && row.archived == 0L) {
|
||||||
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
|
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,6 +690,7 @@ object MessageCacheStore {
|
|||||||
id = convId,
|
id = convId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DmConversationListNotifier.notifyChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteDmConversation(otherUserId: Int) {
|
suspend fun deleteDmConversation(otherUserId: Int) {
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ object MessageRepository {
|
|||||||
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||||
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
|
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
|
||||||
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
|
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
|
||||||
|
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
|
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
|
||||||
@@ -137,6 +138,7 @@ object MessageRepository {
|
|||||||
runCatching { ApiClient.markMessagesRead(ids) }
|
runCatching { ApiClient.markMessagesRead(ids) }
|
||||||
}
|
}
|
||||||
MessageCacheStore.markPublicMessagesReadLocally()
|
MessageCacheStore.markPublicMessagesReadLocally()
|
||||||
|
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun archiveDmConversation(otherUserId: Int) =
|
suspend fun archiveDmConversation(otherUserId: Int) =
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import ru.fromchat.api.schema.user.profile.UserProfile
|
|||||||
import ru.fromchat.api.schema.user.profile.VerificationStatus
|
import ru.fromchat.api.schema.user.profile.VerificationStatus
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when a profile entry should not expose its `username` field
|
* Returns true when a profile entry should not expose its `username` field
|
||||||
@@ -56,18 +58,34 @@ object ProfileCache {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var loadedInstanceId: String = ""
|
private var loadedInstanceId: String = ""
|
||||||
|
|
||||||
|
/** Epoch millis when a full (non-preview) profile was last applied from the server. */
|
||||||
|
@Volatile
|
||||||
|
private var fullProfileFetchedAtMs: Map<Int, Long> = emptyMap()
|
||||||
|
|
||||||
private val _revision = MutableStateFlow(0)
|
private val _revision = MutableStateFlow(0)
|
||||||
val revision: StateFlow<Int> = _revision.asStateFlow()
|
val revision: StateFlow<Int> = _revision.asStateFlow()
|
||||||
|
|
||||||
private val persistMutex = Mutex()
|
private val persistMutex = Mutex()
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
/** Skip force=false network refetch when a full profile was fetched within this window. */
|
||||||
|
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
|
||||||
|
|
||||||
private fun bumpRevision() {
|
private fun bumpRevision() {
|
||||||
_revision.value++
|
_revision.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(userId: Int): UserProfile? = profiles[userId]
|
fun get(userId: Int): UserProfile? = profiles[userId]
|
||||||
|
|
||||||
|
/** True when a full non-preview profile was fetched recently enough to skip refetch. */
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
|
||||||
|
val profile = get(userId) ?: return false
|
||||||
|
if (profile.isClientPreviewOnly) return false
|
||||||
|
val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false
|
||||||
|
return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs
|
||||||
|
}
|
||||||
|
|
||||||
/** Emits whenever this user's cached profile changes (including bio). */
|
/** Emits whenever this user's cached profile changes (including bio). */
|
||||||
fun observeUser(userId: Int): Flow<UserProfile?> =
|
fun observeUser(userId: Int): Flow<UserProfile?> =
|
||||||
revision.map { get(userId) }.distinctUntilChanged()
|
revision.map { get(userId) }.distinctUntilChanged()
|
||||||
@@ -173,12 +191,20 @@ object ProfileCache {
|
|||||||
* When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP
|
* When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP
|
||||||
* response cannot overwrite a fresher WebSocket update.
|
* response cannot overwrite a fresher WebSocket update.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
|
fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
|
||||||
if (profile.id <= 0) return
|
if (profile.id <= 0) return
|
||||||
val normalized = profile.copy(isClientPreviewOnly = false)
|
val normalized = profile.copy(isClientPreviewOnly = false)
|
||||||
|
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||||
if (!force) {
|
if (!force) {
|
||||||
val existing = get(profile.id)
|
val existing = get(profile.id)
|
||||||
if (existing != null && !existing.isClientPreviewOnly) {
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
|
val patched = existing.copy(
|
||||||
|
verified = normalized.verified ?: existing.verified,
|
||||||
|
verificationStatus = normalized.verificationStatus
|
||||||
|
?: existing.verificationStatus,
|
||||||
|
)
|
||||||
|
if (patched != existing) put(patched)
|
||||||
if (existing.bio != normalized.bio) {
|
if (existing.bio != normalized.bio) {
|
||||||
ru.fromchat.Logger.d(
|
ru.fromchat.Logger.d(
|
||||||
"ProfileCache",
|
"ProfileCache",
|
||||||
@@ -186,6 +212,8 @@ object ProfileCache {
|
|||||||
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
|
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Refresh TTL so force=false callers stop refetching.
|
||||||
|
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +223,7 @@ object ProfileCache {
|
|||||||
"bio='${normalized.bio?.take(48)}'",
|
"bio='${normalized.bio?.take(48)}'",
|
||||||
)
|
)
|
||||||
put(normalized)
|
put(normalized)
|
||||||
|
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(userId: Int) {
|
fun remove(userId: Int) {
|
||||||
@@ -297,7 +326,14 @@ object ProfileCache {
|
|||||||
val uid = message.user_id
|
val uid = message.user_id
|
||||||
if (uid <= 0) return
|
if (uid <= 0) return
|
||||||
val existing = get(uid)
|
val existing = get(uid)
|
||||||
if (existing != null && !existing.isClientPreviewOnly) return
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
|
val patched = existing.copy(
|
||||||
|
verified = message.verified ?: existing.verified,
|
||||||
|
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
|
||||||
|
)
|
||||||
|
if (patched != existing) put(patched)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
||||||
if (uname.isBlank()) return
|
if (uname.isBlank()) return
|
||||||
@@ -375,6 +411,7 @@ object ProfileCache {
|
|||||||
} else {
|
} else {
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
|
fullProfileFetchedAtMs = emptyMap()
|
||||||
pruneUnusableClientPreviewsLocked()
|
pruneUnusableClientPreviewsLocked()
|
||||||
bumpRevision()
|
bumpRevision()
|
||||||
}
|
}
|
||||||
@@ -428,6 +465,7 @@ object ProfileCache {
|
|||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
persistMutex.withLock {
|
persistMutex.withLock {
|
||||||
profiles = emptyMap()
|
profiles = emptyMap()
|
||||||
|
fullProfileFetchedAtMs = emptyMap()
|
||||||
loadedInstanceId = ""
|
loadedInstanceId = ""
|
||||||
bumpRevision()
|
bumpRevision()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import ru.fromchat.api.schema.messages.Message
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom.
|
* Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom.
|
||||||
* Avoids optimistic rows jumping mid-thread when client clock lags the server.
|
* Pending rows sort by enqueue sequence (monotonic negative id), not hash or wall-clock.
|
||||||
*/
|
*/
|
||||||
internal fun sortMessagesForChatDisplay(messages: List<Message>): List<Message> {
|
internal fun sortMessagesForChatDisplay(messages: List<Message>): List<Message> {
|
||||||
if (messages.size <= 1) return messages
|
if (messages.size <= 1) return messages
|
||||||
val (pending, confirmed) = messages.partition { it.id < 0 }
|
val (pending, confirmed) = messages.partition { it.id < 0 }
|
||||||
val comparator = compareBy<Message>(
|
val confirmedComparator = compareBy<Message>(
|
||||||
{ messageSortEpochMillis(it) },
|
{ messageSortEpochMillis(it) },
|
||||||
{ it.id.toLong() },
|
{ it.id.toLong() },
|
||||||
)
|
)
|
||||||
return confirmed.sortedWith(comparator) + pending.sortedWith(comparator)
|
// Monotonic ids are -1, -2, … so -id ascending = enqueue order.
|
||||||
|
val pendingComparator = compareBy<Message>(
|
||||||
|
{ -it.id.toLong() },
|
||||||
|
{ it.client_message_id.orEmpty() },
|
||||||
|
)
|
||||||
|
return confirmed.sortedWith(confirmedComparator) + pending.sortedWith(pendingComparator)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun messageSortEpochMillis(message: Message): Long =
|
internal fun messageSortEpochMillis(message: Message): Long =
|
||||||
|
|||||||
+3
-3
@@ -20,8 +20,8 @@ fun nowMessageTimestampIso(): String = Clock.System.now().toString()
|
|||||||
* Parse message timestamps from server or client.
|
* Parse message timestamps from server or client.
|
||||||
*
|
*
|
||||||
* - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants.
|
* - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants.
|
||||||
* - Zone-less ISO from the API is naive server wall time (`datetime.now().isoformat()`),
|
* - Zone-less ISO from the API is naive UTC wall time (`datetime.now().isoformat()`),
|
||||||
* interpreted in the device zone so HH:mm matches the user's clock.
|
* interpreted as UTC so local formatting shows the user's clock.
|
||||||
*/
|
*/
|
||||||
internal fun parseMessageInstant(timestamp: String): Instant? {
|
internal fun parseMessageInstant(timestamp: String): Instant? {
|
||||||
val raw = timestamp.trim()
|
val raw = timestamp.trim()
|
||||||
@@ -32,7 +32,7 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
|
|||||||
}
|
}
|
||||||
val local = parseLocalDateTimeOrNull(normalized) ?: return null
|
val local = parseLocalDateTimeOrNull(normalized) ?: return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
local.toInstant(TimeZone.currentSystemDefault())
|
local.toInstant(TimeZone.UTC)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-5
@@ -1,13 +1,42 @@
|
|||||||
package ru.fromchat.api.local.messages
|
package ru.fromchat.api.local.messages
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/** Stable negative row id for an optimistic / outbox [clientMessageId]. */
|
private val optimisticIdMutex = Mutex()
|
||||||
|
private var nextOptimisticSeq = 0
|
||||||
|
private val idByClientMessageId = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
private inline fun <T> withOptimisticIdLock(block: () -> T): T {
|
||||||
|
while (!optimisticIdMutex.tryLock()) {
|
||||||
|
// spin — allocation is rare and short
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
optimisticIdMutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negative row id for an optimistic / outbox [clientMessageId].
|
||||||
|
* Monotonic enqueue order (-1, -2, …); stable for the same client id within process.
|
||||||
|
*/
|
||||||
fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int {
|
fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int {
|
||||||
val hc = clientMessageId.hashCode()
|
val key = clientMessageId.trim()
|
||||||
val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else abs(hc)
|
if (key.isEmpty()) return nextOptimisticMessageId()
|
||||||
return -(if (absHc == 0) 1 else absHc)
|
return withOptimisticIdLock {
|
||||||
|
idByClientMessageId.getOrPut(key) {
|
||||||
|
nextOptimisticSeq += 1
|
||||||
|
-nextOptimisticSeq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh monotonic negative id when no client message id is available yet. */
|
||||||
|
fun nextOptimisticMessageId(): Int = withOptimisticIdLock {
|
||||||
|
nextOptimisticSeq += 1
|
||||||
|
-nextOptimisticSeq
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Message.isQueuedOutbound(): Boolean = id < 0
|
fun Message.isQueuedOutbound(): Boolean = id < 0
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package ru.fromchat.api.local.messages
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.schema.messages.Message
|
||||||
|
import ru.fromchat.api.schema.websocket.types.MessageDeletedData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global public-chat inbox: persists add/edit/delete into [MessageRepository] even when
|
||||||
|
* no [ru.fromchat.ui.chat.panels.publicchat.PublicChatPanel] is open.
|
||||||
|
*/
|
||||||
|
object PublicInboxCoordinator {
|
||||||
|
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
|
val message = decodeMessage(element) ?: return@withContext
|
||||||
|
ProfileCache.mergePreviewFromPublicMessage(message)
|
||||||
|
val clientId = message.client_message_id?.trim().orEmpty()
|
||||||
|
val currentUserId = ApiClient.user?.id
|
||||||
|
if (currentUserId != null && message.user_id == currentUserId) {
|
||||||
|
if (clientId.isNotEmpty()) {
|
||||||
|
MessageRepository.confirmPublicMessage(clientId, message)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
// Server omitted client_message_id — still clear a matching pending row if unique.
|
||||||
|
val pending = MessageRepository.loadPublicMessages()
|
||||||
|
.filter { it.user_id == currentUserId && it.id < 0 }
|
||||||
|
val matchCid = when {
|
||||||
|
pending.size == 1 -> pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
else -> {
|
||||||
|
val content = message.content.trim()
|
||||||
|
pending.singleOrNull {
|
||||||
|
it.content.trim() == content ||
|
||||||
|
(!it.files.isNullOrEmpty() && !message.files.isNullOrEmpty())
|
||||||
|
}?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchCid != null) {
|
||||||
|
MessageRepository.confirmPublicMessage(
|
||||||
|
matchCid,
|
||||||
|
message.copy(client_message_id = matchCid),
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageRepository.upsertPublicMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
|
val edited = decodeMessage(element) ?: return@withContext
|
||||||
|
ProfileCache.mergePreviewFromPublicMessage(edited)
|
||||||
|
val existing = MessageRepository.loadPublicMessages()
|
||||||
|
val merged = existing.map { current ->
|
||||||
|
if (current.id == edited.id) {
|
||||||
|
edited.copy(reply_to = edited.reply_to ?: current.reply_to)
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (merged.none { it.id == edited.id }) {
|
||||||
|
MessageRepository.upsertPublicMessage(edited)
|
||||||
|
} else {
|
||||||
|
MessageRepository.replacePublicMessages(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
|
val deleted = runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
|
||||||
|
}.getOrNull() ?: return@withContext
|
||||||
|
MessageRepository.deletePublicMessageById(deleted.message_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeMessage(element: JsonElement): Message? =
|
||||||
|
runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.fromchat.api.local.messages
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
|
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a single updates batch in order (add/edit/delete) before the cursor advances.
|
||||||
|
*/
|
||||||
|
object UpdatesBatchApplier {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val publicTypes = setOf("newMessage", "messageEdited", "messageDeleted")
|
||||||
|
private val dmTypes = setOf("dmNew", "dmDeleted", "dmEdited")
|
||||||
|
|
||||||
|
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
|
||||||
|
val envelope = runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
||||||
|
}.getOrNull() ?: return@withLock null
|
||||||
|
|
||||||
|
for (update in envelope.updates) {
|
||||||
|
applyOne(WebSocketMessage(type = update.type, data = update.data))
|
||||||
|
}
|
||||||
|
envelope.seq
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun applyOne(message: WebSocketMessage) {
|
||||||
|
when (message.type) {
|
||||||
|
"updates" -> {
|
||||||
|
val data = message.data ?: return
|
||||||
|
applyEnvelope(data)
|
||||||
|
}
|
||||||
|
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
|
||||||
|
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
|
||||||
|
"messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) }
|
||||||
|
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCacheAffecting(type: String): Boolean =
|
||||||
|
type in publicTypes || type in dmTypes
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.fromchat.api.schema.websocket.requests
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AckUpdatesRequest(
|
||||||
|
@SerialName("lastSeq") val lastSeq: Int,
|
||||||
|
)
|
||||||
+2
-1
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
|
|||||||
data class GetUpdatesResponse(
|
data class GetUpdatesResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
@SerialName("lastSeq") val lastSeq: Int,
|
@SerialName("lastSeq") val lastSeq: Int,
|
||||||
@SerialName("missedCount") val missedCount: Int
|
@SerialName("missedCount") val missedCount: Int,
|
||||||
|
@SerialName("hasMore") val hasMore: Boolean = false,
|
||||||
)
|
)
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
/** Platform bridge so shared mark-read can dismiss message notifications. */
|
||||||
|
expect object ChatNotificationDismissals {
|
||||||
|
fun dismissAllMessageNotifications()
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import ru.fromchat.api.ApiClient
|
|||||||
import ru.fromchat.api.local.AttachmentMediaLog
|
import ru.fromchat.api.local.AttachmentMediaLog
|
||||||
import ru.fromchat.api.local.messages.generateClientMessageId
|
import ru.fromchat.api.local.messages.generateClientMessageId
|
||||||
import ru.fromchat.api.local.messages.nowMessageTimestampIso
|
import ru.fromchat.api.local.messages.nowMessageTimestampIso
|
||||||
|
import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId
|
||||||
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
||||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
@@ -611,7 +612,7 @@ abstract class ChatPanel(
|
|||||||
|
|
||||||
val tempId = generateClientMessageId()
|
val tempId = generateClientMessageId()
|
||||||
val newOptimistic = message.copy(
|
val newOptimistic = message.copy(
|
||||||
id = uniqueOptimisticMessageId(),
|
id = optimisticMessageIdForClientMessageId(tempId),
|
||||||
client_message_id = tempId
|
client_message_id = tempId
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -698,7 +699,7 @@ abstract class ChatPanel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
|
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
|
||||||
val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId())
|
val optimistic = tempMessage.copy(id = optimisticMessageIdForClientMessageId(tempId))
|
||||||
addMessage(optimistic)
|
addMessage(optimistic)
|
||||||
|
|
||||||
Logger.d(
|
Logger.d(
|
||||||
@@ -743,14 +744,6 @@ abstract class ChatPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun uniqueOptimisticMessageId(): Int = addMessageMutex.withLock {
|
|
||||||
var id: Int
|
|
||||||
do {
|
|
||||||
id = -kotlin.random.Random.nextInt(1, Int.MAX_VALUE)
|
|
||||||
} while (_state.messages.any { it.id == id })
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist optimistic row for offline / process death; no-op by default. */
|
/** Persist optimistic row for offline / process death; no-op by default. */
|
||||||
protected open suspend fun persistOptimisticMessage(message: Message) {}
|
protected open suspend fun persistOptimisticMessage(message: Message) {}
|
||||||
|
|
||||||
|
|||||||
@@ -315,10 +315,19 @@ fun ChatScreen(
|
|||||||
visitEnterSyncComplete = true
|
visitEnterSyncComplete = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// First non-empty load for this visit: never animate existing history.
|
// First non-empty load for this visit: seed history without animating.
|
||||||
|
// Exception: empty → single live/own message should animate (first bubble in chat).
|
||||||
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
|
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
|
||||||
seedWithoutAnimating("seed_first_load")
|
val isLiveFirstMessage =
|
||||||
return@LaunchedEffect
|
previousCount == 0 &&
|
||||||
|
messages.size == 1 &&
|
||||||
|
sizeDelta == 1
|
||||||
|
if (!isLiveFirstMessage) {
|
||||||
|
seedWithoutAnimating("seed_first_load")
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
enterAnimationsSeeded = true
|
||||||
|
visitEnterSyncComplete = true
|
||||||
}
|
}
|
||||||
|
|
||||||
previousNewestFingerprint = fingerprint
|
previousNewestFingerprint = fingerprint
|
||||||
@@ -413,6 +422,7 @@ fun ChatScreen(
|
|||||||
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
|
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val profileUserId = panelState.profileUserId
|
val profileUserId = panelState.profileUserId
|
||||||
@@ -701,6 +711,7 @@ fun ChatScreen(
|
|||||||
msg.id == menuMessage.id
|
msg.id == menuMessage.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (liveMessage == null) {
|
if (liveMessage == null) {
|
||||||
contextMenuState = contextMenuState.copy(isOpen = false, message = null)
|
contextMenuState = contextMenuState.copy(isOpen = false, message = null)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
@@ -1376,7 +1387,9 @@ fun ChatScreen(
|
|||||||
|
|
||||||
ChatTopBar(
|
ChatTopBar(
|
||||||
hazeState = hazeState,
|
hazeState = hazeState,
|
||||||
onBack = { navController.navigateUp() },
|
onBack = {
|
||||||
|
runNav { navController.navigateUp() }
|
||||||
|
},
|
||||||
backContentDescription = stringResource(Res.string.back),
|
backContentDescription = stringResource(Res.string.back),
|
||||||
showCallButton = panel.showCallButton() && !isReadOnly,
|
showCallButton = panel.showCallButton() && !isReadOnly,
|
||||||
onCallClick = {
|
onCallClick = {
|
||||||
@@ -1391,7 +1404,9 @@ fun ChatScreen(
|
|||||||
title = panelState.title,
|
title = panelState.title,
|
||||||
titleAvatar = panelState.titleAvatar,
|
titleAvatar = panelState.titleAvatar,
|
||||||
profileUserId = profileUserId,
|
profileUserId = profileUserId,
|
||||||
onTitleClick = onTitleClick,
|
onTitleClick = onTitleClick?.let { click ->
|
||||||
|
{ runNav(click) }
|
||||||
|
},
|
||||||
hideTitleBarAvatar = hideTitleBarAvatar,
|
hideTitleBarAvatar = hideTitleBarAvatar,
|
||||||
onAvatarSlotBounds = onAvatarSlotBounds,
|
onAvatarSlotBounds = onAvatarSlotBounds,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
|||||||
@@ -32,8 +32,14 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
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.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.navigation.NavController
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
@@ -68,6 +74,7 @@ import ru.fromchat.ui.profile.StatusBadge
|
|||||||
import ru.fromchat.ui.profile.peerIsDeleted
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
import ru.fromchat.ui.profile.resolveVerificationStatus
|
import ru.fromchat.ui.profile.resolveVerificationStatus
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
import com.pr0gramm3r101.utils.scaleOnPress
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
@@ -258,8 +265,12 @@ fun ChatTopBarInner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
key == "typing" -> {
|
key == "typing" -> {
|
||||||
|
val me = ApiClient.user?.id
|
||||||
TypingIndicator(
|
TypingIndicator(
|
||||||
typingUsers = currentTypingUsers.map { it.username },
|
typingUsers = currentTypingUsers.map { user ->
|
||||||
|
ProfileCache.get(user.userId)?.visibleDisplayName(me)
|
||||||
|
?: user.username
|
||||||
|
},
|
||||||
showUsernames = typingShowsUsernames,
|
showUsernames = typingShowsUsernames,
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
@@ -447,3 +458,28 @@ fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-flight gate for chat title/avatar navigate vs back during shared-element transitions.
|
||||||
|
* Ignores taps while a transition is running or the current destination is not resumed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberChatNavigationGate(
|
||||||
|
navController: NavController,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
|
): (() -> Unit) -> Unit {
|
||||||
|
var locked by remember { mutableStateOf(false) }
|
||||||
|
val entry = navController.currentBackStackEntry
|
||||||
|
LaunchedEffect(entry) {
|
||||||
|
locked = false
|
||||||
|
}
|
||||||
|
return { action ->
|
||||||
|
val transitionRunning = animatedVisibilityScope?.transition?.isRunning == true
|
||||||
|
val lifecycleOk = navController.currentBackStackEntry?.lifecycle?.currentState
|
||||||
|
?.let { it == Lifecycle.State.RESUMED } != false
|
||||||
|
if (!locked && !transitionRunning && lifecycleOk) {
|
||||||
|
locked = true
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.offset
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -715,7 +714,7 @@ fun ImageFullscreenPreview(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(WindowInsets.systemBars),
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = effectiveMenusVisible,
|
visible = effectiveMenusVisible,
|
||||||
@@ -796,20 +795,24 @@ fun ImageFullscreenPreview(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
if (currentUserId != null &&
|
||||||
text = {
|
(message.user_id == currentUserId || currentUserId == 1)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
) {
|
||||||
Icon(Icons.Rounded.Delete, null, tint = Color.White)
|
DropdownMenuItem(
|
||||||
Spacer(Modifier.width(8.dp))
|
text = {
|
||||||
Text(labelDelete, color = Color.White)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
}
|
Icon(Icons.Rounded.Delete, null, tint = Color.White)
|
||||||
},
|
Spacer(Modifier.width(8.dp))
|
||||||
onClick = {
|
Text(labelDelete, color = Color.White)
|
||||||
menuExpanded = false
|
}
|
||||||
onDelete(message)
|
},
|
||||||
dismissRequested = true
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onDelete(message)
|
||||||
|
dismissRequested = true
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -821,7 +824,7 @@ fun ImageFullscreenPreview(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(WindowInsets.systemBars),
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = effectiveMenusVisible && message.content.isNotBlank(),
|
visible = effectiveMenusVisible && message.content.isNotBlank(),
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ fun MessageItem(
|
|||||||
isMessageCorrupted(message)
|
isMessageCorrupted(message)
|
||||||
}
|
}
|
||||||
val primaryIsImageMessage = remember(
|
val primaryIsImageMessage = remember(
|
||||||
message.reply_to,
|
|
||||||
message.pendingFileUri,
|
message.pendingFileUri,
|
||||||
message.pendingFilename,
|
message.pendingFilename,
|
||||||
message.files,
|
message.files,
|
||||||
@@ -219,10 +218,8 @@ fun MessageItem(
|
|||||||
)
|
)
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
message.reply_to == null && (
|
pendingIsImage ||
|
||||||
pendingIsImage ||
|
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
||||||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
val formattedTime = remember(message.timestamp) {
|
val formattedTime = remember(message.timestamp) {
|
||||||
formatMessageTimeLocal(message.timestamp)
|
formatMessageTimeLocal(message.timestamp)
|
||||||
@@ -569,10 +566,8 @@ fun MessageItem(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val primaryIsImageContent = message.reply_to == null && (
|
val primaryIsImageContent = pendingIsImage ||
|
||||||
pendingIsImage ||
|
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
||||||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
|
||||||
)
|
|
||||||
val hasImageCaption =
|
val hasImageCaption =
|
||||||
message.content.isNotBlank() &&
|
message.content.isNotBlank() &&
|
||||||
!isFilenameOnlyMessageCaption(message)
|
!isFilenameOnlyMessageCaption(message)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ru.fromchat.api.local.cache.CacheContext
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.ui.chat.rememberChatNavigationGate
|
||||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
import ru.fromchat.utils.haptic.rememberHapticFeedback
|
import ru.fromchat.utils.haptic.rememberHapticFeedback
|
||||||
@@ -46,6 +47,7 @@ fun DmChatRoute(
|
|||||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
||||||
|
|
||||||
DmScreen(
|
DmScreen(
|
||||||
@@ -54,8 +56,10 @@ fun DmChatRoute(
|
|||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
scrollToMessageId = scrollToMessageId,
|
scrollToMessageId = scrollToMessageId,
|
||||||
onTitleClick = {
|
onTitleClick = {
|
||||||
haptic(HapticFeedbackEvent.ProfileOpened)
|
runNav {
|
||||||
navController.navigate(DmNav.profileRoute(otherUserId))
|
haptic(HapticFeedbackEvent.ProfileOpened)
|
||||||
|
navController.navigate(DmNav.profileRoute(otherUserId))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
@@ -74,6 +78,7 @@ fun DmProfileRoute(
|
|||||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
||||||
val stateSnapshot = panel.getState()
|
val stateSnapshot = panel.getState()
|
||||||
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
||||||
@@ -92,12 +97,16 @@ fun DmProfileRoute(
|
|||||||
userId = otherUserId,
|
userId = otherUserId,
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
onBack = {
|
onBack = {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
runNav {
|
||||||
navController.popBackStack()
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onChat = {
|
onChat = {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
runNav {
|
||||||
navController.popBackStack()
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
|||||||
+15
-6
@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||||
|
import ru.fromchat.ui.chat.rememberChatNavigationGate
|
||||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||||
import ru.fromchat.ui.profile.PublicChatProfileScreen
|
import ru.fromchat.ui.profile.PublicChatProfileScreen
|
||||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||||
@@ -31,6 +32,7 @@ fun PublicChatChatRoute(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
|
|
||||||
PublicChatScreen(
|
PublicChatScreen(
|
||||||
scrollToMessageId = scrollToMessageId,
|
scrollToMessageId = scrollToMessageId,
|
||||||
@@ -38,8 +40,10 @@ fun PublicChatChatRoute(
|
|||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
|
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
|
||||||
onTitleClick = {
|
onTitleClick = {
|
||||||
haptic(HapticFeedbackEvent.ProfileOpened)
|
runNav {
|
||||||
navController.navigate(PublicChatNav.PROFILE_ROUTE)
|
haptic(HapticFeedbackEvent.ProfileOpened)
|
||||||
|
navController.navigate(PublicChatNav.PROFILE_ROUTE)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -57,6 +61,7 @@ fun PublicChatProfileRoute(
|
|||||||
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
|
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
|
||||||
}
|
}
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
val stateSnapshot = panel.getState()
|
val stateSnapshot = panel.getState()
|
||||||
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: stateSnapshot.title.takeIf { it.isNotBlank() }
|
?: stateSnapshot.title.takeIf { it.isNotBlank() }
|
||||||
@@ -65,12 +70,16 @@ fun PublicChatProfileRoute(
|
|||||||
PublicChatProfileScreen(
|
PublicChatProfileScreen(
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
onBack = {
|
onBack = {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
runNav {
|
||||||
navController.popBackStack()
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onChat = {
|
onChat = {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
runNav {
|
||||||
navController.popBackStack()
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
|||||||
+45
@@ -236,6 +236,7 @@ class PublicChatPanel(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
|
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
|
||||||
|
* When the server omits client_message_id, fall back to a single pending own optimistic.
|
||||||
*/
|
*/
|
||||||
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
|
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
|
||||||
val laidOut = newMsg.resolvePublicAttachmentLayout()
|
val laidOut = newMsg.resolvePublicAttachmentLayout()
|
||||||
@@ -247,12 +248,56 @@ class PublicChatPanel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
|
if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
|
||||||
|
// Already confirmed in UI — still try to clear a leftover optimistic.
|
||||||
|
matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
|
||||||
|
handleMessageConfirmed(
|
||||||
|
pendingCid,
|
||||||
|
laidOut.copy(client_message_id = pendingCid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
|
||||||
|
handleMessageConfirmed(
|
||||||
|
pendingCid,
|
||||||
|
laidOut.copy(client_message_id = pendingCid),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ingestIncomingPublicMessage(laidOut)
|
ingestIncomingPublicMessage(laidOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best-effort: unique pending own optimistic that looks like [confirmed]. */
|
||||||
|
private fun matchPendingOptimisticClientId(confirmed: Message): String? {
|
||||||
|
val pending = snapshotPendingOptimisticMessages().filter {
|
||||||
|
it.user_id == confirmed.user_id && it.id < 0
|
||||||
|
}
|
||||||
|
if (pending.isEmpty()) return null
|
||||||
|
val confirmedCid = confirmed.client_message_id?.trim().orEmpty()
|
||||||
|
if (confirmedCid.isNotEmpty()) {
|
||||||
|
pending.firstOrNull { it.client_message_id?.trim() == confirmedCid }
|
||||||
|
?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { return it }
|
||||||
|
}
|
||||||
|
if (pending.size == 1) {
|
||||||
|
return pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
val confirmedTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(confirmed.timestamp)
|
||||||
|
val content = confirmed.content.trim()
|
||||||
|
val matches = pending.filter { opt ->
|
||||||
|
val optCid = opt.client_message_id?.trim().orEmpty()
|
||||||
|
if (optCid.isEmpty()) return@filter false
|
||||||
|
if (opt.content.trim() != content && confirmed.files.isNullOrEmpty() && opt.files.isNullOrEmpty()) {
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
val optTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(opt.timestamp)
|
||||||
|
confirmedTime == null || optTime == null ||
|
||||||
|
kotlin.math.abs(confirmedTime - optTime) <= 180_000L
|
||||||
|
}
|
||||||
|
return matches.singleOrNull()?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
|
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
|
||||||
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
||||||
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
|
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ internal fun messageDedupeKey(msg: Message): String {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops optimistic rows already represented by a confirmed message (same client id),
|
* Drops optimistic rows already represented by a confirmed message (same client id),
|
||||||
* or legacy near-duplicate own rows that have no client id.
|
* or near-duplicate own rows when the confirmed ack omitted client_message_id.
|
||||||
*
|
*
|
||||||
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept —
|
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept
|
||||||
* time-based heuristics must not remove them (that aborted enter animations mid-spring).
|
* unless a confirmed own row without client id is a clear near-duplicate (1:1 pairing).
|
||||||
*/
|
*/
|
||||||
internal fun dropSupersededOptimisticMessages(
|
internal fun dropSupersededOptimisticMessages(
|
||||||
messages: List<Message>,
|
messages: List<Message>,
|
||||||
@@ -48,17 +48,27 @@ internal fun dropSupersededOptimisticMessages(
|
|||||||
val confirmed = messages.filter { it.id > 0 }
|
val confirmed = messages.filter { it.id > 0 }
|
||||||
val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
|
val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
|
||||||
val self = currentUserId
|
val self = currentUserId
|
||||||
|
val usedConfirmedIds = mutableSetOf<Int>()
|
||||||
return messages.filter { msg ->
|
return messages.filter { msg ->
|
||||||
if (msg.id >= 0) return@filter true
|
if (msg.id >= 0) return@filter true
|
||||||
val cid = msg.client_message_id?.trim().orEmpty()
|
val cid = msg.client_message_id?.trim().orEmpty()
|
||||||
if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false
|
if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false
|
||||||
// Stable client id still in flight — never drop via time heuristics.
|
|
||||||
if (cid.isNotEmpty()) return@filter true
|
|
||||||
// In-flight uploads (file or image): keep until a confirmed row shares the same client id.
|
|
||||||
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
|
|
||||||
if (self == null || msg.user_id != self) return@filter true
|
if (self == null || msg.user_id != self) return@filter true
|
||||||
|
|
||||||
|
// Match confirmed acks that omitted client_message_id (phantom duplicate).
|
||||||
|
if (cid.isNotEmpty()) {
|
||||||
|
val matched = findNearOwnConfirmedWithoutClientId(msg, confirmed, self, usedConfirmedIds)
|
||||||
|
if (matched != null) {
|
||||||
|
usedConfirmedIds.add(matched.id)
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
return@filter true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: no client id on pending — time-based near-dup.
|
||||||
|
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
|
||||||
val msgTime = parseMessageTimestampMillis(msg.timestamp)
|
val msgTime = parseMessageTimestampMillis(msg.timestamp)
|
||||||
val nearOwnConfirmed = confirmed.filter { it.user_id == self }.any { confirmedMsg ->
|
val nearOwnConfirmed = confirmed.filter { it.user_id == self && it.id !in usedConfirmedIds }.any { confirmedMsg ->
|
||||||
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp)
|
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp)
|
||||||
msgTime != null && confirmedTime != null &&
|
msgTime != null && confirmedTime != null &&
|
||||||
abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS
|
abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS
|
||||||
@@ -77,6 +87,34 @@ internal fun dropSupersededOptimisticMessages(
|
|||||||
|
|
||||||
private const val NEAR_DUPLICATE_MS = 180_000L
|
private const val NEAR_DUPLICATE_MS = 180_000L
|
||||||
|
|
||||||
|
private fun findNearOwnConfirmedWithoutClientId(
|
||||||
|
pending: Message,
|
||||||
|
confirmed: List<Message>,
|
||||||
|
self: Int,
|
||||||
|
usedConfirmedIds: Set<Int>,
|
||||||
|
): Message? {
|
||||||
|
val msgTime = parseMessageTimestampMillis(pending.timestamp) ?: return null
|
||||||
|
val pendingIsAttachment = !pending.files.isNullOrEmpty() ||
|
||||||
|
pending.pendingFileUri != null ||
|
||||||
|
!pending.uploadJobId.isNullOrBlank()
|
||||||
|
val candidates = confirmed.filter { confirmedMsg ->
|
||||||
|
if (confirmedMsg.user_id != self) return@filter false
|
||||||
|
if (confirmedMsg.id in usedConfirmedIds) return@filter false
|
||||||
|
if (!confirmedMsg.client_message_id.isNullOrBlank()) return@filter false
|
||||||
|
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp) ?: return@filter false
|
||||||
|
if (abs(msgTime - confirmedTime) > NEAR_DUPLICATE_MS) return@filter false
|
||||||
|
val confirmedHasAttachment = !confirmedMsg.files.isNullOrEmpty()
|
||||||
|
val contentMatches = pending.content.trim() == confirmedMsg.content.trim()
|
||||||
|
when {
|
||||||
|
pendingIsAttachment && confirmedHasAttachment -> true
|
||||||
|
!pendingIsAttachment && !confirmedHasAttachment && contentMatches -> true
|
||||||
|
!pendingIsAttachment && confirmedHasAttachment && contentMatches -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates.minByOrNull { abs((parseMessageTimestampMillis(it.timestamp) ?: 0L) - msgTime) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun preferMessageForDedupe(existing: Message, incoming: Message): Message {
|
private fun preferMessageForDedupe(existing: Message, incoming: Message): Message {
|
||||||
val preferred = when {
|
val preferred = when {
|
||||||
incoming.id > 0 && existing.id < 0 -> incoming
|
incoming.id > 0 && existing.id < 0 -> incoming
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ internal fun attachPublicReplyReferences(
|
|||||||
return messages.map { msg ->
|
return messages.map { msg ->
|
||||||
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
|
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
|
||||||
val nested = msg.reply_to
|
val nested = msg.reply_to
|
||||||
if (nested != null && nested.content.isNotBlank()) return@map msg
|
if (
|
||||||
|
nested != null &&
|
||||||
|
(nested.content.isNotBlank() || !nested.files.isNullOrEmpty())
|
||||||
|
) {
|
||||||
|
return@map msg
|
||||||
|
}
|
||||||
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
|
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.imePadding
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@@ -247,6 +248,7 @@ fun ChatsSearchScreen(
|
|||||||
placeholder = searchBarHint,
|
placeholder = searchBarHint,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
|
|||||||
@@ -654,27 +654,11 @@ fun ChatsTab(
|
|||||||
|
|
||||||
fun deleteChats(userIds: Set<Int>) {
|
fun deleteChats(userIds: Set<Int>) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
var failures = 0
|
|
||||||
|
|
||||||
userIds.forEach { otherUserId ->
|
userIds.forEach { otherUserId ->
|
||||||
var ok = true
|
|
||||||
|
|
||||||
val messages = runCatching { MessageRepository.loadDmMessages(otherUserId) }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
.filter { it.id > 0 }
|
|
||||||
|
|
||||||
messages.forEach { msg ->
|
|
||||||
runCatching {
|
|
||||||
ApiClient.deleteDm(msg.id, otherUserId)
|
|
||||||
}.onFailure {
|
|
||||||
ok = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runCatching {
|
runCatching {
|
||||||
MessageRepository.deleteDmConversation(otherUserId)
|
ApiClient.archiveDmConversation(otherUserId, archived = true)
|
||||||
}.onFailure { ok = false }
|
MessageRepository.archiveDmConversation(otherUserId)
|
||||||
|
}
|
||||||
if (!ok) failures++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshDmList()
|
refreshDmList()
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val hadContent = devices.isNotEmpty()
|
val hadContent = devices.isNotEmpty()
|
||||||
if (hadContent) refreshing = true
|
if (!hadContent) refreshing = true
|
||||||
|
|
||||||
runCatching { ApiClient.listDevices() }
|
runCatching { ApiClient.listDevices() }
|
||||||
.onSuccess { list ->
|
.onSuccess { list ->
|
||||||
@@ -570,11 +570,8 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
AnimatedVisibility(
|
// Initial load only — background poll must not add/remove list height (overscroll jump).
|
||||||
visible = refreshing,
|
if (refreshing && devices.isEmpty()) {
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
+8
-4
@@ -18,13 +18,17 @@ fun resolveVerificationStatus(
|
|||||||
message: Message? = null,
|
message: Message? = null,
|
||||||
user: User? = null,
|
user: User? = null,
|
||||||
): VerificationStatus? {
|
): VerificationStatus? {
|
||||||
ProfileCache.get(userId)?.effectiveVerificationStatus()?.let { cached ->
|
ProfileCache.get(userId)?.let { cached ->
|
||||||
if (cached != VerificationStatus.None || ProfileCache.get(userId)?.verificationStatus != null) {
|
if (cached.verificationStatus != null || cached.verified != null) {
|
||||||
return cached
|
return cached.effectiveVerificationStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user?.effectiveVerificationStatus()?.let { return it }
|
user?.let { u ->
|
||||||
|
if (u.verificationStatus != null || u.verified != null) {
|
||||||
|
return u.effectiveVerificationStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message?.verificationStatus?.let { return it }
|
message?.verificationStatus?.let { return it }
|
||||||
message?.verified?.let { return if (it) VerificationStatus.Verified else null }
|
message?.verified?.let { return if (it) VerificationStatus.Verified else null }
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
actual object ChatNotificationDismissals {
|
||||||
|
actual fun dismissAllMessageNotifications() = Unit
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user