Fix freezes

This commit is contained in:
2026-04-03 15:22:39 +03:00
Unverified
parent 0d979fc656
commit 6bda885bdc
21 changed files with 966 additions and 641 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ When working with the mobile app:
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), immediately: (1) build the debug APK with `./gradlew :app:android:assembleDebug` (use the repos working `JAVA_HOME` if the Android Studio path above is invalid); (2) on **Mobile MCP** server `user-Mobile MCP`, call `mobile_list_available_devices`; (3) for **every** device `id` returned, run `mobile_install_app` (`path` = absolute path to `app/android/build/outputs/apk/debug/android-debug.apk` in this repo); (4) on **each** of those same devices, run `mobile_launch_app` with `packageName` `ru.fromchat` (install and launch are both required, not optional).
- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), immediately: (1) build the debug APK with `./gradlew :app:android:assembleDebug` (use the repos working `JAVA_HOME` if the Android Studio path above is invalid); (2) on **Mobile MCP** server `user-Mobile MCP`, call `mobile_list_available_devices`; (3) for **every** device `id` returned, run `mobile_install_app` (`path` = absolute path to `app/android/build/outputs/apk/debug/android-debug.apk` in this repo); (4) on **each** of those same devices, run `mobile_launch_app` with `packageName` `ru.fromchat.beta` (debug uses `applicationIdSuffix` so it can install next to release `ru.fromchat`; install and launch are both required, not optional).
# ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
+3 -2
View File
@@ -19,8 +19,9 @@ build
local.properties
app/android/keys
releases
release
/releases
app/android/release
app/android/debug
*.xcuserstate
xcuserdata
+18 -5
View File
@@ -1,6 +1,5 @@
import com.android.build.api.dsl.ApplicationExtension
import java.io.FileInputStream
import java.util.Properties
plugins {
@@ -68,20 +67,34 @@ extensions.configure<ApplicationExtension> {
}
signingConfigs {
create("release") {
val keystoreProperties = Properties().apply {
load(FileInputStream(file("keys/keystore.properties")))
load(file("keys/keystore.properties").inputStream())
}
create("release") {
storeFile = file("keys/release.jks")
keyAlias = "key0"
storePassword = keystoreProperties["storePassword"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
storePassword = keystoreProperties["releaseStorePassword"].toString()
keyPassword = keystoreProperties["releaseKeyPassword"].toString()
enableV3Signing = true
}
getByName("debug") {
storeFile = file("keys/debug.jks")
keyAlias = "debug"
storePassword = keystoreProperties["debugStorePassword"].toString()
keyPassword = keystoreProperties["debugKeyPassword"].toString()
enableV3Signing = true
}
}
buildTypes {
debug {
applicationIdSuffix = ".beta"
versionNameSuffix = "-beta"
signingConfig = signingConfigs.getByName("debug")
}
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">FromChat Beta</string>
</resources>
+1 -1
View File
@@ -39,7 +39,7 @@
android:name=".notifications.NotificationReplyReceiver"
android:exported="false">
<intent-filter>
<action android:name="ru.fromchat.NOTIFICATION_REPLY" />
<action android:name="${applicationId}.NOTIFICATION_REPLY" />
</intent-filter>
</receiver>
</application>
@@ -49,11 +49,14 @@ object NotificationHelper {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun notificationReplyAction(context: Context) =
"${context.packageName}.NOTIFICATION_REPLY"
private fun createReplyIntent(context: Context) = PendingIntent.getBroadcast(
context,
SUMMARY_NOTIFICATION_ID,
Intent(context, NotificationReplyReceiver::class.java).apply {
action = "ru.fromchat.NOTIFICATION_REPLY"
action = notificationReplyAction(context)
putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
@@ -30,6 +30,8 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config
import ru.fromchat.ui.chat.PublicChatPanelCache
import ru.fromchat.ui.dm.DmPanelCache
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds
@@ -446,6 +448,8 @@ object ApiClient {
token = null
user = null
runCatching { ProfileCache.clear() }
runCatching { DmPanelCache.clearAll() }
runCatching { PublicChatPanelCache.clear() }
}
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
@@ -2,8 +2,10 @@ package ru.fromchat.api.db
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmConversation
import ru.fromchat.api.Message
import ru.fromchat.api.ProfileCache
import ru.fromchat.db.Conversation
import ru.fromchat.db.MessageDatabase
import ru.fromchat.db.Message as DbMessage
@@ -28,12 +30,23 @@ object MessageCacheStore {
private fun conversationIdForPublic(): String = "public"
private fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId"
/**
* Full history for a conversation (unbounded). Prefer [loadRecentPublicMessages] when opening UI.
*/
suspend fun loadPublicMessages(): List<Message> {
return loadMessages(conversationIdForPublic())
}
/**
* Most recent [limit] messages for public chat, chronological (oldest → newest).
* Avoids reading the entire `"public"` thread from SQLite when the cache has grown large.
*/
suspend fun loadRecentPublicMessages(limit: Long): List<Message> {
return loadRecentMessages(conversationIdForPublic(), limit)
}
suspend fun replacePublicMessages(messages: List<Message>) {
val pending = loadPublicMessages().filter { it.id < 0 }
val pending = loadPendingMessages(conversationIdForPublic())
val stillPending = pending.filter { p ->
val cid = p.client_message_id
cid == null || messages.none { it.client_message_id == cid }
@@ -56,7 +69,7 @@ object MessageCacheStore {
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
val convId = conversationIdForDm(otherUserId)
val pending = loadDmMessages(otherUserId).filter { it.id < 0 }
val pending = loadPendingMessages(convId)
val stillPending = pending.filter { p ->
val cid = p.client_message_id
cid == null || messages.none { it.client_message_id == cid }
@@ -145,24 +158,56 @@ object MessageCacheStore {
db.messageDatabaseQueries
.selectMessagesByConversation(conversationId)
.executeAsList()
.map { row: DbMessage ->
Message(
id = row.id.toInt(),
user_id = row.userId.toInt(),
content = row.content,
timestamp = row.timestamp,
is_read = row.isRead != 0L,
is_edited = row.isEdited != 0L,
username = "", // Filled from network; cache focuses on content & ordering.
profile_picture = null,
verified = null,
.map { row: DbMessage -> row.toAppMessage() }
}
private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> =
withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectRecentMessagesByConversation(conversationId, limit)
.executeAsList()
.map { row: DbMessage -> row.toAppMessage() }
.reversed()
}
private suspend fun loadPendingMessages(conversationId: String): List<Message> =
withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectPendingMessagesByConversation(conversationId)
.executeAsList()
.map { row: DbMessage -> row.toAppMessage() }
}
private fun DbMessage.toAppMessage(): Message {
val uid = userId.toInt()
val self = ApiClient.user
val profile = ProfileCache.get(uid)
val usernameResolved = when {
self != null && uid == self.id -> self.username
else -> profile?.username?.takeIf { it.isNotBlank() }
?: profile?.displayName?.takeIf { it.isNotBlank() }
?: ""
}
val pictureResolved = when {
self != null && uid == self.id -> self.profile_picture
else -> profile?.profilePicture?.takeIf { it.isNotBlank() }
}
return Message(
id = id.toInt(),
user_id = uid,
content = content,
timestamp = timestamp,
is_read = isRead != 0L,
is_edited = isEdited != 0L,
username = usernameResolved,
profile_picture = pictureResolved,
verified = profile?.verified,
reply_to = null,
client_message_id = row.clientMessageId,
client_message_id = clientMessageId,
reactions = null,
files = null
)
}
}
private suspend fun replaceMessages(conversationId: String, messages: List<Message>) {
withContext(Dispatchers.Default) {
@@ -2,6 +2,7 @@ package ru.fromchat.ui
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -18,9 +19,11 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UpdateSyncManager
@@ -93,6 +96,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}
FromChatTheme {
SharedTransitionLayout {
val navController = rememberNavController()
// Handle navigation to public chat when requested (e.g., from notification)
@@ -104,8 +108,6 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}
}
// Set up global auth error handler
LaunchedEffect(navController) {
ApiClient.onAuthError = {
@@ -187,19 +189,43 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}
composable("chats/publicChat") {
PublicChatScreen(scrollToMessageId = scrollToMessageId)
PublicChatScreen(
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
)
}
composable("debug") {
DebugApiScreen()
}
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.savedStateHandle.get<String>("userId")?.toIntOrNull()
composable(
route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("useSharedElement") {
type = NavType.StringType
defaultValue = "false"
},
navArgument("sourceMessageId") {
type = NavType.StringType
defaultValue = "-1"
}
)
) { backStackEntry ->
val handle = backStackEntry.savedStateHandle
val userId = handle.get<String>("userId")?.toIntOrNull()
val useSharedElement = handle.get<String>("useSharedElement") == "true"
val sourceMessageId = handle.get<String>("sourceMessageId")?.toIntOrNull() ?: -1
ProfileScreen(
userId = userId,
onBack = { navController.navigateUp() },
onChat = { navController.navigate("dm/$it") }
onChat = { navController.navigate("dm/$it") },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
useSharedElementFromNavigation = useSharedElement,
sharedSourceMessageId = sourceMessageId
)
}
@@ -218,4 +244,5 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}
}
}
}
}
@@ -64,14 +64,13 @@ fun Avatar(
if (imageLoadFailed || profilePictureUrl == null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = size.minDimension / 2f
// Background gradient circle
drawCircle(
brush = gradient,
radius = radius,
center = center
)
// Letters sized relative to radius
if (initials.isNotBlank()) {
val fontPx = radius * 0.7f
val fontSp = (fontPx / density).sp
@@ -98,5 +97,6 @@ fun Avatar(
}
}
}
}
}
@@ -79,19 +79,19 @@ fun generateGradientFromName(name: String): Brush {
* Get initials from display name (first 2 words, first letter of each)
*/
fun getInitials(displayName: String): String {
val words = displayName.trim().split("\\s+".toRegex())
val words = displayName.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }
return when {
words.isEmpty() -> "?"
words.isEmpty() -> ""
words.size == 1 -> {
val word = words[0]
if (word.length >= 2) {
word.take(2).uppercase()
} else {
word.uppercase() + "?"
(word + word).take(2).uppercase()
}
}
else -> {
words.take(2).joinToString("") { it.firstOrNull()?.uppercase() ?: "" }
words.take(2).joinToString("") { it.firstOrNull()?.uppercaseChar()?.toString() ?: "" }
}
}
}
@@ -51,6 +51,8 @@ abstract class ChatPanel(
private val pendingMessages = mutableMapOf<String, Pair<Job, Message>>()
private var onStateChange: ((ChatPanelState) -> Unit)? = null
private var batchDepth: Int = 0
private var pendingBatchedState: ChatPanelState? = null
/**
* Set state change callback
@@ -74,12 +76,43 @@ abstract class ChatPanel(
val newState = _state.copy()
Logger.d("ChatPanel", "State updated: messages=${newState.messages.size}, callback=${callback != null}")
if (callback != null) {
if (batchDepth > 0) {
pendingBatchedState = newState
} else {
scope.launch(Dispatchers.Main) {
Logger.d("ChatPanel", "Calling state change callback with ${newState.messages.size} messages")
callback(newState)
}
}
}
}
/**
* Coalesce multiple [updateState] calls into a single [onStateChange] delivery (last state wins).
* Use for bulk loads so the main thread is not spammed with recompositions.
*/
protected suspend fun <R> batchStateUpdates(block: suspend () -> R): R {
batchDepth++
try {
return block()
} finally {
batchDepth--
if (batchDepth == 0) {
val callback = onStateChange
val stateToSend = pendingBatchedState
pendingBatchedState = null
if (callback != null && stateToSend != null) {
scope.launch(Dispatchers.Main) {
Logger.d(
"ChatPanel",
"Calling batched state change callback with ${stateToSend.messages.size} messages"
)
callback(stateToSend)
}
}
}
}
}
private val addMessageMutex = Mutex()
@@ -50,6 +50,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
@@ -126,11 +127,6 @@ fun ChatScreen(
panelState = panel.getState()
}
// Debug: Log state changes
LaunchedEffect(panelState.messages.size) {
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
}
LaunchedEffect(panelState.titleAvatar) {
onTitleAvatarChange?.invoke(panelState.titleAvatar)
}
@@ -141,7 +137,9 @@ fun ChatScreen(
val haptic = rememberHapticFeedback()
val navController = LocalNavController.current
val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState(blurEnabled = true)
val hazeState = rememberHazeState(
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState()
@@ -171,10 +169,9 @@ fun ChatScreen(
val messageIndex = messages.indexOfFirst { it.id == messageId }
if (messageIndex != -1) {
scope.launch {
listState.animateScrollToItem(
index = messages.size - 1 - messageIndex,
scrollOffset = 0
)
// reverseLayout list: index 0 = bottom spacer, 1..n = newest..oldest
val lazyIndex = 1 + (messages.size - 1 - messageIndex)
listState.animateScrollToItem(index = lazyIndex, scrollOffset = 0)
}
}
}
@@ -270,32 +267,30 @@ fun ChatScreen(
}
// Scroll to bottom when new messages arrive.
// - Initial composition: jump (no animation) to avoid jank.
// - Subsequent messages: always scroll when we sent (last message is ours); otherwise only if near bottom.
// - Scroll to last item (totalItemsCount - 1) so new message appears at bottom; delay to allow layout.
// LazyColumn uses reverseLayout + chronological messages asReversed(): index 0 is bottom inset, 1..n newest→oldest.
// - Initial: after one frame, scrollToItem(0) so the first list composition/layout is not merged with scroll in one VSYNC.
// - Later: same anchor; "near bottom" = smallest visible index is near 0.
var didInitialScroll by remember(panel) { mutableStateOf(false) }
LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) {
if (panelState.messages.isEmpty()) return@LaunchedEffect
val lastMessage = panelState.messages.lastOrNull()
val lastIsOurs = lastMessage?.user_id == currentUserId
val totalItems = 2 + panelState.messages.size // top spacer + messages + bottom spacer
val lastIndex = totalItems - 1
if (!didInitialScroll) {
didInitialScroll = true
listState.scrollToItem(lastIndex)
withFrameNanos { }
listState.scrollToItem(0)
return@LaunchedEffect
}
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val isNearBottom = lastVisibleIndex >= (totalItems - 3)
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } ?: Int.MAX_VALUE
val isNearBottom = minVisibleIndex <= 2
if (lastIsOurs || isNearBottom) {
delay(100) // Allow new item to be composed and laid out
listState.animateScrollToItem(lastIndex)
// Re-scroll after layout may change (e.g. aspect ratio update)
delay(100)
listState.animateScrollToItem(0)
delay(150)
listState.animateScrollToItem(lastIndex)
listState.animateScrollToItem(0)
}
}
@@ -340,7 +335,7 @@ fun ChatScreen(
val avatar = panelState.titleAvatar
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
?: panelState.title.takeIf { it.isNotBlank() }
?: "?"
?: ""
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = avatar?.profilePictureUrl,
@@ -626,21 +621,23 @@ fun ChatScreen(
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState),
userScrollEnabled = !contextMenuState.isOpen,
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
items(
items = panelState.messages,
items = panelState.messages.asReversed(),
key = { msg ->
msg.client_message_id ?: "id_${msg.id}_${msg.timestamp}"
}
) { message ->
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
Box(modifier = Modifier.hazeSource(hazeState)) {
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
@@ -664,7 +661,10 @@ fun ChatScreen(
) {
{
ProfileCache.mergePreviewFromPublicMessage(message)
navController.navigate("profile/${message.user_id}")
navController.navigate(
"profile/${message.user_id}" +
"?useSharedElement=true&sourceMessageId=${message.id}"
)
}
} else {
null
@@ -676,12 +676,28 @@ fun ChatScreen(
expandedImageKey = expandedImageKey,
isImageClosing = isImageClosing,
showUsername = panel.showUsernamesInMessages,
currentUserId = currentUserId
currentUserId = currentUserId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarNavKey =
if (
panel.supportsNavigateToSenderProfile &&
sharedTransitionScope != null &&
animatedVisibilityScope != null &&
message.user_id != currentUserId &&
message.user_id > 0
) {
publicChatProfileSharedAvatarKey(
message.user_id,
message.id
)
} else {
null
}
)
}
}
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
}
}
@@ -1,14 +1,10 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -86,20 +82,22 @@ fun MessageItem(
expandedImageKey: String? = null,
isImageClosing: Boolean = false,
isContextMenuOpen: Boolean = false,
isContextMenuForThisMessage: Boolean = false
isContextMenuForThisMessage: Boolean = false,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarNavKey: String? = null
) {
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300)) + slideInVertically(
initialOffsetY = { 20 },
animationSpec = tween(300)
),
exit = fadeOut(animationSpec = tween(200)) + slideOutVertically(
targetOffsetY = { -10 },
animationSpec = tween(200)
),
modifier = modifier
) {
// Cache derived values per message to avoid recomputing in every recomposition.
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
isMessageCorrupted(message)
}
val formattedTime = remember(message.timestamp) {
formatTime(message.timestamp)
}
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
// composition (N messages ⇒ N concurrent animations + huge JIT), causing main-thread jank.
var isPressed by remember { mutableStateOf(false) }
var avatarPressed by remember(message.id) { mutableStateOf(false) }
var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) }
@@ -125,7 +123,7 @@ fun MessageItem(
)
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
@@ -153,12 +151,30 @@ fun MessageItem(
)
}
) {
val navSharedKey = sharedAvatarNavKey
val navStScope = sharedTransitionScope
val navVisScope = animatedVisibilityScope
if (navSharedKey != null && navStScope != null && navVisScope != null) {
with(navStScope) {
Avatar(
profilePictureUrl = message.profile_picture,
displayName = message.username,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = navSharedKey),
animatedVisibilityScope = navVisScope
)
.size(32.dp)
)
}
} else {
Avatar(
profilePictureUrl = message.profile_picture,
displayName = message.username,
modifier = Modifier.size(32.dp)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
}
@@ -347,7 +363,7 @@ fun MessageItem(
}
// Attachments (images/files) or corrupted message
if (isMessageCorrupted(message)) {
if (isCorrupted) {
Text(
text = "_This message is corrupted and cannot be displayed.",
style = MaterialTheme.typography.bodyMedium,
@@ -455,7 +471,7 @@ fun MessageItem(
)
}
}
if (message.content.isNotBlank() && !isMessageCorrupted(message)) {
if (message.content.isNotBlank() && !isCorrupted) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
@@ -489,7 +505,7 @@ fun MessageItem(
Spacer(modifier = Modifier.width(6.dp))
}
Text(
text = formatTime(message.timestamp),
text = formattedTime,
style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp,
color = if (isAuthor) {
@@ -519,7 +535,6 @@ fun MessageItem(
}
}
}
}
}
@ExperimentalTime
@@ -1,7 +1,9 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.MessageDeletedData
@@ -24,6 +26,25 @@ class PublicChatPanel(
private val typingHandler = PublicChatTypingHandler(scope)
private var messagesLoaded = false
/**
* Whether replacing the list would change **structure or message body** (content / edited).
* Intentionally ignores username, avatar, reactions, read, verified: cache vs API often differ
* there while ids + text match; comparing those forced a useless clear/re-add and JIT spike.
*/
private fun publicHistoryDiffersForUi(shown: List<Message>, fromNetwork: List<Message>): Boolean {
val order = compareBy<Message> { it.timestamp }.thenBy { it.id }
val a = shown.sortedWith(order)
val b = fromNetwork.sortedWith(order)
if (a.size != b.size) return true
for (i in a.indices) {
val x = a[i]
val y = b[i]
if (x.id != y.id) return true
if (x.content != y.content || x.is_edited != y.is_edited) return true
}
return false
}
override val supportsNavigateToSenderProfile: Boolean
get() = true
@@ -49,50 +70,83 @@ class PublicChatPanel(
}
override suspend fun persistOptimisticMessage(message: Message) {
withContext(Dispatchers.Default) {
MessageCacheStore.upsertPublicMessage(message)
}
}
override suspend fun removeOptimisticFromCache(message: Message) {
val cid = message.client_message_id ?: return
withContext(Dispatchers.Default) {
MessageCacheStore.deletePublicMessageByClientMessageId(cid)
}
}
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
withContext(Dispatchers.Default) {
MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed)
}
}
override suspend fun loadMessages() {
if (messagesLoaded) return
setLoading(true)
try {
// First, try to show cached messages immediately for offline / fast startup.
runCatching {
val cached = MessageCacheStore.loadPublicMessages()
if (cached.isNotEmpty()) {
clearMessages()
cached.forEach { message ->
addMessage(message)
messagesLoaded = true
// 1) Read cache off main first. Do NOT setLoading(true) before this: that forced an extra
// frame (spinner + msgs=0) and a second heavy recomposition before the cached list applied.
val cached = withContext(Dispatchers.Default) {
// Bounded read: public conversation can accumulate many rows; SQLDelight is thin — the cost is SQLite I/O.
runCatching { MessageCacheStore.loadRecentPublicMessages(limit = 128) }.getOrDefault(emptyList())
}
if (cached.isNotEmpty()) {
withContext(Dispatchers.Main) {
batchStateUpdates {
clearMessages()
addMessages(cached)
setLoading(false)
}
}
} else {
withContext(Dispatchers.Main) {
setLoading(true)
}
}
// Then refresh from network when available.
val response = ApiClient.getMessages(limit = 50)
if (response.messages.isNotEmpty()) {
clearMessages()
response.messages.forEach { message ->
addMessage(message)
// 2) Refresh from network; this may be fast or slow, but runs entirely off main.
val response = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) }.getOrNull()
}
// Persist fresh messages to cache for offline use.
if (response != null && response.messages.isNotEmpty()) {
withContext(Dispatchers.Main) {
val shown = _state.messages
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
} else {
batchStateUpdates {
clearMessages()
addMessages(response.messages)
setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false)
}
}
}
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(response.messages)
}
setHasMoreMessages(false) // TODO: Implement has_more from API
messagesLoaded = true
} catch (_: Exception) {
// Keep whatever cached state we have; no-op on error.
} finally {
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)
}
} else {
// We already displayed cached messages; just mark pagination state.
withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
}
}
@@ -105,7 +159,9 @@ class PublicChatPanel(
val oldestMessage = messages.first()
setLoadingMore(true)
try {
val response = ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
val response = withContext(Dispatchers.Default) {
ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
}
if (response.messages.isNotEmpty()) {
// Prepend older messages (they come in reverse chronological order)
updateState { currentState ->
@@ -141,25 +197,28 @@ class PublicChatPanel(
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { editedMsg }
// Update cache to reflect edit
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
}
}
"messageDeleted" -> {
val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id)
// Mark deleted in cache
withContext(Dispatchers.Default) {
MessageCacheStore.markMessageDeleted("public", deletedData.message_id)
MessageCacheStore.replacePublicMessages(_state.messages)
}
}
"reactionUpdate" -> {
val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
handleReactionUpdate(reactionUpdate)
// Re-write cache so reactions are updated
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
}
}
"typing" -> {
val data = updateMessage.data ?: return
val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
@@ -0,0 +1,62 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* Single retained [PublicChatPanel] for the app session (same idea as [ru.fromchat.ui.dm.DmPanelCache]).
* Navigating away from public chat used to dispose [remember] and recreate the panel, so every open
* paid for cache + network + full list layout again; DM reuses cached panels and only [loadMessages]
* when the list is still empty.
*
* Uses a [SupervisorJob] scope instead of [androidx.compose.runtime.rememberCoroutineScope] so typing
* collectors and [ChatPanel] state callbacks keep working after the composable is left and re-entered.
*/
object PublicChatPanelCache {
private const val GENERAL_CHAT_NAME = "General Chat"
private var supervisorJob = SupervisorJob()
private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate)
private var panel: PublicChatPanel? = null
private var cachedChatName: String? = null
private var cachedUserId: Int? = null
private fun ensureScope() {
if (!supervisorJob.isActive) {
supervisorJob = SupervisorJob()
panelScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate)
}
}
fun getOrCreateGeneralChat(currentUserId: Int?): PublicChatPanel =
getOrCreate(GENERAL_CHAT_NAME, currentUserId)
fun getOrCreate(chatName: String, currentUserId: Int?): PublicChatPanel {
ensureScope()
if (
panel != null &&
cachedChatName == chatName &&
cachedUserId == currentUserId
) {
return panel!!
}
panel?.destroy()
panel = PublicChatPanel(
chatName = chatName,
currentUserId = currentUserId,
scope = panelScope
)
cachedChatName = chatName
cachedUserId = currentUserId
return panel!!
}
fun clear() {
panel?.destroy()
panel = null
cachedChatName = null
cachedUserId = null
supervisorJob.cancel()
}
}
@@ -0,0 +1,9 @@
package ru.fromchat.ui.chat
/**
* Stable shared-element keys for NavHost predictive backcompatible transitions from
* a public-chat message row to [ru.fromchat.ui.profile.ProfileScreen].
* One key per message so LazyColumn rows never duplicate keys for the same user.
*/
fun publicChatProfileSharedAvatarKey(userId: Int, sourceMessageId: Int): String =
"public-chat-profile-avatar-$userId-$sourceMessageId"
@@ -1,31 +1,29 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.ui.isPublicChatVisible
@Composable
fun PublicChatScreen(scrollToMessageId: Int? = null) {
val scope = rememberCoroutineScope()
fun PublicChatScreen(
scrollToMessageId: Int? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
) {
val currentUserId = ApiClient.user?.id
// Create panel instance
val panel = remember {
PublicChatPanel(
chatName = "General Chat",
currentUserId = currentUserId,
scope = scope
)
// Reuse one panel for the session (like DM [DmPanelCache]); avoids full reload on every visit.
val panel = remember(currentUserId) {
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
}
// Load messages on first appear
LaunchedEffect(Unit) {
scope.launch {
LaunchedEffect(panel) {
if (panel.getState().messages.isEmpty()) {
panel.loadMessages()
}
}
@@ -42,7 +40,8 @@ fun PublicChatScreen(scrollToMessageId: Int? = null) {
ChatScreen(
panel = panel,
currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedContentScope
)
}
@@ -26,4 +26,9 @@ object DmPanelCache {
fun remove(otherUserId: Int) {
panels.remove(otherUserId)?.destroy()
}
fun clearAll() {
panels.values.forEach { it.destroy() }
panels.clear()
}
}
@@ -73,6 +73,7 @@ import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey
import ru.fromchat.ui.scaleOnPress
private data class ProfileUiState(
@@ -100,6 +101,9 @@ fun ProfileScreen(
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarKey: Any? = null,
/** When true (Nav from public chat), [sharedSourceMessageId] pairs with [targetUserId] for the avatar key. */
useSharedElementFromNavigation: Boolean = false,
sharedSourceMessageId: Int = -1,
initialDisplayName: String? = null,
onOpenSettings: () -> Unit = {}
) {
@@ -188,9 +192,17 @@ fun ProfileScreen(
?: initialDisplayName?.takeIf { it.isNotBlank() }
?: "?"
val navSharedAvatarKey =
if (useSharedElementFromNavigation && targetUserId != null && sharedSourceMessageId != -1) {
publicChatProfileSharedAvatarKey(targetUserId, sharedSourceMessageId)
} else {
null
}
val effectiveSharedAvatarKey: Any? = sharedAvatarKey ?: navSharedAvatarKey
val useSharedAvatar = sharedTransitionScope != null &&
animatedVisibilityScope != null &&
sharedAvatarKey != null
effectiveSharedAvatarKey != null
Column(
modifier = Modifier
@@ -201,15 +213,18 @@ fun ProfileScreen(
) {
when {
useSharedAvatar -> {
with(sharedTransitionScope) {
val sharedKey = checkNotNull(effectiveSharedAvatarKey)
val stScope = checkNotNull(sharedTransitionScope)
val visScope = checkNotNull(animatedVisibilityScope)
with(stScope) {
Avatar(
profilePictureUrl = profile?.profilePicture,
displayName = displayName,
modifier = Modifier
.padding(top = 16.dp)
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope
rememberSharedContentState(key = sharedKey),
animatedVisibilityScope = visScope
)
.size(128.dp)
)
@@ -44,6 +44,21 @@ FROM message
WHERE conversationId = ? AND deletedFlag = 0
ORDER BY timestamp ASC;
-- Last N rows for a conversation (newest first in SQL; reverse in Kotlin for chronological UI).
selectRecentMessagesByConversation:
SELECT *
FROM message
WHERE conversationId = ? AND deletedFlag = 0
ORDER BY timestamp DESC
LIMIT :limit;
-- Optimistic rows only (avoid scanning the full conversation on replace).
selectPendingMessagesByConversation:
SELECT *
FROM message
WHERE conversationId = ? AND deletedFlag = 0 AND id < 0
ORDER BY timestamp ASC;
deleteMessagesForConversation:
DELETE FROM message
WHERE conversationId = ?;