Fix lots of bugs

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-19 19:47:37 +03:00
Unverified
parent cb250bb91f
commit 5730bb527f
34 changed files with 706 additions and 190 deletions
+3
View File
@@ -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()
@@ -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",
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.i( Logger.i(
"UpdateSyncManager", "UpdateSyncManager",
"Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}" "Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
"missed=${response.missedCount}, hasMore=${response.hasMore}",
) )
onUpdatesBatch(parsed.lastSeq) updateMissedCount(response.missedCount)
updateMissedCount(parsed.missedCount)
}.onFailure { when (response.status) {
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it) "tooLong" -> {
} val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
} else { } else {
Logger.d("UpdateSyncManager", "No data returned from getUpdates; treating as no-op") Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
}
break
}
"ok" -> {
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
}
if (!response.hasMore) break
}
else -> {
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
break
}
}
} }
} 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(
@@ -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 =
@@ -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()
} }
@@ -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
@@ -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()
}
@@ -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
}
@@ -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,
)
@@ -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,
) )
@@ -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,11 +315,20 @@ 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()) {
val isLiveFirstMessage =
previousCount == 0 &&
messages.size == 1 &&
sizeDelta == 1
if (!isLiveFirstMessage) {
seedWithoutAnimating("seed_first_load") seedWithoutAnimating("seed_first_load")
return@LaunchedEffect return@LaunchedEffect
} }
enterAnimationsSeeded = true
visitEnterSyncComplete = true
}
previousNewestFingerprint = fingerprint previousNewestFingerprint = fingerprint
previousEnterMessageCount = messages.size previousEnterMessageCount = messages.size
@@ -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,6 +795,9 @@ fun ImageFullscreenPreview(
}, },
) )
} }
if (currentUserId != null &&
(message.user_id == currentUserId || currentUserId == 1)
) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -808,20 +810,21 @@ fun ImageFullscreenPreview(
menuExpanded = false menuExpanded = false
onDelete(message) onDelete(message)
dismissRequested = true dismissRequested = true
} },
) )
} }
} }
} }
} }
} }
}
// Bottom: message text // Bottom: message text
Box( Box(
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 = {
runNav {
haptic(HapticFeedbackEvent.ProfileOpened) haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(DmNav.profileRoute(otherUserId)) 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 = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
}
}, },
onChat = { onChat = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
}
}, },
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
@@ -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 = {
runNav {
haptic(HapticFeedbackEvent.ProfileOpened) haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(PublicChatNav.PROFILE_ROUTE) 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 = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
}
}, },
onChat = { onChat = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
}
}, },
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
@@ -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,28 +654,12 @@ 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 { runCatching {
ApiClient.deleteDm(msg.id, otherUserId) ApiClient.archiveDmConversation(otherUserId, archived = true)
}.onFailure { MessageRepository.archiveDmConversation(otherUserId)
ok = false
} }
} }
runCatching {
MessageRepository.deleteDmConversation(otherUserId)
}.onFailure { ok = false }
if (!ok) failures++
}
refreshDmList() refreshDmList()
exitSelectionMode() exitSelectionMode()
@@ -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()
@@ -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 }
@@ -0,0 +1,5 @@
package ru.fromchat.notifications
actual object ChatNotificationDismissals {
actual fun dismissAllMessageNotifications() = Unit
}