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:name=".App"
android:networkSecurityConfig="@xml/network_security_config">
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/logo" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -37,6 +37,7 @@ class App: Application() {
override fun onCreate() {
super.onCreate()
UtilsLibrary.init(this)
ru.fromchat.notifications.ChatNotificationDismissals.install(this)
WebSocketManager.addGlobalMessageHandler { msg ->
GlobalScope.launch(Dispatchers.IO) {
@@ -403,7 +403,7 @@ object NotificationHelper {
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo_big)
.setSmallIcon(R.drawable.logo)
.setContentTitle(title)
.setContentText(body)
.setStyle(
@@ -506,7 +506,7 @@ object NotificationHelper {
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo_big)
.setSmallIcon(R.drawable.logo)
.setStyle(
NotificationCompat.MessagingStyle(
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.messages.MarkReadRequest
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.DmConversationsResponse
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
@@ -558,12 +559,18 @@ object ApiClient {
}
.body()
suspend fun getProfileById(userId: Int): UserProfile =
http
suspend fun getProfileById(userId: Int, force: Boolean = false): UserProfile {
if (!force) {
ProfileCache.get(userId)?.takeIf {
!it.isClientPreviewOnly && ProfileCache.hasFreshFullProfile(userId)
}?.let { return it }
}
return http
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
contentType(ContentType.Application.Json)
}
.body()
}
suspend fun getProfileByUsername(username: String): UserProfile =
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> {
val trimmed = query.trim()
if (trimmed.length < 2) return emptyList()
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview
import kotlinx.serialization.json.JsonElement
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
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.MessageRepository
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.schema.messages.Message
import ru.fromchat.api.schema.websocket.WebSocketMessage
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
* message for list previews (without opening each chat first).
* Keeps the chats tab list in sync: DM conversations from the server and public-chat
* previews (cache is filled by the updates pipeline / history rebuild).
*/
@OptIn(FlowPreview::class)
object ChatListSync {
@@ -58,7 +57,14 @@ object ChatListSync {
suspend fun syncFromNetwork() {
if (!canSync()) return
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 {
@@ -78,12 +84,13 @@ object ChatListSync {
}
}
private suspend fun syncPublicChatPreview() {
private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching {
val response = ApiClient.getMessages(limit = 1)
val latest = response.messages.maxByOrNull { message ->
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching
// Upsert only — does not wipe older cached messages.
MessageRepository.upsertPublicMessage(latest)
}
}
@@ -100,16 +107,14 @@ object ChatListSync {
}
}
"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
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import 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.api.local.WebSocketManager
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.WebSocketMessage
import ru.fromchat.api.schema.websocket.requests.AckUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
import kotlin.concurrent.Volatile
/**
* Tracks the last seen WebSocket update sequence for the current user and
* persists it between sessions so we can ask the backend for missed updates.
* Per-device update cursor: advance + ack only after batches are applied successfully.
*/
object UpdateSyncManager {
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)
val lastSeq: StateFlow<Int> = _lastSeq.asStateFlow()
@@ -30,6 +42,8 @@ object UpdateSyncManager {
private val _lastMissedCount = MutableStateFlow<Int?>(null)
val lastMissedCount: StateFlow<Int?> = _lastMissedCount.asStateFlow()
private val applyMutex = Mutex()
@Volatile
private var gapDetectionInProgress: Boolean = false
@@ -42,22 +56,15 @@ object UpdateSyncManager {
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
}
@OptIn(DelicateCoroutinesApi::class)
fun onUpdatesBatch(seq: Int) {
if (seq <= 0) return
val currentUserId = ApiClient.user?.id ?: return
val newSeq = seq.coerceAtLeast(_lastSeq.value)
if (newSeq == _lastSeq.value) return
_lastSeq.value = newSeq
ConnectionStateStore.updateSeqAndMissed(lastSeq = newSeq, missedCount = _lastMissedCount.value)
val key = KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId
GlobalScope.launch(Dispatchers.Default) {
runCatching {
settings.putInt(key, newSeq)
}.onFailure {
Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$newSeq for userId=$currentUserId: ${it.message}", it)
/**
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
*/
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
applyMutex.withLock {
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
if (seq > _lastSeq.value) {
persistLastSeq(seq)
sendAck(seq)
}
}
}
@@ -81,9 +88,8 @@ object UpdateSyncManager {
}
/**
* Ask the backend for missed updates between our lastSeq and the current sequence.
* This call is idempotent while in progress and will no-op if there is no active
* WebSocket session or no authenticated user.
* Catch up from [lastSeq]: chunked getUpdates, or tooLong history rebuild.
* Does not advance the cursor until apply/rebuild succeeds.
*/
suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) {
@@ -98,52 +104,158 @@ object UpdateSyncManager {
}
gapDetectionInProgress = true
val startSeq = _lastSeq.value
ConnectionStateStore.onUpdating(start = true)
try {
Logger.i("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
if (startSeq > 0) {
ConnectionStateStore.onUpdating(start = true)
}
var rounds = 0
while (rounds < 100) {
rounds++
val startSeq = _lastSeq.value
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
val requestMessage = WebSocketMessage(
type = "getUpdates",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
),
data = ApiClient.json.encodeToJsonElement(
GetUpdatesRequest.serializer(),
GetUpdatesRequest(lastSeq = startSeq)
val response = requestGetUpdates(token, startSeq) ?: break
Logger.i(
"UpdateSyncManager",
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
"missed=${response.missedCount}, hasMore=${response.hasMore}",
)
)
updateMissedCount(response.missedCount)
val response = WebSocketManager.request(requestMessage)
val data = response?.data
if (data != null) {
runCatching {
val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
Logger.i(
"UpdateSyncManager",
"Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}"
)
onUpdatesBatch(parsed.lastSeq)
updateMissedCount(parsed.missedCount)
}.onFailure {
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
when (response.status) {
"tooLong" -> {
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
} else {
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
}
}
} else {
Logger.d("UpdateSyncManager", "No data returned from getUpdates; treating as no-op")
}
} catch (t: Throwable) {
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
} finally {
if (startSeq > 0) {
ConnectionStateStore.onUpdating(start = false)
}
ConnectionStateStore.onUpdating(start = 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.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig
import kotlin.concurrent.Volatile
import kotlin.time.Clock
@@ -234,12 +233,11 @@ object WebSocketManager {
val msg = when (messageType) {
"updates" -> {
// Apply add/edit/delete before advancing; ack happens inside.
runCatching {
val updatesData = json.decodeFromJsonElement(
WebSocketUpdatesData.serializer(), jsonTree)
UpdateSyncManager.onUpdatesBatch(updatesData.seq)
UpdateSyncManager.onUpdatesEnvelope(jsonTree)
}.onFailure {
logW("Failed to decode updates envelope for seq tracking: ${it.message}", it)
logW("Failed to apply updates envelope: ${it.message}", it)
}
WebSocketMessage(
@@ -539,7 +539,7 @@ object MessageCacheStore {
.filter { it.type == "dm" }
localDm.forEach { row ->
val otherId = row.otherUserId?.toInt() ?: return@forEach
if (otherId !in serverOtherUserIds) {
if (otherId !in serverOtherUserIds && row.archived == 0L) {
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
}
}
@@ -690,6 +690,7 @@ object MessageCacheStore {
id = convId,
)
}
DmConversationListNotifier.notifyChanged()
}
suspend fun deleteDmConversation(otherUserId: Int) {
@@ -117,6 +117,7 @@ object MessageRepository {
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
}
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
@@ -137,6 +138,7 @@ object MessageRepository {
runCatching { ApiClient.markMessagesRead(ids) }
}
MessageCacheStore.markPublicMessagesReadLocally()
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
}
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.local.cache.CacheContext
import kotlin.concurrent.Volatile
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
/**
* Returns true when a profile entry should not expose its `username` field
@@ -56,18 +58,34 @@ object ProfileCache {
@Volatile
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)
val revision: StateFlow<Int> = _revision.asStateFlow()
private val persistMutex = Mutex()
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() {
_revision.value++
}
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). */
fun observeUser(userId: Int): Flow<UserProfile?> =
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
* response cannot overwrite a fresher WebSocket update.
*/
@OptIn(ExperimentalTime::class)
fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
if (profile.id <= 0) return
val normalized = profile.copy(isClientPreviewOnly = false)
val nowMs = Clock.System.now().toEpochMilliseconds()
if (!force) {
val existing = get(profile.id)
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) {
ru.fromchat.Logger.d(
"ProfileCache",
@@ -186,6 +212,8 @@ object ProfileCache {
"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
}
}
@@ -195,6 +223,7 @@ object ProfileCache {
"bio='${normalized.bio?.take(48)}'",
)
put(normalized)
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
}
fun remove(userId: Int) {
@@ -297,7 +326,14 @@ object ProfileCache {
val uid = message.user_id
if (uid <= 0) return
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() }
if (uname.isBlank()) return
@@ -375,6 +411,7 @@ object ProfileCache {
} else {
emptyMap()
}
fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked()
bumpRevision()
}
@@ -428,8 +465,9 @@ object ProfileCache {
suspend fun clear() {
persistMutex.withLock {
profiles = emptyMap()
fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = ""
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.
* 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> {
if (messages.size <= 1) return messages
val (pending, confirmed) = messages.partition { it.id < 0 }
val comparator = compareBy<Message>(
val confirmedComparator = compareBy<Message>(
{ messageSortEpochMillis(it) },
{ 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 =
@@ -20,8 +20,8 @@ fun nowMessageTimestampIso(): String = Clock.System.now().toString()
* Parse message timestamps from server or client.
*
* - 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()`),
* interpreted in the device zone so HH:mm matches the user's clock.
* - Zone-less ISO from the API is naive UTC wall time (`datetime.now().isoformat()`),
* interpreted as UTC so local formatting shows the user's clock.
*/
internal fun parseMessageInstant(timestamp: String): Instant? {
val raw = timestamp.trim()
@@ -32,7 +32,7 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
}
val local = parseLocalDateTimeOrNull(normalized) ?: return null
return runCatching {
local.toInstant(TimeZone.currentSystemDefault())
local.toInstant(TimeZone.UTC)
}.getOrNull()
}
@@ -1,13 +1,42 @@
package ru.fromchat.api.local.messages
import kotlinx.coroutines.sync.Mutex
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 {
val hc = clientMessageId.hashCode()
val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else abs(hc)
return -(if (absHc == 0) 1 else absHc)
val key = clientMessageId.trim()
if (key.isEmpty()) return nextOptimisticMessageId()
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
@@ -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(
val status: String,
@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.messages.generateClientMessageId
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.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message
@@ -611,7 +612,7 @@ abstract class ChatPanel(
val tempId = generateClientMessageId()
val newOptimistic = message.copy(
id = uniqueOptimisticMessageId(),
id = optimisticMessageIdForClientMessageId(tempId),
client_message_id = tempId
)
@@ -698,7 +699,7 @@ abstract class ChatPanel(
)
// 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)
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. */
protected open suspend fun persistOptimisticMessage(message: Message) {}
@@ -315,10 +315,19 @@ fun ChatScreen(
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()) {
seedWithoutAnimating("seed_first_load")
return@LaunchedEffect
val isLiveFirstMessage =
previousCount == 0 &&
messages.size == 1 &&
sizeDelta == 1
if (!isLiveFirstMessage) {
seedWithoutAnimating("seed_first_load")
return@LaunchedEffect
}
enterAnimationsSeeded = true
visitEnterSyncComplete = true
}
previousNewestFingerprint = fingerprint
@@ -413,6 +422,7 @@ fun ChatScreen(
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
val haptic = rememberHapticFeedback()
val navController = LocalNavController.current
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId
@@ -701,6 +711,7 @@ fun ChatScreen(
msg.id == menuMessage.id
}
}
if (liveMessage == null) {
contextMenuState = contextMenuState.copy(isOpen = false, message = null)
return@LaunchedEffect
@@ -1376,7 +1387,9 @@ fun ChatScreen(
ChatTopBar(
hazeState = hazeState,
onBack = { navController.navigateUp() },
onBack = {
runNav { navController.navigateUp() }
},
backContentDescription = stringResource(Res.string.back),
showCallButton = panel.showCallButton() && !isReadOnly,
onCallClick = {
@@ -1391,7 +1404,9 @@ fun ChatScreen(
title = panelState.title,
titleAvatar = panelState.titleAvatar,
profileUserId = profileUserId,
onTitleClick = onTitleClick,
onTitleClick = onTitleClick?.let { click ->
{ runNav(click) }
},
hideTitleBarAvatar = hideTitleBarAvatar,
onAvatarSlotBounds = onAvatarSlotBounds,
sharedTransitionScope = sharedTransitionScope,
@@ -32,8 +32,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.setValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavController
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.resolveVerificationStatus
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.scaleOnPress
import kotlin.math.PI
@@ -258,8 +265,12 @@ fun ChatTopBarInner(
}
key == "typing" -> {
val me = ApiClient.user?.id
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
typingUsers = currentTypingUsers.map { user ->
ProfileCache.get(user.userId)?.visibleDisplayName(me)
?: user.username
},
showUsernames = typingShowsUsernames,
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
@@ -715,7 +714,7 @@ fun ImageFullscreenPreview(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.systemBars),
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility(
visible = effectiveMenusVisible,
@@ -796,20 +795,24 @@ fun ImageFullscreenPreview(
},
)
}
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Delete, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text(labelDelete, color = Color.White)
}
},
onClick = {
menuExpanded = false
onDelete(message)
dismissRequested = true
if (currentUserId != null &&
(message.user_id == currentUserId || currentUserId == 1)
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Delete, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text(labelDelete, color = Color.White)
}
},
onClick = {
menuExpanded = false
onDelete(message)
dismissRequested = true
},
)
}
)
}
}
}
@@ -821,7 +824,7 @@ fun ImageFullscreenPreview(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.systemBars),
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(),
@@ -204,7 +204,6 @@ fun MessageItem(
isMessageCorrupted(message)
}
val primaryIsImageMessage = remember(
message.reply_to,
message.pendingFileUri,
message.pendingFilename,
message.files,
@@ -219,10 +218,8 @@ fun MessageItem(
)
else -> false
}
message.reply_to == null && (
pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
)
pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
}
val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp)
@@ -569,10 +566,8 @@ fun MessageItem(
} else {
null
}
val primaryIsImageContent = message.reply_to == null && (
pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
)
val primaryIsImageContent = pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
val hasImageCaption =
message.content.isNotBlank() &&
!isFilenameOnlyMessageCaption(message)
@@ -12,6 +12,7 @@ import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.utils.haptic.HapticFeedbackEvent
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.utils.haptic.rememberHapticFeedback
@@ -46,6 +47,7 @@ fun DmChatRoute(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
DmScreen(
@@ -54,8 +56,10 @@ fun DmChatRoute(
modifier = modifier.fillMaxSize(),
scrollToMessageId = scrollToMessageId,
onTitleClick = {
haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(DmNav.profileRoute(otherUserId))
runNav {
haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(DmNav.profileRoute(otherUserId))
}
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
@@ -74,6 +78,7 @@ fun DmProfileRoute(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
@@ -92,12 +97,16 @@ fun DmProfileRoute(
userId = otherUserId,
showBackButton = true,
onBack = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
}
},
onChat = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
}
},
modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope,
@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.ApiClient
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.profile.PublicChatProfileScreen
import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -31,6 +32,7 @@ fun PublicChatChatRoute(
modifier: Modifier = Modifier,
) {
val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
PublicChatScreen(
scrollToMessageId = scrollToMessageId,
@@ -38,8 +40,10 @@ fun PublicChatChatRoute(
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
onTitleClick = {
haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(PublicChatNav.PROFILE_ROUTE)
runNav {
haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(PublicChatNav.PROFILE_ROUTE)
}
},
modifier = modifier.fillMaxSize(),
)
@@ -57,6 +61,7 @@ fun PublicChatProfileRoute(
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
}
val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: stateSnapshot.title.takeIf { it.isNotBlank() }
@@ -65,12 +70,16 @@ fun PublicChatProfileRoute(
PublicChatProfileScreen(
showBackButton = true,
onBack = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
}
},
onChat = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
}
},
modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope,
@@ -236,6 +236,7 @@ class PublicChatPanel(
/**
* 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) {
val laidOut = newMsg.resolvePublicAttachmentLayout()
@@ -247,12 +248,56 @@ class PublicChatPanel(
return
}
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
}
}
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) {
ProfileCache.mergePreviewFromPublicMessage(newMsg)
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),
* 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
* time-based heuristics must not remove them (that aborted enter animations mid-spring).
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept
* unless a confirmed own row without client id is a clear near-duplicate (1:1 pairing).
*/
internal fun dropSupersededOptimisticMessages(
messages: List<Message>,
@@ -48,17 +48,27 @@ internal fun dropSupersededOptimisticMessages(
val confirmed = messages.filter { it.id > 0 }
val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
val self = currentUserId
val usedConfirmedIds = mutableSetOf<Int>()
return messages.filter { msg ->
if (msg.id >= 0) return@filter true
val cid = msg.client_message_id?.trim().orEmpty()
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
// 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 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)
msgTime != null && confirmedTime != null &&
abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS
@@ -77,6 +87,34 @@ internal fun dropSupersededOptimisticMessages(
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 {
val preferred = when {
incoming.id > 0 && existing.id < 0 -> incoming
@@ -28,7 +28,12 @@ internal fun attachPublicReplyReferences(
return messages.map { msg ->
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
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
}
}
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -247,6 +248,7 @@ fun ChatsSearchScreen(
placeholder = searchBarHint,
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(12.dp),
leadingIcon = {
IconButton(onClick = {
@@ -654,27 +654,11 @@ fun ChatsTab(
fun deleteChats(userIds: Set<Int>) {
scope.launch {
var failures = 0
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 {
MessageRepository.deleteDmConversation(otherUserId)
}.onFailure { ok = false }
if (!ok) failures++
ApiClient.archiveDmConversation(otherUserId, archived = true)
MessageRepository.archiveDmConversation(otherUserId)
}
}
refreshDmList()
@@ -390,7 +390,7 @@ fun DevicesScreen(onBack: () -> Unit) {
}
val hadContent = devices.isNotEmpty()
if (hadContent) refreshing = true
if (!hadContent) refreshing = true
runCatching { ApiClient.listDevices() }
.onSuccess { list ->
@@ -570,11 +570,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
item {
AnimatedVisibility(
visible = refreshing,
enter = fadeIn(),
exit = fadeOut(),
) {
// Initial load only — background poll must not add/remove list height (overscroll jump).
if (refreshing && devices.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -18,13 +18,17 @@ fun resolveVerificationStatus(
message: Message? = null,
user: User? = null,
): VerificationStatus? {
ProfileCache.get(userId)?.effectiveVerificationStatus()?.let { cached ->
if (cached != VerificationStatus.None || ProfileCache.get(userId)?.verificationStatus != null) {
return cached
ProfileCache.get(userId)?.let { cached ->
if (cached.verificationStatus != null || cached.verified != null) {
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?.verified?.let { return if (it) VerificationStatus.Verified else null }
@@ -0,0 +1,5 @@
package ru.fromchat.notifications
actual object ChatNotificationDismissals {
actual fun dismissAllMessageNotifications() = Unit
}