3 Commits

36 changed files with 1259 additions and 417 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/logo" /> android:resource="@drawable/ic_stat_fromchat" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -27,9 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
val fallbackMessageId = pushData["message_id"]?.toIntOrNull() val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
?: pushData["dm_id"]?.toIntOrNull() ?: pushData["dm_id"]?.toIntOrNull()
val senderId = pushData["sender_id"]?.toIntOrNull() val senderId = pushData["sender_id"]?.toIntOrNull()
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"] val sender = pushData["sender_display_name"]
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat" ?.takeIf { it.isNotBlank() }
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message" ?: pushData["sender_username"]
?: pushData["senderUsername"]
?: pushData["senderDisplayName"]
val messageType = pushData["type"] ?: "public_message" val messageType = pushData["type"] ?: "public_message"
val isDirectMessage = messageType.equals("dm", ignoreCase = true) val isDirectMessage = messageType.equals("dm", ignoreCase = true)
if (ApiClient.token.isNullOrBlank()) { if (ApiClient.token.isNullOrBlank()) {
@@ -45,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId") Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
return@launch return@launch
} }
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
NotificationHelper.showFallbackPushNotification(
context = applicationContext,
title = title,
body = body,
sender = sender,
messageId = fallbackMessageId,
isDirectMessage = false,
senderId = senderId,
)
}
if (isDirectMessage) { if (isDirectMessage) {
NotificationHelper.fetchAndNotify( NotificationHelper.fetchAndNotify(
applicationContext, applicationContext,
@@ -64,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
dmSenderName = sender, dmSenderName = sender,
) )
} else { } else {
NotificationHelper.fetchAndNotify(applicationContext) // Public: one debounced /messages/new → MessagingStyle. Never post a
// per-message fallback (that duplicated FCM tray entries with different labels).
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e) Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
@@ -19,15 +19,20 @@ import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.get import io.ktor.client.request.get
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.MainActivity import ru.fromchat.MainActivity
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.R import ru.fromchat.R
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.buildChatListPreview import ru.fromchat.api.local.messages.buildChatListPreview
@@ -53,13 +58,20 @@ object NotificationHelper {
private const val CHAT_TYPE_PUBLIC = "public" private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm" private const val CHAT_TYPE_DM = "dm"
private const val CHANNEL_ID = "fromchat_messages" private const val CHANNEL_ID = "fromchat_messages"
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
private const val SUMMARY_NOTIFICATION_ID = 1000000
private const val PREF_SHOWN_KEY = "shown_message_ids" private const val PREF_SHOWN_KEY = "shown_message_ids"
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids" private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id" private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time" private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val publicFetchMutex = Mutex()
private var publicFetchJob: Job? = null
private fun listPreviewStrings(context: Context): ChatListPreviewStrings { private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
val emoji = context.getString(R.string.chat_preview_image_emoji) val emoji = context.getString(R.string.chat_preview_image_emoji)
return ChatListPreviewStrings( return ChatListPreviewStrings(
@@ -72,6 +84,24 @@ object NotificationHelper {
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String = private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
private fun publicConversationTitle(context: Context): String =
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
?: runCatching {
PublicChatProfileCache.hydrateFromDiskImmediate(
CacheContext.activeInstanceId.value.trim()
)?.title?.takeIf { it.isNotBlank() }
}.getOrNull()
?: context.getString(R.string.public_chat)
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
ProfileCache.get(message.user_id)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return message.username.trim().ifBlank { "FromChat" }
}
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
private fun createMessageIntent( private fun createMessageIntent(
@@ -123,7 +153,6 @@ object NotificationHelper {
}, },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
) )
fun createChannel(context: Context) { fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -141,6 +170,17 @@ object NotificationHelper {
} }
} }
/** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */
fun schedulePublicFetchAndNotify(context: Context) {
publicFetchJob?.cancel()
publicFetchJob = helperScope.launch {
delay(PUBLIC_FETCH_DEBOUNCE_MS)
publicFetchMutex.withLock {
fetchAndNotify(context.applicationContext, includeDmMessages = false)
}
}
}
suspend fun fetchAndNotify( suspend fun fetchAndNotify(
context: Context, context: Context,
includeDmMessages: Boolean = false, includeDmMessages: Boolean = false,
@@ -168,10 +208,7 @@ object NotificationHelper {
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)") Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
CoroutineScope(Dispatchers.Main).launch { displayNotifications(context, messages)
createChannel(context)
displayNotifications(context, messages)
}
} else { } else {
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned") Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
} }
@@ -194,13 +231,15 @@ object NotificationHelper {
"fetchAndNotify retry: fetched ${retryMessages.size} public messages" "fetchAndNotify retry: fetched ${retryMessages.size} public messages"
) )
if (retryMessages.isNotEmpty()) { if (retryMessages.isNotEmpty()) {
CoroutineScope(Dispatchers.Main).launch { displayNotifications(context, retryMessages)
createChannel(context)
displayNotifications(context, retryMessages)
}
} }
if (includeDmMessages) { if (includeDmMessages) {
fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName) fetchAndNotifyDirectMessages(
context,
settings.getInt("current_user_id", -1),
dmMessageId,
dmSenderName
)
} }
return return
} catch (_: Exception) { } catch (_: Exception) {
@@ -305,10 +344,11 @@ object NotificationHelper {
val senderName = when { val senderName = when {
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
!envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername !envelope.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
else -> ProfileCache.get(envelope.senderId) else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId) ?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: envelope.senderUsername
}.orEmpty() }.orEmpty()
val dmConversationUserId = envelope.senderId val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope( val notificationBody = buildChatListPreviewFromEnvelope(
@@ -320,9 +360,9 @@ object NotificationHelper {
showFallbackPushNotification( showFallbackPushNotification(
context = context, context = context,
title = if (senderName.isNotBlank()) { title = if (senderName.isNotBlank()) {
"Direct message from $senderName" context.getString(R.string.notification_direct_message_from, senderName)
} else { } else {
"Direct message" context.getString(R.string.notification_direct_message)
}, },
body = notificationBody, body = notificationBody,
sender = senderName, sender = senderName,
@@ -330,7 +370,7 @@ object NotificationHelper {
allowWhenPublicChatVisible = true, allowWhenPublicChatVisible = true,
isDirectMessage = true, isDirectMessage = true,
targetDmUserId = dmConversationUserId, targetDmUserId = dmConversationUserId,
conversationTitle = "Direct Messages" conversationTitle = context.getString(R.string.notification_direct_messages_title)
) )
shownDm.add(shownDmKey) shownDm.add(shownDmKey)
} }
@@ -351,10 +391,10 @@ object NotificationHelper {
allowWhenPublicChatVisible: Boolean = false, allowWhenPublicChatVisible: Boolean = false,
isDirectMessage: Boolean = false, isDirectMessage: Boolean = false,
targetDmUserId: Int? = null, targetDmUserId: Int? = null,
conversationTitle: String = "Public Chat", conversationTitle: String = context.getString(R.string.public_chat),
senderId: Int? = null, senderId: Int? = null,
) { ) {
CoroutineScope(Dispatchers.Main).launch { helperScope.launch(Dispatchers.Main) {
createChannel(context) createChannel(context)
val currentUserId = settings.getInt("current_user_id", -1) val currentUserId = settings.getInt("current_user_id", -1)
@@ -400,22 +440,37 @@ object NotificationHelper {
} }
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat" val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
val groupKey = if (isDirectMessage && targetDmUserId != null) {
GROUP_DM_PREFIX + targetDmUserId
} else {
GROUP_PUBLIC
}
val notificationId = if (isDirectMessage && targetDmUserId != null) {
SUMMARY_NOTIFICATION_ID + targetDmUserId
} else {
SUMMARY_NOTIFICATION_ID
}
cancelStaleSystemTrayDuplicates(context)
notify( notify(
SUMMARY_NOTIFICATION_ID, notificationId,
NotificationCompat.Builder(context, CHANNEL_ID) NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo) .setSmallIcon(NotificationSmallIcon.resId(context))
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
.setGroup(groupKey)
.setStyle( .setStyle(
NotificationCompat.MessagingStyle( NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build() Person.Builder().setName("FromChat").build()
).setConversationTitle(conversationTitle).addMessage(
NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
Person.Builder().setName(senderName).build()
)
) )
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
.addMessage(
NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
Person.Builder().setName(senderName).build()
)
)
) )
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE) .setCategory(Notification.CATEGORY_MESSAGE)
@@ -423,7 +478,7 @@ object NotificationHelper {
.addAction( .addAction(
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send, android.R.drawable.ic_menu_send,
"Reply", context.getString(R.string.notification_reply),
createReplyIntent( createReplyIntent(
context = context, context = context,
isDirectMessage = isDirectMessage, isDirectMessage = isDirectMessage,
@@ -433,7 +488,7 @@ object NotificationHelper {
) )
.addRemoteInput( .addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY) RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...") .setLabel(context.getString(R.string.notification_reply_hint))
.build() .build()
) )
.setAllowGeneratedReplies(true) .setAllowGeneratedReplies(true)
@@ -457,117 +512,146 @@ object NotificationHelper {
} }
} }
} }
@OptIn(DelicateCoroutinesApi::class)
private fun displayNotifications(context: Context, messages: List<Message>) { private fun displayNotifications(context: Context, messages: List<Message>) {
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages") Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
// Don't show notifications if user is currently viewing the public chat
if (isPublicChatVisible) { if (isPublicChatVisible) {
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat") Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return return
} }
GlobalScope.launch { helperScope.launch(Dispatchers.Main.immediate) {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet() val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0 var newMessageCount = 0
val previewStrings = listPreviewStrings(context) val previewStrings = listPreviewStrings(context)
val conversationTitle = publicConversationTitle(context)
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
with(NotificationManagerCompat.from(context)) { with(NotificationManagerCompat.from(context)) {
if ( if (
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
context, context,
Manifest.permission.POST_NOTIFICATIONS Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED ) != PackageManager.PERMISSION_GRANTED
) { ) {
// Find new messages that are not from the current user Logger.w(
val currentUserId = settings.getInt("current_user_id", -1) "NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
return@launch
}
if (currentUserId == -1) return@launch val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId == -1) return@launch
val newMessages = messages.filter { msg -> val newMessages = messages
!shown.contains(msg.id.toString()) && // Not already shown .filter { msg ->
msg.user_id != currentUserId // Not from current user !shown.contains(msg.id.toString()) && msg.user_id != currentUserId
} }
if (newMessages.isEmpty()) { .sortedBy { it.id }
Logger.d( if (newMessages.isEmpty()) {
"NotificationHelper",
"displayNotifications: no new messages after filters for user=$currentUserId"
)
return@launch
}
newMessages.apply { forEach { shown.add(it.id.toString()) } }
newMessageCount = newMessages.size
Logger.d( Logger.d(
"NotificationHelper", "NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}" "displayNotifications: no new messages after filters for user=$currentUserId"
) )
return@launch
}
newMessages.forEach { shown.add(it.id.toString()) }
notify( newMessageCount = newMessages.size
SUMMARY_NOTIFICATION_ID, Logger.d(
NotificationCompat.Builder(context, CHANNEL_ID) "NotificationHelper",
.setSmallIcon(R.drawable.logo) "displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
.setStyle( "newMessages=$newMessageCount conversationTitle=$conversationTitle"
NotificationCompat.MessagingStyle( )
Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let { style ->
for (msg in newMessages.takeLast(10)) {
val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
style.addMessage( createChannel(context)
NotificationCompat.MessagingStyle.Message( cancelStaleSystemTrayDuplicates(context)
notificationBodyForMessage(msg, previewStrings),
timestamp,
Person.Builder()
.setName(msg.username)
.build()
)
)
}
style val messagingStyle = NotificationCompat.MessagingStyle(
} Person.Builder().setName("FromChat").build()
) )
.setPriority(NotificationCompat.PRIORITY_HIGH) .setConversationTitle(conversationTitle)
.setCategory(Notification.CATEGORY_MESSAGE) .setGroupConversation(true)
.setAutoCancel(true)
for (msg in newMessages.takeLast(10)) {
val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
notificationBodyForMessage(msg, previewStrings),
timestamp,
Person.Builder()
.setName(senderDisplayLabel(msg, currentUserId))
.setKey(msg.user_id.toString())
.build()
)
)
}
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setLargeIcon(avatar)
.setContentTitle(conversationTitle)
.setStyle(messagingStyle)
.setGroup(GROUP_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.addAction( .addAction(
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send, android.R.drawable.ic_menu_send,
"Reply", context.getString(R.string.notification_reply),
createReplyIntent( createReplyIntent(
context = context, context = context,
isDirectMessage = false, isDirectMessage = false,
parentMessageId = newMessages.last().id parentMessageId = newMessages.last().id
) )
) )
.addRemoteInput( .addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY) RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...") .setLabel(context.getString(R.string.notification_reply_hint))
.build() .build()
) )
.setAllowGeneratedReplies(true) .setAllowGeneratedReplies(true)
.build() .build()
) )
.setContentIntent(createMessageIntent(context, newMessages.last().id)) .setContentIntent(createMessageIntent(context, newMessages.last().id))
.build() .setShortcutId(GROUP_PUBLIC)
) .build()
} else { )
Logger.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
}
} }
settings.putStringSet(PREF_SHOWN_KEY, shown) settings.putStringSet(PREF_SHOWN_KEY, shown)
Logger.i("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}") Logger.i(
"NotificationHelper",
"displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}"
)
}
}
/** Clears FCM auto-posted tray entries (notification payload) that duplicate our MessagingStyle. */
private fun cancelStaleSystemTrayDuplicates(context: Context) {
runCatching {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Legacy FCM auto notifications used id 0 / fcm_fallback_notification_channel.
manager.cancel(0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
manager.activeNotifications
.filter { status ->
status.notification.channelId == "fcm_fallback_notification_channel" ||
status.id == 0
}
.forEach { status ->
manager.cancel(status.tag, status.id)
}
}
} }
} }
} }
@@ -0,0 +1,78 @@
package ru.fromchat.notifications
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.Typeface
import kotlin.math.abs
/**
* Builds a circular initials avatar matching the in-app public-chat row (title initials +
* name-hash gradient), for use as a notification large icon.
*/
internal object PublicChatNotificationAvatar {
private const val SIZE_PX = 192
fun create(title: String): Bitmap {
val seed = title.ifBlank { "FromChat" }
val hash = seed.hashCode()
val r = abs(hash % 256)
val g = abs((hash / 256) % 256)
val b = abs((hash / 65536) % 256)
val colorStart = android.graphics.Color.rgb(
(r + 100).coerceIn(0, 255),
(g + 100).coerceIn(0, 255),
(b + 100).coerceIn(0, 255),
)
val colorEnd = android.graphics.Color.rgb(
(r + 50).coerceIn(0, 255),
(g + 50).coerceIn(0, 255),
(b + 50).coerceIn(0, 255),
)
val bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f,
0f,
SIZE_PX.toFloat(),
SIZE_PX.toFloat(),
colorStart,
colorEnd,
Shader.TileMode.CLAMP,
)
}
val radius = SIZE_PX / 2f
canvas.drawCircle(radius, radius, radius, paint)
val initials = initialsFrom(seed)
if (initials.isNotBlank()) {
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.WHITE
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textSize = radius * 0.7f
}
val textY = radius - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, radius, textY, textPaint)
}
return bitmap
}
private fun initialsFrom(displayName: String): String {
val words = displayName.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }
return when {
words.isEmpty() -> ""
words.size == 1 -> {
val word = words[0]
if (word.length >= 2) word.take(2).uppercase() else (word + word).take(2).uppercase()
}
else -> words.take(2).joinToString("") {
it.firstOrNull()?.uppercaseChar()?.toString().orEmpty()
}
}
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z" />
</vector>
@@ -1,5 +1,11 @@
<resources> <resources>
<string name="public_chat">Общий чат</string>
<string name="chat_preview_attachment">Вложение</string> <string name="chat_preview_attachment">Вложение</string>
<string name="chat_preview_image_emoji">📷</string> <string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 фото</string> <string name="chat_preview_image">%1$s 1 фото</string>
<string name="notification_reply">Ответить</string>
<string name="notification_reply_hint">Ответ в чат…</string>
<string name="notification_direct_message">Личное сообщение</string>
<string name="notification_direct_message_from">Личное сообщение от %1$s</string>
<string name="notification_direct_messages_title">Личные сообщения</string>
</resources> </resources>
@@ -1,6 +1,12 @@
<resources> <resources>
<string name="app_name" translatable="false">FromChat</string> <string name="app_name" translatable="false">FromChat</string>
<string name="public_chat">Main chat</string>
<string name="chat_preview_attachment">Attachment</string> <string name="chat_preview_attachment">Attachment</string>
<string name="chat_preview_image_emoji">📷</string> <string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 photo</string> <string name="chat_preview_image">%1$s 1 photo</string>
<string name="notification_reply">Reply</string>
<string name="notification_reply_hint">Reply to chat…</string>
<string name="notification_direct_message">Direct message</string>
<string name="notification_direct_message_from">Direct message from %1$s</string>
<string name="notification_direct_messages_title">Direct Messages</string>
</resources> </resources>
@@ -0,0 +1,14 @@
package ru.fromchat.notifications
import android.content.Context
/** White silhouette drawable for status-bar / notification small icons. */
object NotificationSmallIcon {
private const val DRAWABLE_NAME = "ic_stat_fromchat"
fun resId(context: Context): Int {
val id = context.resources.getIdentifier(DRAWABLE_NAME, "drawable", context.packageName)
check(id != 0) { "Missing drawable/$DRAWABLE_NAME in application resources" }
return id
}
}
@@ -1,6 +1,5 @@
package ru.fromchat.ui.calls package ru.fromchat.ui.calls
import android.R
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -16,6 +15,7 @@ import androidx.core.app.Person
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.notifications.NotificationSmallIcon
/** /**
* Foreground call session: keeps camera / mic eligible in background. * Foreground call session: keeps camera / mic eligible in background.
@@ -60,12 +60,6 @@ class CallForegroundService : Service() {
ensureActiveCallChannel(nm, channelLabel) ensureActiveCallChannel(nm, channelLabel)
val smallIcon = try {
packageManager.getApplicationInfo(packageName, 0).icon
} catch (_: Exception) {
R.drawable.sym_call_outgoing
}
val hangUpPi = PendingIntent.getService( val hangUpPi = PendingIntent.getService(
this, this,
RC_HANG_UP, RC_HANG_UP,
@@ -93,7 +87,7 @@ class CallForegroundService : Service() {
.build() .build()
val builder = NotificationCompat.Builder(this, CHANNEL_ID) val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(smallIcon) .setSmallIcon(NotificationSmallIcon.resId(this))
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_CALL) .setCategory(Notification.CATEGORY_CALL)
@@ -127,6 +127,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession import ru.fromchat.api.calls.LiveKitConnectSession
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.notifications.NotificationSmallIcon
import ru.fromchat.call_status_connecting import ru.fromchat.call_status_connecting
import ru.fromchat.call_status_reconnecting import ru.fromchat.call_status_reconnecting
import ru.fromchat.call_status_reconnecting_with_detail import ru.fromchat.call_status_reconnecting_with_detail
@@ -1293,15 +1294,10 @@ private fun CallInlineControlBar(
) )
nm.createNotificationChannel(ch) nm.createNotificationChannel(ch)
} }
val smallIcon = try {
context.packageManager.getApplicationInfo(context.packageName, 0).icon
} catch (_: Exception) {
R.drawable.stat_sys_upload
}
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID) return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
.setContentTitle(updatedTitle) .setContentTitle(updatedTitle)
.setContentText(updatedText) .setContentText(updatedText)
.setSmallIcon(smallIcon) .setSmallIcon(NotificationSmallIcon.resId(context))
.setOngoing(true) .setOngoing(true)
.build() .build()
} }
@@ -112,10 +112,8 @@ import ru.fromchat.api.schema.user.auth.ChangeYandexRequest
import ru.fromchat.api.schema.user.auth.ChangeYandexResponse import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
import ru.fromchat.api.schema.user.auth.CheckAuthResponse import ru.fromchat.api.schema.user.auth.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
import ru.fromchat.api.schema.user.auth.LoginRequest
import ru.fromchat.api.schema.user.auth.LoginResponse import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
import ru.fromchat.api.schema.user.auth.RegisterRequest
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
import ru.fromchat.api.schema.user.auth.YandexOAuthParams import ru.fromchat.api.schema.user.auth.YandexOAuthParams
@@ -273,7 +271,7 @@ object ApiClient {
} }
if (response.status.value == 401) { if (response.status.value == 401) {
val path = response.call.request.url.encodedPath val path = response.call.request.url.encodedPath
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register") val isCredentialCheck = path.endsWith("/auth/steps/password") || path.endsWith("/auth/steps/register/confirm")
if (!isCredentialCheck && !logoutInProgress) { if (!isCredentialCheck && !logoutInProgress) {
MainScope().launch { MainScope().launch {
runCatching { WebSocketManager.disconnect() } runCatching { WebSocketManager.disconnect() }
@@ -484,22 +482,6 @@ object ApiClient {
} }
suspend fun loginRequest(request: LoginRequest): LoginResponse =
http
.post("${ServerConfig.apiBaseUrl}/login") {
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
suspend fun registerRequest(request: RegisterRequest): LoginResponse =
http
.post("${ServerConfig.apiBaseUrl}/register") {
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
suspend fun authUsernameStep(username: String): AuthUsernameStepResponse = suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
httpProbe httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/steps/username") { .post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
@@ -8,6 +8,7 @@ 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 ru.fromchat.Logger
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
@@ -86,12 +87,54 @@ object ChatListSync {
private suspend fun refreshPublicChatPreviewFromLatest() { private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching { runCatching {
val response = ApiClient.getMessages(limit = 1) val cached = MessageRepository.loadPublicMessages()
val latest = response.messages.maxByOrNull { message -> val maxCachedId = cached.asSequence().map { it.id }.filter { it > 0 }.maxOrNull() ?: 0
val latestResponse = ApiClient.getMessages(limit = 1)
val latest = latestResponse.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) val cachedIds = cached.asSequence().map { it.id }.filter { it > 0 }.toHashSet()
val holeBelowLatest =
latest.id > 0 &&
maxCachedId > 0 &&
latest.id > maxCachedId + 1
val latestMissingWithPriorCache =
latest.id > 0 &&
latest.id !in cachedIds &&
maxCachedId > 0 &&
latest.id > maxCachedId
if (holeBelowLatest || latestMissingWithPriorCache) {
// Preview-only upsert would leave first+last holes; pull a page and merge.
Logger.i(
"ChatListSync",
"Public preview gap: latestId=${latest.id} maxCachedId=$maxCachedId " +
"cachedCount=${cached.size} — fetching page to fill",
)
val page = ApiClient.getMessages(limit = 50)
val networkMessages = page.messages
if (networkMessages.isEmpty()) {
MessageRepository.upsertPublicMessage(latest)
return@runCatching
}
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
val networkIds = networkMessages.map { it.id }.toSet()
val minNetworkId = networkMessages.minOf { it.id }
val maxNetworkId = networkMessages.maxOf { it.id }
val older = cached.filter { it.id > 0 && it.id !in networkIds && it.id < minNetworkId }
val ahead = cached.filter { it.id > 0 && it.id !in networkIds && it.id > maxNetworkId }
val merged = (networkMessages + older + ahead).distinctBy { it.id }
Logger.i(
"ChatListSync",
"Public gap fill: network=${networkMessages.size} older=${older.size} " +
"ahead=${ahead.size} merged=${merged.size} — replaceAll=true",
)
MessageRepository.replacePublicMessages(merged, replaceAll = true)
} else {
// Upsert only — does not wipe older cached messages.
MessageRepository.upsertPublicMessage(latest)
}
} }
} }
@@ -51,13 +51,26 @@ object ProfileUpdateSync {
val data = message.data ?: return val data = message.data ?: return
val updates = runCatching { val updates = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data) ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return }.getOrNull() ?: run {
Logger.w("ProfileUpdateSync", "updates batch decode failed")
return
}
val profileUpdates = updates.updates.count { it.type == "profileUpdate" }
if (profileUpdates > 0) {
Logger.d(
"ProfileUpdateSync",
"updates batch seq=${updates.seq} profileUpdateCount=$profileUpdates",
)
}
updates.updates.forEach { update -> updates.updates.forEach { update ->
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data)) handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
} }
} }
"profileUpdate" -> { "profileUpdate" -> {
val payload = message.data ?: return val payload = message.data ?: run {
Logger.w("ProfileUpdateSync", "profileUpdate missing data")
return
}
onProfileUpdatePayload(payload) onProfileUpdatePayload(payload)
} }
} }
@@ -73,9 +86,18 @@ object ProfileUpdateSync {
Logger.d( Logger.d(
"ProfileUpdateSync", "ProfileUpdateSync",
"profileUpdate id=${profile.id} username='${profile.username}' " + "profileUpdate id=${profile.id} username='${profile.username}' " +
"bio='${profile.bio?.take(48)}'", "deleted=${profile.deleted} suspended=${profile.suspended} " +
"bio='${profile.bio?.take(48)}' revisionBefore=${ProfileCache.revision.value}",
) )
val hadCached = ProfileCache.get(profile.id)
ProfileCache.applyServerProfile(profile, force = true) ProfileCache.applyServerProfile(profile, force = true)
val after = ProfileCache.get(profile.id)
Logger.d(
"ProfileUpdateSync",
"profileUpdate applied id=${profile.id} " +
"wasDeleted=${hadCached?.deleted} nowDeleted=${after?.deleted} " +
"revisionAfter=${ProfileCache.revision.value}",
)
UserStatusStore.update(profile.id, profile.online, profile.lastSeen) UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
if (ApiClient.user?.id == profile.id) { if (ApiClient.user?.id == profile.id) {
@@ -83,6 +105,19 @@ object ProfileUpdateSync {
} }
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) } runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) }
.onFailure {
Logger.w(
"ProfileUpdateSync",
"patchDmConversationPeerProfile failed id=${profile.id}: ${it.message}",
it,
)
}
.onSuccess {
Logger.d(
"ProfileUpdateSync",
"patchDmConversationPeerProfile done id=${profile.id}",
)
}
} }
private fun parseProfileUpdate(data: JsonElement): UserProfile? { private fun parseProfileUpdate(data: JsonElement): UserProfile? {
@@ -58,13 +58,24 @@ object UpdateSyncManager {
/** /**
* Apply a live or replayed updates envelope, then advance cursor and ack the server. * Apply a live or replayed updates envelope, then advance cursor and ack the server.
*
* Ack is fire-and-forget: [WebSocketManager.request] must not be awaited from the WS
* receive loop (it would deadlock the ack response cannot be read while this call blocks).
*/ */
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) { suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
applyMutex.withLock { applyMutex.withLock {
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock Logger.d("UpdateSync", "onUpdatesEnvelope begin")
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: run {
Logger.w("UpdateSync", "onUpdatesEnvelope apply returned null")
return@withLock
}
Logger.d(
"UpdateSync",
"onUpdatesEnvelope applied seq=$seq lastSeq=${_lastSeq.value}",
)
if (seq > _lastSeq.value) { if (seq > _lastSeq.value) {
persistLastSeq(seq) persistLastSeq(seq)
sendAck(seq) sendAckFireAndForget(seq)
} }
} }
} }
@@ -90,6 +101,10 @@ object UpdateSyncManager {
/** /**
* Catch up from [lastSeq]: chunked getUpdates, or tooLong history rebuild. * Catch up from [lastSeq]: chunked getUpdates, or tooLong history rebuild.
* Does not advance the cursor until apply/rebuild succeeds. * Does not advance the cursor until apply/rebuild succeeds.
*
* Loops until [GetUpdatesResponse.hasMore] is false. On repeated getUpdates failures
* (e.g. prior ack-deadlock timeouts), falls back to a full history rebuild so the UI
* is not left with first+last holes filled only by slow incremental envelopes.
*/ */
suspend fun runGapDetectionIfNeeded() { suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) { if (gapDetectionInProgress) {
@@ -108,16 +123,38 @@ object UpdateSyncManager {
try { try {
var rounds = 0 var rounds = 0
var consecutiveFailures = 0
while (rounds < 100) { while (rounds < 100) {
rounds++ rounds++
val startSeq = _lastSeq.value val startSeq = _lastSeq.value
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)") Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
val response = requestGetUpdates(token, startSeq) ?: break val response = requestGetUpdates(token, startSeq)
if (response == null) {
consecutiveFailures++
Logger.w(
"UpdateSyncManager",
"getUpdates returned null (timeout/disconnect) " +
"failures=$consecutiveFailures lastSeq=$startSeq",
)
if (consecutiveFailures >= 2) {
Logger.w(
"UpdateSyncManager",
"Gap catch-up stalled — rebuilding from history",
)
rebuildStateFromHistory()
break
}
continue
}
consecutiveFailures = 0
val gapHint = (response.lastSeq - startSeq).coerceAtLeast(response.missedCount)
Logger.i( Logger.i(
"UpdateSyncManager", "UpdateSyncManager",
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " + "Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
"missed=${response.missedCount}, hasMore=${response.hasMore}", "missed=${response.missedCount}, hasMore=${response.hasMore}, " +
"gapHint=$gapHint clientSeq=$startSeq",
) )
updateMissedCount(response.missedCount) updateMissedCount(response.missedCount)
@@ -126,18 +163,43 @@ object UpdateSyncManager {
val ok = rebuildStateFromHistory() val ok = rebuildStateFromHistory()
if (ok) { if (ok) {
persistLastSeq(response.lastSeq) persistLastSeq(response.lastSeq)
sendAck(response.lastSeq) sendAckFireAndForget(response.lastSeq)
} else { } else {
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq") Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
} }
break break
} }
"ok" -> { "ok" -> {
// Envelopes for this chunk are applied on the receive path before this
// response is delivered; advance cursor here only when there was nothing to apply.
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) { if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
persistLastSeq(response.lastSeq) persistLastSeq(response.lastSeq)
sendAck(response.lastSeq) sendAckFireAndForget(response.lastSeq)
}
if (!response.hasMore) {
Logger.i(
"UpdateSyncManager",
"Gap catch-up complete after $rounds round(s) lastSeq=${_lastSeq.value}",
)
break
}
if (_lastSeq.value <= startSeq && response.missedCount > 0) {
// Chunk was announced but cursor did not advance — avoid tight spin.
Logger.w(
"UpdateSyncManager",
"Gap chunk did not advance cursor " +
"(start=$startSeq now=${_lastSeq.value} missed=${response.missedCount})",
)
consecutiveFailures++
if (consecutiveFailures >= 2) {
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAckFireAndForget(response.lastSeq)
}
break
}
} }
if (!response.hasMore) break
} }
else -> { else -> {
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}") Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
@@ -165,7 +227,7 @@ object UpdateSyncManager {
GetUpdatesRequest(lastSeq = lastSeq), GetUpdatesRequest(lastSeq = lastSeq),
), ),
) )
val response = WebSocketManager.request(requestMessage) val response = WebSocketManager.request(requestMessage, timeoutMs = 30_000)
val data = response?.data ?: return null val data = response?.data ?: return null
return runCatching { return runCatching {
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data) ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
@@ -174,11 +236,15 @@ object UpdateSyncManager {
}.getOrNull() }.getOrNull()
} }
private suspend fun sendAck(seq: Int) { /**
* Send ack without waiting for a response. Must not use [WebSocketManager.request] from
* the receive/apply path that deadlocks the incoming frame loop for ~10s per envelope.
*/
private suspend fun sendAckFireAndForget(seq: Int) {
val token = ApiClient.token ?: return val token = ApiClient.token ?: return
if (seq <= 0) return if (seq <= 0) return
runCatching { runCatching {
WebSocketManager.request( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "ackUpdates", type = "ackUpdates",
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token), credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
@@ -188,6 +254,7 @@ object UpdateSyncManager {
), ),
), ),
) )
Logger.d("UpdateSync", "ackUpdates sent (fire-and-forget) seq=$seq")
}.onFailure { }.onFailure {
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it) Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
} }
@@ -236,18 +303,26 @@ object UpdateSyncManager {
val ordered = collected.values.sortedBy { val ordered = collected.values.sortedBy {
parseMessageTimestampMillis(it.timestamp) ?: 0L parseMessageTimestampMillis(it.timestamp) ?: 0L
} }
MessageRepository.replacePublicMessages(ordered) MessageCacheStore.clearPublicMessages()
Logger.i(
"UpdateSync",
"rebuildPublicHistory messages=${ordered.size} — replaceAll=true",
)
MessageRepository.replacePublicMessages(ordered, replaceAll = true)
} }
private suspend fun rebuildDmHistories() { private suspend fun rebuildDmHistories() {
val conversations = MessageRepository.loadCachedDmConversations() val conversations = MessageRepository.loadCachedDmConversations()
Logger.i("UpdateSync", "rebuildDmHistories conversations=${conversations.size}")
for (conversation in conversations) { for (conversation in conversations) {
val otherId = conversation.otherUserId val otherId = conversation.otherUserId
MessageCacheStore.clearDmMessages(otherId) MessageCacheStore.clearDmMessages(otherId)
var beforeId: Int? = null var beforeId: Int? = null
var pageCount = 0
repeat(MAX_HISTORY_PAGES) { repeat(MAX_HISTORY_PAGES) {
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId) val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
if (page.messages.isEmpty()) return@repeat if (page.messages.isEmpty()) return@repeat
pageCount++
for (envelope in page.messages) { for (envelope in page.messages) {
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope) val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
DmInboundMessageProcessor.processNew(element) DmInboundMessageProcessor.processNew(element)
@@ -256,6 +331,7 @@ object UpdateSyncManager {
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id beforeId = oldest.id
} }
Logger.d("UpdateSync", "rebuildDmHistories otherUserId=$otherId pages=$pageCount")
} }
} }
} }
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
import ru.fromchat.api.local.messages.ChatListPreviewState import ru.fromchat.api.local.messages.ChatListPreviewState
@@ -167,8 +168,12 @@ object MessageCacheStore {
} }
} }
suspend fun replacePublicMessages(messages: List<Message>) { suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
Logger.d(
"MessageCache",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll convId=$convId",
)
val resolved = messages.map { it.resolvePublicAttachmentLayout() } val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved) ProfileCache.mergePreviewFromPublicMessages(resolved)
val pending = loadPendingMessages(convId) val pending = loadPendingMessages(convId)
@@ -181,10 +186,11 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, merged) purgeSupersededPendingRows(iid, convId, before, merged)
} }
replaceMessages(convId, merged) replaceMessages(convId, merged, replaceAll = replaceAll)
} }
suspend fun clearPublicMessages() { suspend fun clearPublicMessages() {
Logger.d("MessageCache", "clearPublicMessages")
clearConversationMessages(conversationIdForPublic()) clearConversationMessages(conversationIdForPublic())
} }
@@ -192,11 +198,17 @@ object MessageCacheStore {
loadMessages(conversationIdForDm(otherUserId)) loadMessages(conversationIdForDm(otherUserId))
suspend fun clearDmMessages(otherUserId: Int) { suspend fun clearDmMessages(otherUserId: Int) {
Logger.d("MessageCache", "clearDmMessages otherUserId=$otherUserId")
clearConversationMessages(conversationIdForDm(otherUserId)) clearConversationMessages(conversationIdForDm(otherUserId))
} }
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) { suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
Logger.d(
"MessageCache",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} " +
"replaceAll=$replaceAll convId=$convId",
)
val pending = loadPendingMessages(convId) val pending = loadPendingMessages(convId)
val stillPending = filterStillPendingForReplace(convId, pending, messages) val stillPending = filterStillPendingForReplace(convId, pending, messages)
val before = messages + stillPending val before = messages + stillPending
@@ -208,7 +220,7 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, hydrated) purgeSupersededPendingRows(iid, convId, before, hydrated)
} }
replaceMessages(convId, hydrated) replaceMessages(convId, hydrated, replaceAll = replaceAll)
pruneEmptyConversations() pruneEmptyConversations()
} }
@@ -246,6 +258,11 @@ object MessageCacheStore {
suspend fun upsertPublicMessage(message: Message) { suspend fun upsertPublicMessage(message: Message) {
val resolved = message.resolvePublicAttachmentLayout() val resolved = message.resolvePublicAttachmentLayout()
Logger.d(
"MessageCache",
"upsertPublicMessage id=${resolved.id} userId=${resolved.user_id} " +
"clientId=${resolved.client_message_id}",
)
ProfileCache.mergePreviewFromPublicMessage(resolved) ProfileCache.mergePreviewFromPublicMessage(resolved)
upsertSingle(conversationIdForPublic(), resolved) upsertSingle(conversationIdForPublic(), resolved)
} }
@@ -279,25 +296,37 @@ object MessageCacheStore {
} }
suspend fun upsertDmMessage(otherUserId: Int, message: Message) { suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
Logger.d(
"MessageCache",
"upsertDmMessage otherUserId=$otherUserId id=${message.id} " +
"userId=${message.user_id} clientId=${message.client_message_id}",
)
ensureDmConversationRow(otherUserId) ensureDmConversationRow(otherUserId)
upsertSingle(conversationIdForDm(otherUserId), message) upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
} }
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) { suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
Logger.d("MessageCache", "deletePublicByClientId clientId=$clientMessageId")
deleteByClientMessageId(conversationIdForPublic(), clientMessageId) deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
} }
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) { suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
Logger.d(
"MessageCache",
"deleteDmByClientId otherUserId=$otherUserId clientId=$clientMessageId",
)
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId) deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
} }
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) { suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
Logger.d("MessageCache", "deleteDmById otherUserId=$otherUserId messageId=$messageId")
deleteMessageById(conversationIdForDm(otherUserId), messageId) deleteMessageById(conversationIdForDm(otherUserId), messageId)
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
} }
suspend fun deletePublicMessageById(messageId: Int) { suspend fun deletePublicMessageById(messageId: Int) {
Logger.d("MessageCache", "deletePublicById messageId=$messageId")
deleteMessageById(conversationIdForPublic(), messageId) deleteMessageById(conversationIdForPublic(), messageId)
} }
@@ -440,6 +469,10 @@ object MessageCacheStore {
suspend fun markMessageDeleted(conversationId: String, messageId: Int) { suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
val iid = instanceId() val iid = instanceId()
Logger.d(
"MessageCache",
"markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.markMessageDeleted( db.messageDatabaseQueries.markMessageDeleted(
instanceId = iid, instanceId = iid,
@@ -611,10 +644,43 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId) .selectConversationById(iid, convId)
.executeAsOneOrNull() ?: return@withContext .executeAsOneOrNull() ?: run {
val label = resolveDmConversationDisplayLabel(otherUserId, null) Logger.d(
if (label.isEmpty()) return@withContext "MessageCache",
if (label == existing.displayName) return@withContext "patchDmPeerProfile noConversation otherUserId=$otherUserId",
)
return@withContext
}
val profile = ProfileCache.get(otherUserId)
val isDeleted = profile?.deleted == true ||
profile?.username?.startsWith("#deleted") == true
val label = if (isDeleted) {
""
} else {
resolveDmConversationDisplayLabel(otherUserId, null)
}
if (!isDeleted && label.isEmpty()) {
Logger.d(
"MessageCache",
"patchDmPeerProfile skipEmptyLabel otherUserId=$otherUserId " +
"deleted=${profile?.deleted}",
)
return@withContext
}
if (label == existing.displayName) {
Logger.d(
"MessageCache",
"patchDmPeerProfile unchanged otherUserId=$otherUserId " +
"deleted=$isDeleted labelEmpty=${label.isEmpty()}",
)
return@withContext
}
Logger.d(
"MessageCache",
"patchDmPeerProfile otherUserId=$otherUserId deleted=$isDeleted " +
"oldLabelEmpty=${existing.displayName.isNullOrBlank()} " +
"newLabelEmpty=${label.isEmpty()}",
)
db.messageDatabaseQueries.upsertConversation( db.messageDatabaseQueries.upsertConversation(
instanceId = iid, instanceId = iid,
id = existing.id, id = existing.id,
@@ -947,6 +1013,7 @@ object MessageCacheStore {
private suspend fun clearConversationMessages(conversationId: String) { private suspend fun clearConversationMessages(conversationId: String) {
val iid = instanceId() val iid = instanceId()
Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
} }
@@ -954,6 +1021,10 @@ object MessageCacheStore {
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) { private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
val iid = instanceId() val iid = instanceId()
Logger.d(
"MessageCache",
"deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId) db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
} }
@@ -961,6 +1032,12 @@ object MessageCacheStore {
private suspend fun deleteMessageById(conversationId: String, messageId: Int) { private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
val iid = instanceId() val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageById( db.messageDatabaseQueries.deleteMessageById(
instanceId = iid, instanceId = iid,
@@ -968,6 +1045,17 @@ object MessageCacheStore {
id = messageId.toLong(), id = messageId.toLong(),
) )
} }
val afterCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"deleteMessageById convId=$conversationId messageId=$messageId " +
"rowsBefore=$beforeCount rowsAfter=$afterCount removed=${beforeCount - afterCount}",
)
} }
private suspend fun upsertSingle(conversationId: String, msg: Message) { private suspend fun upsertSingle(conversationId: String, msg: Message) {
@@ -1249,9 +1337,11 @@ object MessageCacheStore {
val profile = ProfileCache.get(uid) val profile = ProfileCache.get(uid)
val usernameResolved = when { val usernameResolved = when {
self != null && uid == self.id -> self.username self != null && uid == self.id -> self.username
else -> profile?.username?.takeIf { it.isNotBlank() } else -> profile?.username?.takeIf { it.isNotBlank() }.orEmpty()
?: profile?.displayName?.takeIf { it.isNotBlank() } }
?: "" val displayNameResolved = when {
self != null && uid == self.id -> self.displayName?.trim()?.takeIf { it.isNotEmpty() }
else -> profile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} }
val pictureResolved = when { val pictureResolved = when {
self != null && uid == self.id -> self.profile_picture self != null && uid == self.id -> self.profile_picture
@@ -1266,6 +1356,7 @@ object MessageCacheStore {
is_read = isRead != 0L, is_read = isRead != 0L,
is_edited = isEdited != 0L, is_edited = isEdited != 0L,
username = usernameResolved, username = usernameResolved,
displayName = displayNameResolved,
profile_picture = pictureResolved, profile_picture = pictureResolved,
verified = profile?.verified, verified = profile?.verified,
verificationStatus = profile?.verificationStatus, verificationStatus = profile?.verificationStatus,
@@ -1326,6 +1417,7 @@ object MessageCacheStore {
} }
suspend fun clearAll() { suspend fun clearAll() {
Logger.d("MessageCache", "clearAll")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.purgeAllCache() db.messageDatabaseQueries.purgeAllCache()
} }
@@ -1334,44 +1426,86 @@ object MessageCacheStore {
private fun validatedOrEmpty(conversationId: String, messages: List<Message>): List<Message> { private fun validatedOrEmpty(conversationId: String, messages: List<Message>): List<Message> {
val self = ApiClient.user?.id val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) { if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"validatedOrEmpty incoherent→empty convId=$conversationId count=${messages.size}",
)
return emptyList() return emptyList()
} }
return CacheValidator.filterMessages(conversationId, messages, self) return CacheValidator.filterMessages(conversationId, messages, self)
} }
private suspend fun replaceMessages(conversationId: String, messages: List<Message>) { private suspend fun replaceMessages(
conversationId: String,
messages: List<Message>,
replaceAll: Boolean = false,
) {
val self = ApiClient.user?.id val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) { if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"replaceMessages incoherent→clear convId=$conversationId " +
"count=${messages.size} replaceAll=$replaceAll",
)
clearConversationMessages(conversationId) clearConversationMessages(conversationId)
return return
} }
val validated = CacheValidator.filterMessages(conversationId, messages, self) val validated = CacheValidator.filterMessages(conversationId, messages, self)
val iid = instanceId() val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"replaceMessages convId=$conversationId replaceAll=$replaceAll " +
"incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val existingReplyToIds = db.messageDatabaseQueries val existingReplyToIds = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
.associate { it.id.toInt() to it.replyToId } .associate { it.id.toInt() to it.replyToId }
db.messageDatabaseQueries.transaction { if (replaceAll) {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
validated.forEach { msg: Message ->
db.messageDatabaseQueries.upsertMessage(
instanceId = iid,
id = msg.id.toLong(),
conversationId = conversationId,
userId = msg.user_id.toLong(),
content = storedMessageContent(msg),
timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L,
isEdited = if (msg.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
clientMessageId = msg.client_message_id,
deletedFlag = 0L,
sendStatus = if (msg.id < 0) "pending" else "sent",
)
}
}
} else {
// Merge into existing rows — partial UI snapshots must not wipe full history.
validated.forEach { msg: Message -> validated.forEach { msg: Message ->
db.messageDatabaseQueries.upsertMessage( upsertSingle(conversationId, msg)
instanceId = iid,
id = msg.id.toLong(),
conversationId = conversationId,
userId = msg.user_id.toLong(),
content = storedMessageContent(msg),
timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L,
isEdited = if (msg.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
clientMessageId = msg.client_message_id,
deletedFlag = 0L,
sendStatus = if (msg.id < 0) "pending" else "sent"
)
} }
} }
} }
val afterCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"replaceMessages done convId=$conversationId replaceAll=$replaceAll " +
"rowsAfter=$afterCount",
)
dmOtherUserIdFromConversationId(conversationId)?.let { dmOtherUserIdFromConversationId(conversationId)?.let {
syncDmConversationPreviewFromCache(it) syncDmConversationPreviewFromCache(it)
pruneEmptyConversations() pruneEmptyConversations()
@@ -56,8 +56,13 @@ object MessageRepository {
fun observeActiveDmConversations(): Flow<List<CachedConversation>> = fun observeActiveDmConversations(): Flow<List<CachedConversation>> =
MessageCacheStore.observeActiveDmConversations(activeInstance()) MessageCacheStore.observeActiveDmConversations(activeInstance())
suspend fun replacePublicMessages(messages: List<Message>) = suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
MessageCacheStore.replacePublicMessages(messages) ru.fromchat.Logger.d(
"MessageRepo",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replacePublicMessages(messages, replaceAll = replaceAll)
}
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message) suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
@@ -67,20 +72,32 @@ object MessageRepository {
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) = suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId) MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId)
suspend fun markMessageDeleted(conversationId: String, messageId: Int) = suspend fun deletePublicMessageById(messageId: Int) {
ru.fromchat.Logger.d("MessageRepo", "deletePublicMessageById messageId=$messageId")
MessageCacheStore.deletePublicMessageById(messageId)
}
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
ru.fromchat.Logger.d(
"MessageRepo",
"markMessageDeleted convId=$conversationId messageId=$messageId",
)
MessageCacheStore.markMessageDeleted(conversationId, messageId) MessageCacheStore.markMessageDeleted(conversationId, messageId)
}
suspend fun markPublicMessageDeleted(messageId: Int) = suspend fun markPublicMessageDeleted(messageId: Int) =
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId) markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
suspend fun deletePublicMessageById(messageId: Int) =
MessageCacheStore.deletePublicMessageById(messageId)
suspend fun loadDmMessages(otherUserId: Int): List<Message> = suspend fun loadDmMessages(otherUserId: Int): List<Message> =
MessageCacheStore.loadDmMessages(otherUserId) MessageCacheStore.loadDmMessages(otherUserId)
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) = suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
MessageCacheStore.replaceDmMessages(otherUserId, messages) ru.fromchat.Logger.d(
"MessageRepo",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replaceDmMessages(otherUserId, messages, replaceAll = replaceAll)
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) = suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
MessageCacheStore.upsertDmMessage(otherUserId, message) MessageCacheStore.upsertDmMessage(otherUserId, message)
@@ -91,8 +108,13 @@ object MessageRepository {
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) = suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId) MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId)
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) = suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
ru.fromchat.Logger.d(
"MessageRepo",
"deleteDmMessageById otherUserId=$otherUserId messageId=$messageId",
)
MessageCacheStore.deleteDmMessageById(otherUserId, messageId) MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
}
suspend fun replaceDmConversations( suspend fun replaceDmConversations(
conversations: List<DmConversation>, conversations: List<DmConversation>,
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversationUser import ru.fromchat.api.schema.messages.dm.DmConversationUser
@@ -71,19 +72,41 @@ object ProfileCache {
/** Skip force=false network refetch when a full profile was fetched within this window. */ /** Skip force=false network refetch when a full profile was fetched within this window. */
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
private fun bumpRevision() { private fun bumpRevision(reason: String) {
_revision.value++ val next = _revision.value + 1
_revision.value = next
Logger.d("ProfileCache", "revision=$next reason=$reason size=${profiles.size}")
} }
private fun profileSummary(profile: UserProfile): String =
"id=${profile.id} user='${profile.username}' deleted=${profile.deleted} " +
"suspended=${profile.suspended} preview=${profile.isClientPreviewOnly} " +
"verified=${profile.verified} vStatus=${profile.verificationStatus}"
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. */ /** True when a full non-preview profile was fetched recently enough to skip refetch. */
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean { fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
val profile = get(userId) ?: return false val profile = get(userId) ?: run {
if (profile.isClientPreviewOnly) return false Logger.d("ProfileCache", "hasFreshFullProfile id=$userId miss")
val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false return false
return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs }
if (profile.isClientPreviewOnly) {
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=previewOnly")
return false
}
val fetchedAt = fullProfileFetchedAtMs[userId] ?: run {
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=noFetchTs")
return false
}
val ageMs = Clock.System.now().toEpochMilliseconds() - fetchedAt
val fresh = ageMs <= maxAgeMs
Logger.d(
"ProfileCache",
"hasFreshFullProfile id=$userId fresh=$fresh ageMs=$ageMs maxAgeMs=$maxAgeMs",
)
return fresh
} }
/** Emits whenever this user's cached profile changes (including bio). */ /** Emits whenever this user's cached profile changes (including bio). */
@@ -110,7 +133,13 @@ object ProfileCache {
) { ) {
if (id <= 0) return if (id <= 0) return
val existing = get(id) val existing = get(id)
if (existing != null && !existing.isClientPreviewOnly) return if (existing != null && !existing.isClientPreviewOnly) {
Logger.d(
"ProfileCache",
"mergePreview skipFullExists id=$id deleted=${existing.deleted}",
)
return
}
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() } val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() } ?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
@@ -120,11 +149,27 @@ object ProfileCache {
} else { } else {
displayName?.trim()?.takeIf { it.isNotEmpty() } displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() } ?: existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingUsername
} }
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) {
Logger.d("ProfileCache", "mergePreview skipEmptyIdentity id=$id")
return
}
if (incomingUsername.isNullOrEmpty() || incomingDisplayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergePreview missingIdentity id=$id " +
"hasUsername=${!incomingUsername.isNullOrEmpty()} " +
"hasDisplayName=${!incomingDisplayName.isNullOrBlank()}",
)
}
Logger.d(
"ProfileCache",
"mergePreview id=$id deleted=$isDeleted hadExisting=${existing != null} " +
"user='${incomingUsername.orEmpty()}' display='${incomingDisplayName.orEmpty()}'",
)
put( put(
UserProfile( UserProfile(
id = id, id = id,
@@ -159,29 +204,49 @@ object ProfileCache {
val hasIdentity = val hasIdentity =
profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank() profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
if (!hasIdentity) { if (!hasIdentity) {
Logger.d("ProfileCache", "put removeEmptyPreview id=${profile.id}")
remove(profile.id) remove(profile.id)
return return
} }
} }
val cur = profiles val cur = profiles
val existing = cur[profile.id] val existing = cur[profile.id]
val deletedChanged = existing?.deleted != profile.deleted
val suspendedChanged = existing?.suspended != profile.suspended
val verificationChanged = existing?.verified != profile.verified ||
existing?.verificationStatus != profile.verificationStatus
if ( if (
existing != null && existing != null &&
!existing.isClientPreviewOnly && !existing.isClientPreviewOnly &&
existing.bio != profile.bio existing.bio != profile.bio
) { ) {
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " + "put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}", "'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}",
) )
} }
if (deletedChanged || suspendedChanged || verificationChanged || existing == null) {
Logger.d(
"ProfileCache",
"put ${profileSummary(profile)} hadExisting=${existing != null} " +
"deletedChanged=$deletedChanged suspendedChanged=$suspendedChanged " +
"verificationChanged=$verificationChanged",
)
}
profiles = cur + (profile.id to profile) profiles = cur + (profile.id to profile)
bumpRevision() bumpRevision("put:${profile.id}")
val instanceId = loadedInstanceId val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
ioScope.launch { ioScope.launch {
runCatching { ProfileCacheStore.put(instanceId, profile) } runCatching { ProfileCacheStore.put(instanceId, profile) }
.onFailure {
Logger.w(
"ProfileCache",
"persist put failed id=${profile.id}: ${it.message}",
it,
)
}
} }
} }
} }
@@ -199,27 +264,56 @@ object ProfileCache {
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 lifecycleMismatch =
existing.deleted != normalized.deleted ||
existing.suspended != normalized.suspended ||
isDeletedPlaceholderUsername(existing.username) !=
isDeletedPlaceholderUsername(normalized.username)
if (lifecycleMismatch) {
Logger.w(
"ProfileCache",
"applyServerProfile force=false lifecycleMismatch " +
"cachedDeleted=${existing.deleted} incomingDeleted=${normalized.deleted} " +
"cachedSuspended=${existing.suspended} " +
"incomingSuspended=${normalized.suspended} " +
"cachedUser='${existing.username}' incomingUser='${normalized.username}' " +
"— applying full server profile",
)
put(normalized)
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
return
}
val patched = existing.copy( val patched = existing.copy(
verified = normalized.verified ?: existing.verified, verified = normalized.verified ?: existing.verified,
verificationStatus = normalized.verificationStatus verificationStatus = normalized.verificationStatus
?: existing.verificationStatus, ?: existing.verificationStatus,
) )
val verificationChanged = patched.verified != existing.verified ||
patched.verificationStatus != existing.verificationStatus
if (patched != existing) put(patched) if (patched != existing) put(patched)
if (existing.bio != normalized.bio) { if (existing.bio != normalized.bio) {
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"applyServerProfile skipped stale HTTP id=${profile.id} " + "applyServerProfile skipped stale HTTP id=${profile.id} " +
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'", "cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}' " +
"deleted=${existing.deleted}",
)
} else {
Logger.d(
"ProfileCache",
"applyServerProfile keepCached force=false id=${profile.id} " +
"deleted=${existing.deleted} verificationChanged=$verificationChanged",
) )
} }
// Refresh TTL so force=false callers stop refetching. if (!verificationChanged) {
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs) fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
}
return return
} }
} }
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"applyServerProfile applied force=$force id=${profile.id} " + "applyServerProfile applied force=$force ${profileSummary(normalized)} " +
"bio='${normalized.bio?.take(48)}'", "bio='${normalized.bio?.take(48)}'",
) )
put(normalized) put(normalized)
@@ -228,9 +322,13 @@ object ProfileCache {
fun remove(userId: Int) { fun remove(userId: Int) {
val cur = profiles val cur = profiles
if (userId !in cur) return if (userId !in cur) {
Logger.d("ProfileCache", "remove miss id=$userId")
return
}
Logger.d("ProfileCache", "remove id=$userId wasDeleted=${cur[userId]?.deleted}")
profiles = cur - userId profiles = cur - userId
bumpRevision() bumpRevision("remove:$userId")
val instanceId = loadedInstanceId val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
ioScope.launch { ioScope.launch {
@@ -251,20 +349,40 @@ object ProfileCache {
if (user.id <= 0) return if (user.id <= 0) return
val incomingUsername = user.username.trim() val incomingUsername = user.username.trim()
if (incomingUsername.isEmpty()) return if (incomingUsername.isEmpty()) {
Logger.d("ProfileCache", "mergeFromDmUser skipEmptyUsername id=${user.id}")
return
}
val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername) val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
val incomingDisplayName = if (isDeleted) { val incomingDisplayName = if (isDeleted) {
null null
} else { } else {
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername user.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
if (!isDeleted && incomingDisplayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergeFromDmUser missingDisplayName id=${user.id} user='$incomingUsername'",
)
} }
val existing = get(user.id) val existing = get(user.id)
Logger.d(
"ProfileCache",
"mergeFromDmUser id=${user.id} deleted=$isDeleted " +
"incomingDeleted=${user.deleted} hadFull=${existing != null && existing.isClientPreviewOnly != true} " +
"user='$incomingUsername' display='${incomingDisplayName.orEmpty()}'",
)
if (existing != null && !existing.isClientPreviewOnly) { if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy( val patched = existing.copy(
username = incomingUsername, username = incomingUsername,
displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName, displayName = if (isDeleted) {
null
} else {
incomingDisplayName ?: existing.displayName
},
profilePicture = if (isDeleted) { profilePicture = if (isDeleted) {
null null
} else { } else {
@@ -278,7 +396,14 @@ object ProfileCache {
suspensionReason = user.suspensionReason ?: existing.suspensionReason, suspensionReason = user.suspensionReason ?: existing.suspensionReason,
deleted = isDeleted, deleted = isDeleted,
) )
if (patched != existing) put(patched) if (patched != existing) {
Logger.d(
"ProfileCache",
"mergeFromDmUser patchFull id=${user.id} " +
"deleted ${existing.deleted}${patched.deleted}",
)
put(patched)
}
return return
} }
@@ -286,8 +411,11 @@ object ProfileCache {
UserProfile( UserProfile(
id = user.id, id = user.id,
username = incomingUsername, username = incomingUsername,
displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } displayName = if (isDeleted) {
?: incomingDisplayName, null
} else {
incomingDisplayName ?: existing?.displayName?.takeIf { it.isNotBlank() }
},
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() } profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture, ?: existing?.profilePicture,
bio = existing?.bio, bio = existing?.bio,
@@ -336,9 +464,18 @@ object ProfileCache {
} }
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() } val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() }
if (uname.isBlank() && incomingDisplay.isNullOrBlank()) return
if (uname.isBlank() || incomingDisplay.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergePreviewFromPublicMessage missingIdentity id=$uid " +
"hasUsername=${uname.isNotBlank()} hasDisplayName=${!incomingDisplay.isNullOrBlank()}",
)
}
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname val display = if (isDeleted) null else incomingDisplay
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() } val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture ?: existing?.profilePicture
@@ -379,6 +516,8 @@ object ProfileCache {
val user = ApiClient.user val user = ApiClient.user
return message.copy( return message.copy(
username = message.username.trim().ifBlank { user?.username.orEmpty() }, username = message.username.trim().ifBlank { user?.username.orEmpty() },
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: user?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() } profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: user?.profile_picture, ?: user?.profile_picture,
reply_to = enrichedReply, reply_to = enrichedReply,
@@ -389,6 +528,8 @@ object ProfileCache {
username = message.username.trim().ifBlank { username = message.username.trim().ifBlank {
profile?.visibleUsername(self).orEmpty() profile?.visibleUsername(self).orEmpty()
}, },
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: profile?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() } profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: profile?.profilePicture, ?: profile?.profilePicture,
verified = message.verified ?: profile?.verified, verified = message.verified ?: profile?.verified,
@@ -413,7 +554,7 @@ object ProfileCache {
} }
fullProfileFetchedAtMs = emptyMap() fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked() pruneUnusableClientPreviewsLocked()
bumpRevision() bumpRevision("onActiveInstanceChanged:$instanceId")
} }
} }
} }
@@ -427,6 +568,7 @@ object ProfileCache {
} else { } else {
emptyMap() emptyMap()
} }
val before = profiles.size
if (profiles.isEmpty()) { if (profiles.isEmpty()) {
profiles = diskProfiles profiles = diskProfiles
} else { } else {
@@ -442,8 +584,14 @@ object ProfileCache {
} }
profiles = merged profiles = merged
} }
val deletedCount = profiles.values.count { it.deleted == true }
Logger.d(
"ProfileCache",
"hydrateFromDisk instanceId=$instanceId before=$before " +
"disk=${diskProfiles.size} after=${profiles.size} deletedCount=$deletedCount",
)
pruneUnusableClientPreviewsLocked() pruneUnusableClientPreviewsLocked()
bumpRevision() bumpRevision("hydrateFromDisk")
} }
} }
@@ -455,6 +603,7 @@ object ProfileCache {
p.displayName.isNullOrBlank() p.displayName.isNullOrBlank()
}.keys }.keys
if (toRemove.isEmpty()) return if (toRemove.isEmpty()) return
Logger.d("ProfileCache", "pruneUnusablePreviews ids=$toRemove")
var cur = profiles var cur = profiles
for (id in toRemove) { for (id in toRemove) {
cur = cur - id cur = cur - id
@@ -464,10 +613,11 @@ object ProfileCache {
suspend fun clear() { suspend fun clear() {
persistMutex.withLock { persistMutex.withLock {
Logger.d("ProfileCache", "clear sizeWas=${profiles.size}")
profiles = emptyMap() profiles = emptyMap()
fullProfileFetchedAtMs = emptyMap() fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = "" loadedInstanceId = ""
bumpRevision() bumpRevision("clear")
} }
} }
} }
@@ -8,7 +8,6 @@ import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.db.parseDmMessageContent import ru.fromchat.api.local.db.parseDmMessageContent
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.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ActiveDmChatTracker import ru.fromchat.api.local.messages.ActiveDmChatTracker
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.messages.dm.DmEnvelope
@@ -65,16 +64,31 @@ object DmInboundMessageProcessor {
suspend fun processDeleted(element: JsonElement) { suspend fun processDeleted(element: JsonElement) {
val data = runCatching { val data = runCatching {
ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element) ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return }.getOrNull() ?: run {
ru.fromchat.Logger.w("DmInbox", "processDeleted decode failed")
return
}
val currentUserId = ApiClient.user?.id ?: return val currentUserId = ApiClient.user?.id ?: return
if (data.senderId != currentUserId && data.recipientId != currentUserId) return if (data.senderId != currentUserId && data.recipientId != currentUserId) {
ru.fromchat.Logger.d(
"DmInbox",
"processDeleted skipNotParticipant messageId=${data.id} " +
"senderId=${data.senderId} recipientId=${data.recipientId} self=$currentUserId",
)
return
}
val otherUserId = when (currentUserId) { val otherUserId = when (currentUserId) {
data.senderId -> data.recipientId data.senderId -> data.recipientId
else -> data.senderId else -> data.senderId
} ?: return } ?: return
ru.fromchat.Logger.i(
"DmInbox",
"processDeleted messageId=${data.id} otherUserId=$otherUserId " +
"senderId=${data.senderId} recipientId=${data.recipientId}",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.deleteDmMessageById(otherUserId, data.id) MessageRepository.deleteDmMessageById(otherUserId, data.id)
} }
@@ -132,19 +146,31 @@ object DmInboundMessageProcessor {
otherUserId: Int, otherUserId: Int,
): Message { ): Message {
val dec = parseDmMessageContent(plaintext) val dec = parseDmMessageContent(plaintext)
val senderUsername = envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
val senderDisplayName = envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
if (envelope.senderId != currentUserId) { if (envelope.senderId != currentUserId) {
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName -> if (senderUsername != null || senderDisplayName != null) {
ProfileCache.mergePreview(id = envelope.senderId, username = senderName) ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername,
displayName = senderDisplayName,
)
} }
} }
val senderProfile = ProfileCache.get(envelope.senderId) val senderProfile = ProfileCache.get(envelope.senderId)
val username = if (envelope.senderId == currentUserId) { val username = if (envelope.senderId == currentUserId) {
"You" ApiClient.user?.username.orEmpty()
} else { } else {
senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() } senderUsername
?: envelope.senderUsername?.takeIf { it.isNotBlank() } ?: senderProfile?.username?.trim()?.takeIf { it.isNotEmpty() }
?: "" ?: ""
} }
val displayName = if (envelope.senderId == currentUserId) {
ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} else {
senderDisplayName
?: senderProfile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
return Message( return Message(
id = envelope.id, id = envelope.id,
user_id = envelope.senderId, user_id = envelope.senderId,
@@ -153,6 +179,7 @@ object DmInboundMessageProcessor {
is_read = envelope.senderId == currentUserId, is_read = envelope.senderId == currentUserId,
is_edited = false, is_edited = false,
username = username, username = username,
displayName = displayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = null, reply_to = null,
@@ -36,6 +36,7 @@ object DmInboxCoordinator {
} }
"dmDeleted" -> message.data?.let { element -> "dmDeleted" -> message.data?.let { element ->
scope.launch { scope.launch {
ru.fromchat.Logger.d("DmInbox", "handleMessage dmDeleted")
DmInboundMessageProcessor.processDeleted(element) DmInboundMessageProcessor.processDeleted(element)
DmConversationListNotifier.notifyChanged() DmConversationListNotifier.notifyChanged()
} }
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.messages
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
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
@@ -15,7 +16,15 @@ import ru.fromchat.api.schema.websocket.types.MessageDeletedData
*/ */
object PublicInboxCoordinator { object PublicInboxCoordinator {
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) { suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
val message = decodeMessage(element) ?: return@withContext val message = decodeMessage(element) ?: run {
Logger.w("PublicInbox", "processNew decode failed")
return@withContext
}
Logger.d(
"PublicInbox",
"processNew id=${message.id} userId=${message.user_id} " +
"clientId=${message.client_message_id}",
)
ProfileCache.mergePreviewFromPublicMessage(message) ProfileCache.mergePreviewFromPublicMessage(message)
val clientId = message.client_message_id?.trim().orEmpty() val clientId = message.client_message_id?.trim().orEmpty()
val currentUserId = ApiClient.user?.id val currentUserId = ApiClient.user?.id
@@ -49,7 +58,11 @@ object PublicInboxCoordinator {
} }
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) { suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
val edited = decodeMessage(element) ?: return@withContext val edited = decodeMessage(element) ?: run {
Logger.w("PublicInbox", "processEdited decode failed")
return@withContext
}
Logger.d("PublicInbox", "processEdited id=${edited.id} userId=${edited.user_id}")
ProfileCache.mergePreviewFromPublicMessage(edited) ProfileCache.mergePreviewFromPublicMessage(edited)
val existing = MessageRepository.loadPublicMessages() val existing = MessageRepository.loadPublicMessages()
val merged = existing.map { current -> val merged = existing.map { current ->
@@ -62,14 +75,23 @@ object PublicInboxCoordinator {
if (merged.none { it.id == edited.id }) { if (merged.none { it.id == edited.id }) {
MessageRepository.upsertPublicMessage(edited) MessageRepository.upsertPublicMessage(edited)
} else { } else {
MessageRepository.replacePublicMessages(merged) MessageRepository.upsertPublicMessage(
merged.first { it.id == edited.id },
)
} }
} }
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) { suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
val deleted = runCatching { val deleted = runCatching {
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element) ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
}.getOrNull() ?: return@withContext }.getOrNull() ?: run {
Logger.w("PublicInbox", "processDeleted decode failed element=${element.toString().take(120)}")
return@withContext
}
Logger.i(
"PublicInbox",
"processDeleted messageId=${deleted.message_id} — hard-deleting from cache",
)
MessageRepository.deletePublicMessageById(deleted.message_id) MessageRepository.deletePublicMessageById(deleted.message_id)
} }
@@ -18,8 +18,16 @@ object UpdatesBatchApplier {
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock { suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
val envelope = runCatching { val envelope = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data) ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return@withLock null }.getOrNull() ?: run {
Logger.w("UpdateSync", "applyEnvelope decode failed: ${data.toString().take(160)}")
return@withLock null
}
val types = envelope.updates.map { it.type }
Logger.d(
"UpdateSync",
"applyEnvelope seq=${envelope.seq} count=${envelope.updates.size} types=$types",
)
for (update in envelope.updates) { for (update in envelope.updates) {
applyOne(WebSocketMessage(type = update.type, data = update.data)) applyOne(WebSocketMessage(type = update.type, data = update.data))
} }
@@ -34,8 +42,14 @@ object UpdatesBatchApplier {
} }
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) } "newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) } "messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
"messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) } "messageDeleted" -> {
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message) Logger.d("UpdateSync", "applyOne messageDeleted")
message.data?.let { PublicInboxCoordinator.processDeleted(it) }
}
"dmNew", "dmDeleted", "dmEdited" -> {
Logger.d("UpdateSync", "applyOne ${message.type}")
DmInboxCoordinator.handleMessage(message)
}
else -> Unit else -> Unit
} }
} }
@@ -17,6 +17,7 @@ data class Message(
val is_read: Boolean, val is_read: Boolean,
val is_edited: Boolean, val is_edited: Boolean,
val username: String, val username: String,
@SerialName("display_name") val displayName: String? = null,
val profile_picture: String? = null, val profile_picture: String? = null,
val verified: Boolean? = null, val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null, @SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
@@ -1,9 +1,11 @@
package ru.fromchat.api.schema.messages package ru.fromchat.api.schema.messages
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ReactionUser( data class ReactionUser(
val id: Int, val id: Int,
val username: String val username: String,
@SerialName("display_name") val displayName: String? = null,
) )
@@ -9,6 +9,7 @@ data class DmEnvelope(
val senderId: Int, val senderId: Int,
val recipientId: Int, val recipientId: Int,
@SerialName("sender_username") val senderUsername: String? = null, @SerialName("sender_username") val senderUsername: String? = null,
@SerialName("sender_display_name") val senderDisplayName: String? = null,
@SerialName("iv_b64") val ivB64: String, @SerialName("iv_b64") val ivB64: String,
@SerialName("ciphertext_b64") val ciphertextB64: String, @SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
@@ -1,5 +1,6 @@
package ru.fromchat.api.schema.websocket.types package ru.fromchat.api.schema.websocket.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -9,5 +10,6 @@ data class ReactionUpdateData(
val action: String, val action: String,
val user_id: Int, val user_id: Int,
val username: String, val username: String,
@SerialName("display_name") val displayName: String? = null,
val reactions: List<ReactionData> val reactions: List<ReactionData>
) )
@@ -94,11 +94,26 @@ abstract class ChatPanel(
addMessageMutex.withLock { addMessageMutex.withLock {
batchStateUpdates { batchStateUpdates {
updateState { current -> updateState { current ->
val panelSnap = panelMessagesForDbMerge()
val merged = mergeDatabaseMessagesWithPanelState( val merged = mergeDatabaseMessagesWithPanelState(
panelMessagesForDbMerge(), panelSnap,
messages, messages,
) )
val withReplies = attachPublicReplyReferences(merged) val withReplies = attachPublicReplyReferences(merged)
if (current.messages.size != withReplies.size ||
current.messages.map { it.id }.toSet() != withReplies.map { it.id }.toSet()
) {
val panelIds = panelSnap.map { it.id }.toSet()
val mergedIds = withReplies.map { it.id }.toSet()
val dbIds = messages.map { it.id }.toSet()
Logger.d(
"ChatPanel",
"syncMessagesFromDatabase panel=${panelSnap.size} db=${messages.size} " +
"merged=${withReplies.size} " +
"panelOnlyIds=${(panelIds - mergedIds).take(8)} " +
"dbOnlyIds=${(dbIds - mergedIds).take(8)}",
)
}
if (current.messages == withReplies) current if (current.messages == withReplies) current
else current.copy(messages = withReplies) else current.copy(messages = withReplies)
} }
@@ -692,7 +707,8 @@ abstract class ChatPanel(
timestamp = nowMessageTimestampIso(), timestamp = nowMessageTimestampIso(),
is_read = false, is_read = false,
is_edited = false, is_edited = false,
username = "You", username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
client_message_id = tempId, client_message_id = tempId,
reply_to = resolvedReply, reply_to = resolvedReply,
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 }, replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
@@ -949,7 +949,8 @@ fun ChatScreen(
timestamp = nowMessageTimestampIso(), timestamp = nowMessageTimestampIso(),
is_read = false, is_read = false,
is_edited = false, is_edited = false,
username = "You", username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = replyTo, reply_to = replyTo,
@@ -33,6 +33,7 @@ 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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -105,7 +106,7 @@ fun ChatTopBarInner(
peerIsDeleted( peerIsDeleted(
userId = userId, userId = userId,
currentUserId = ApiClient.user?.id, currentUserId = ApiClient.user?.id,
username = titleAvatar?.displayName ?: title, username = ProfileCache.get(userId)?.username,
) )
} == true } == true
Row( Row(
@@ -203,7 +204,10 @@ fun ChatTopBarInner(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
profileUserId?.let { userId -> profileUserId?.let { userId ->
val status = resolveVerificationStatus(userId) val profileCacheRevision by ProfileCache.revision.collectAsState()
val status = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
if (status != null) { if (status != null) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
StatusBadge( StatusBadge(
@@ -18,12 +18,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset 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.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Reply import androidx.compose.material.icons.automirrored.rounded.Reply
@@ -710,26 +711,27 @@ fun ImageFullscreenPreview(
} }
// Top bar: back, display name + date/time, 3-dot menu // Top bar: back, display name + date/time, 3-dot menu
Box( AnimatedVisibility(
visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.fillMaxWidth() .fillMaxWidth(),
.windowInsetsPadding(WindowInsets.safeDrawing),
) { ) {
AnimatedVisibility( Column(
visible = effectiveMenusVisible, modifier = Modifier
enter = androidx.compose.animation.fadeIn(), .fillMaxWidth()
exit = androidx.compose.animation.fadeOut(), .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
modifier = Modifier.fillMaxWidth(), .statusBarsPadding(),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(horizontal = 8.dp, vertical = 12.dp), .padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
IconButton(onClick = { dismissRequested = true }) { IconButton(onClick = { dismissRequested = true }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
@@ -820,32 +822,31 @@ fun ImageFullscreenPreview(
} }
// Bottom: message text // Bottom: message text
Box( AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth(),
.windowInsetsPadding(WindowInsets.safeDrawing),
) { ) {
AnimatedVisibility( Column(
visible = effectiveMenusVisible && message.content.isNotBlank(), modifier = Modifier
enter = androidx.compose.animation.fadeIn(), .fillMaxWidth()
exit = androidx.compose.animation.fadeOut(), .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
modifier = Modifier.fillMaxWidth(), .navigationBarsPadding(),
) { ) {
if (message.content.isNotBlank()) { Box(
Box( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .padding(16.dp),
.background(Color.Black.copy(alpha = MENU_BG_ALPHA)) ) {
.padding(16.dp),
) {
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color.White color = Color.White,
) )
} }
}
} }
} }
} }
@@ -15,7 +15,8 @@ import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
/** /**
* Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name. * Resolves the sender label shown in message bubbles: localized «Вы», deleted user label,
* cached/server display name, or login username only as a last resort.
*/ */
@Composable @Composable
fun messageDisplayUsername(message: Message, currentUserId: Int?): String { fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
@@ -30,12 +31,14 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (isDeletedAccountUsername(message.username)) { if (isDeletedAccountUsername(message.username)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId) ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
if (cachedUsername != null) return cachedUsername ?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
if (message.username.equals("deleted", ignoreCase = true)) { if (message.username.equals("deleted", ignoreCase = true)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
return message.username return message.username.trim()
} }
fun messageSenderProfilePicture( fun messageSenderProfilePicture(
@@ -75,5 +78,5 @@ fun messageSenderAvatarLabel(
if (currentUserId != null && message.user_id == currentUserId) { if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty() return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
} }
return message.username.trim() return message.displayName?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
} }
@@ -35,6 +35,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -229,11 +230,14 @@ fun MessageItem(
val sendFailedLabel = stringResource(Res.string.message_send_failed) val sendFailedLabel = stringResource(Res.string.message_send_failed)
val replyPhotoLabel = stringResource(Res.string.message_reply_photo) val replyPhotoLabel = stringResource(Res.string.message_reply_photo)
val displayUsername = messageDisplayUsername(message, currentUserId) val displayUsername = messageDisplayUsername(message, currentUserId)
val profileCacheRevision by ProfileCache.revision.collectAsState()
val senderProfile = ProfileCache.get(message.user_id) val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() } val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture ?: message.profile_picture
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId) val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message) val senderVerificationStatus = remember(message.user_id, message, profileCacheRevision) {
resolveVerificationStatus(message.user_id, message)
}
val isDeletedSender = messageSenderIsDeleted(message, currentUserId) val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
val replyRef = message.reply_to val replyRef = message.reply_to
@@ -159,6 +159,13 @@ class DmPanel(
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val cached = ProfileCache.get(otherUserId) val cached = ProfileCache.get(otherUserId)
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty() val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
Logger.d(
"DmPanel",
"applyCachedPeerProfile otherUserId=$otherUserId " +
"deleted=${cached?.deleted} suspended=${cached?.suspended} " +
"revision=${ProfileCache.revision.value} " +
"titleBlank=${displayName.isBlank()}",
)
cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) } cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (displayName.isNotBlank()) { if (displayName.isNotBlank()) {
@@ -236,17 +243,17 @@ class DmPanel(
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner // Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
// when the chat screen re-entered composition (e.g. pop back from profile). // when the chat screen re-entered composition (e.g. pop back from profile).
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) { val hadCachedMessages = cached.isNotEmpty()
if (hadCachedMessages) {
batchStateUpdates { batchStateUpdates {
clearMessages() clearMessages()
addMessages(cached) addMessages(cached)
setLoading(false) setLoading(false)
} }
messagesLoaded = true } else {
return setLoading(true)
} }
setLoading(true)
try { try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(), CacheContext.requireActiveInstanceId(),
@@ -289,7 +296,7 @@ class DmPanel(
// Persist the most recent DM messages for offline use. // Persist the most recent DM messages for offline use.
val mergedForCache = _state.messages val mergedForCache = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache) MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache, replaceAll = true)
messagesLoaded = true messagesLoaded = true
} else { } else {
val error = historyResult.exceptionOrNull() val error = historyResult.exceptionOrNull()
@@ -299,6 +306,8 @@ class DmPanel(
clearMessages() clearMessages()
setHasMoreMessages(false) setHasMoreMessages(false)
messagesLoaded = true messagesLoaded = true
} else if (hadCachedMessages) {
messagesLoaded = true
} }
} }
} finally { } finally {
@@ -510,8 +519,6 @@ class DmPanel(
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence) MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid) OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
} }
val snapshot = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, snapshot)
} }
} }
@@ -533,31 +540,46 @@ class DmPanel(
} }
val outcome = decryptDmEnvelopeForUi(envelope) val outcome = decryptDmEnvelopeForUi(envelope)
val dec = parseDmMessageContent(outcome.plaintext) val dec = parseDmMessageContent(outcome.plaintext)
val editedForCache = (previous ?: createMessage(envelope, outcome.plaintext, outcome.isCorrupted)).copy(
content = dec.text,
is_edited = true,
fileThumbnails = dec.fileThumbnails ?: previous?.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: previous?.fileAspectRatios,
fileSizes = dec.fileSizes ?: previous?.fileSizes,
fileDimensions = dec.fileDimensions ?: previous?.fileDimensions,
isContentCorrupted = outcome.isCorrupted,
dmEnvelope = envelope,
reply_to = previous?.reply_to,
)
updateMessage(envelope.id) { updateMessage(envelope.id) {
it.copy( editedForCache.copy(reply_to = it.reply_to)
content = dec.text,
is_edited = true,
fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes,
fileDimensions = dec.fileDimensions ?: it.fileDimensions,
isContentCorrupted = outcome.isCorrupted,
dmEnvelope = envelope,
reply_to = it.reply_to,
)
} }
// Persist edit to cache MessageCacheStore.upsertDmMessage(otherUserId, editedForCache)
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
} }
} }
private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message { private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message {
val dec = parseDmMessageContent(plaintext) val dec = parseDmMessageContent(plaintext)
val username = if (envelope.senderId == currentUserId) { val senderUsername = if (envelope.senderId == currentUserId) {
"You" ApiClient.user?.username.orEmpty()
} else { } else {
otherDisplayName envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(envelope.senderId)?.username?.trim().orEmpty()
}
val senderDisplayName = if (envelope.senderId == currentUserId) {
ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} else {
envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
?: otherDisplayName.takeIf { it.isNotBlank() }
?: ProfileCache.get(envelope.senderId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
if (envelope.senderId != currentUserId) {
ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername.takeIf { it.isNotEmpty() },
displayName = senderDisplayName,
)
} }
return Message( return Message(
id = envelope.id, id = envelope.id,
@@ -569,7 +591,8 @@ class DmPanel(
else -> ActiveDmChatTracker.isActive(otherUserId) else -> ActiveDmChatTracker.isActive(otherUserId)
}, },
is_edited = false, is_edited = false,
username = username, username = senderUsername,
displayName = senderDisplayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = null, reply_to = null,
@@ -621,12 +644,27 @@ class DmPanel(
private fun processDeletedEnvelope(element: JsonElement) { private fun processDeletedEnvelope(element: JsonElement) {
val data = runCatching { val data = runCatching {
json.decodeFromJsonElement(DmDeletedData.serializer(), element) json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return }.getOrNull() ?: run {
Logger.w("DmPanel", "processDeletedEnvelope decode failed")
return
}
val involvesPeer = val involvesPeer =
data.senderId == otherUserId || data.senderId == otherUserId ||
data.recipientId == otherUserId || data.recipientId == otherUserId ||
data.senderId == currentUserId data.senderId == currentUserId
if (!involvesPeer) return if (!involvesPeer) {
Logger.d(
"DmPanel",
"processDeletedEnvelope skip messageId=${data.id} " +
"senderId=${data.senderId} recipientId=${data.recipientId} peer=$otherUserId",
)
return
}
Logger.i(
"DmPanel",
"processDeletedEnvelope messageId=${data.id} peer=$otherUserId " +
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == data.id }}",
)
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val clientId = _state.messages.find { it.id == data.id }?.client_message_id val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id) DownloadedFileRegistry.invalidateForMessage(data.id)
@@ -639,6 +677,10 @@ class DmPanel(
} }
deleteMessageImmediately(data.id) deleteMessageImmediately(data.id)
MessageRepository.deleteDmMessageById(otherUserId, data.id) MessageRepository.deleteDmMessageById(otherUserId, data.id)
Logger.d(
"DmPanel",
"processDeletedEnvelope done messageId=${data.id} uiAfter=${_state.messages.size}",
)
} }
} }
@@ -4,6 +4,8 @@ import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@@ -50,7 +52,7 @@ class PublicChatPanel(
scope = scope scope = scope
) { ) {
private val typingHandler = PublicChatTypingHandler(scope) private val typingHandler = PublicChatTypingHandler(scope)
private var networkHistoryLoaded = false private val loadMessagesMutex = Mutex()
/** /**
* Whether replacing the list would change **structure or message body** (content / edited). * Whether replacing the list would change **structure or message body** (content / edited).
@@ -377,98 +379,119 @@ class PublicChatPanel(
} }
override suspend fun loadMessages() { override suspend fun loadMessages() {
hydrateMessagesFromLocalCache() loadMessagesMutex.withLock {
if (networkHistoryLoaded) return hydrateMessagesFromLocalCache()
networkHistoryLoaded = true
val cached = _state.messages val cached = _state.messages
if (cached.isEmpty()) { if (cached.isEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
setLoading(true) setLoading(true)
}
}
// Refresh from network; this may be fast or slow, but runs entirely off main.
val responseResult = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) }
}
val response = responseResult.getOrNull()
if (response != null && response.messages.isNotEmpty()) {
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}"
Logger.d("PublicChatPanel", loadMsg)
var mergedForCache: List<Message>? = null
withContext(Dispatchers.Main) {
val shown = snapshotUiMessagesForNetworkMerge()
Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}")
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
if (withSenders != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
}
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
} else {
batchStateUpdates {
val merged = preserveReplyToFromExisting(
shown,
mergeNetworkHistoryWithShown(shown, networkMessages),
)
clearMessages()
addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged),
)
Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}")
restorePendingOptimisticMessages(optimisticSnapshot)
setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(
panelMessagesForDbMerge(),
networkMessages,
)
}
} }
} }
withContext(Dispatchers.Default) {
val toPersist = mergedForCache // Always refresh from network on open. A retained panel used to skip this after the
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages) // first visit (networkHistoryLoaded), so offline bursts left first+last holes until
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}") // slow WS catch-up filled them.
MessageCacheStore.replacePublicMessages(toPersist) Logger.d(
"PublicChatPanel",
"loadMessages: network refresh cachedCount=${cached.size}",
)
val responseResult = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) }
} }
} else if (responseResult.isFailure) { val response = responseResult.getOrNull()
val cause = responseResult.exceptionOrNull()
if (cause is ClientRequestException && cause.response.status.value == 403) { if (response != null && response.messages.isNotEmpty()) {
MessageCacheStore.clearPublicMessages() val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
Logger.d(
"PublicChatPanel",
"loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds " +
"stateCount=${_state.messages.size} networkCount=${networkMessages.size}",
)
var mergedForCache: List<Message>? = null
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
clearMessages() val shown = snapshotUiMessagesForNetworkMerge()
if (_state.isLoading) setLoading(false) Logger.d(
if (_state.hasMoreMessages) setHasMoreMessages(false) "PublicChatPanel",
"loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}",
)
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
if (withSenders != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
}
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
} else {
batchStateUpdates {
val merged = preserveReplyToFromExisting(
shown,
mergeNetworkHistoryWithShown(shown, networkMessages),
)
clearMessages()
addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged),
)
Logger.d(
"PublicChatPanel",
"loadMessages: after addMessages mergedSize=${merged.size} " +
"restoring optimistic count=${optimisticSnapshot.size}",
)
restorePendingOptimisticMessages(optimisticSnapshot)
setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(
panelMessagesForDbMerge(),
networkMessages,
)
}
}
}
withContext(Dispatchers.Default) {
val toPersist = mergedForCache
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
Logger.d(
"PublicChatPanel",
"loadMessages: persisting to cache messages=${toPersist.size} replaceAll=true",
)
MessageCacheStore.replacePublicMessages(toPersist, replaceAll = true)
}
} else if (responseResult.isFailure) {
val cause = responseResult.exceptionOrNull()
if (cause is ClientRequestException && cause.response.status.value == 403) {
MessageCacheStore.clearPublicMessages()
withContext(Dispatchers.Main) {
clearMessages()
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else if (cached.isEmpty()) {
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else {
withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
}
} }
} else if (cached.isEmpty()) { } else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
} }
} else { } else {
// We already displayed cached messages; just mark pagination state.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.isLoading) setLoading(false)
} }
} }
} else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} }
} }
@@ -494,7 +517,7 @@ class PublicChatPanel(
) )
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.replacePublicMessages(_state.messages, replaceAll = true)
} }
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
@@ -529,29 +552,47 @@ class PublicChatPanel(
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id) DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { existing -> val existing = _state.messages.find { it.id == editedMsg.id }
editedMsg.copy(reply_to = editedMsg.reply_to ?: existing.reply_to) val persisted = editedMsg.copy(reply_to = editedMsg.reply_to ?: existing?.reply_to)
updateMessage(editedMsg.id) { current ->
persisted.copy(reply_to = persisted.reply_to ?: current.reply_to)
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.upsertPublicMessage(persisted.resolvePublicAttachmentLayout())
} }
} }
"messageDeleted" -> { "messageDeleted" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data) val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
Logger.i(
"PublicChatPanel",
"messageDeleted messageId=${deletedData.message_id} " +
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == deletedData.message_id }}",
)
DecryptedImageCache.invalidateForMessage(deletedData.message_id) DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id) removeMessage(deletedData.message_id)
clearReplyReferencesTo(deletedData.message_id) clearReplyReferencesTo(deletedData.message_id)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(deletedData.message_id) MessageRepository.deletePublicMessageById(deletedData.message_id)
} }
Logger.d(
"PublicChatPanel",
"messageDeleted done messageId=${deletedData.message_id} " +
"uiAfter=${_state.messages.size}",
)
} }
"reactionUpdate" -> { "reactionUpdate" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data) val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
handleReactionUpdate(reactionUpdate) val existing = _state.messages.find { it.id == reactionUpdate.message_id }
withContext(Dispatchers.Default) { if (existing != null) {
MessageCacheStore.replacePublicMessages(_state.messages) val updated = existing.copy(reactions = reactionUpdate.reactions)
handleReactionUpdate(reactionUpdate)
withContext(Dispatchers.Default) {
MessageCacheStore.upsertPublicMessage(updated.resolvePublicAttachmentLayout())
}
} else {
handleReactionUpdate(reactionUpdate)
} }
} }
"typing" -> { "typing" -> {
@@ -620,6 +661,7 @@ class PublicChatPanel(
cancelQueuedMessage(message) cancelQueuedMessage(message)
return return
} }
Logger.d("PublicChatPanel", "handleDeleteMessage messageId=$messageId")
beginMessageDissolve(message) beginMessageDissolve(message)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(messageId) MessageRepository.deletePublicMessageById(messageId)
@@ -72,13 +72,28 @@ internal fun mergeDatabaseMessagesWithPanelState(
val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet() val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
val mergedIds = mergedDb.map { it.id }.toSet() val mergedIds = mergedDb.map { it.id }.toSet()
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them. // Keep in-flight panel optimistics even when the DB Flow emission already stripped them.
// Confirmed (id > 0) rows missing from DB are deletes — do not resurrect them from panel state.
val droppedConfirmed = panelMessages.filter { panel ->
panel.id > 0 && panel.id !in mergedIds
}
if (droppedConfirmed.isNotEmpty()) {
ru.fromchat.Logger.d(
"MessageCache",
"mergeDbPanel dropConfirmedDeletes count=${droppedConfirmed.size} " +
"ids=${droppedConfirmed.map { it.id }.take(12)} " +
"panelSize=${panelMessages.size} dbSize=${dbMessages.size}",
)
}
val extraPanel = panelMessages.filter { panel -> val extraPanel = panelMessages.filter { panel ->
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() } val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
when { panel.id < 0 && cid != null && cid !in mergedClientIds
panel.id < 0 && cid != null && cid !in mergedClientIds -> true }
panel.id > 0 && panel.id !in mergedIds && (cid.isNullOrEmpty() || cid !in mergedClientIds) -> true if (extraPanel.isNotEmpty()) {
else -> false ru.fromchat.Logger.d(
} "MessageCache",
"mergeDbPanel keepOptimistic count=${extraPanel.size} " +
"ids=${extraPanel.map { it.id }}",
)
} }
return dedupeMessagesByClientId( return dedupeMessagesByClientId(
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -130,9 +131,13 @@ internal fun ChatListHeadlineWithBadge(
title: String, title: String,
userId: Int, userId: Int,
) { ) {
val profileCacheRevision by ProfileCache.revision.collectAsState()
val verificationStatus = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
DisplayName( DisplayName(
displayName = title, displayName = title,
verificationStatus = resolveVerificationStatus(userId), verificationStatus = verificationStatus,
textStyle = MaterialTheme.typography.bodyLarge, textStyle = MaterialTheme.typography.bodyLarge,
) )
} }
@@ -928,7 +933,7 @@ internal fun DmConversationRowContent(
currentUserId = currentUserId, currentUserId = currentUserId,
deleted = cached?.deleted, deleted = cached?.deleted,
suspended = cached?.suspended, suspended = cached?.suspended,
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() }, username = cached?.username,
) )
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
val peerTitle = when { val peerTitle = when {
@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -422,6 +425,8 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -479,10 +484,15 @@ fun DevicesScreen(onBack: () -> Unit) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .fillMaxSize()
.padding() .consumeWindowInsets(innerPadding)
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), .hazeSource(hazeState),
contentPadding = innerPadding contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = innerPadding.calculateTopPadding() + 8.dp,
bottom = innerPadding.calculateBottomPadding() + 24.dp,
),
) { ) {
item { item {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
@@ -569,9 +579,8 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
} }
item { if (refreshing && devices.isEmpty()) {
// Initial load only — background poll must not add/remove list height (overscroll jump). item {
if (refreshing && devices.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()