diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 17b9834..1c4b199 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -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 repo’s 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 repo’s 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. diff --git a/.gitignore b/.gitignore index 76b9f2e..608871c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,9 @@ build local.properties app/android/keys -releases -release +/releases +app/android/release +app/android/debug *.xcuserstate xcuserdata diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index 079fe13..b9a04cf 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -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 { } signingConfigs { - create("release") { - val keystoreProperties = Properties().apply { - load(FileInputStream(file("keys/keystore.properties"))) - } + val keystoreProperties = Properties().apply { + 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 diff --git a/app/android/src/debug/res/values/strings.xml b/app/android/src/debug/res/values/strings.xml new file mode 100644 index 0000000..132c226 --- /dev/null +++ b/app/android/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + FromChat Beta + diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index 6474f42..c33c8ce 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -39,7 +39,7 @@ android:name=".notifications.NotificationReplyReceiver" android:exported="false"> - + diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt index 6c3cc9d..c97debf 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 3af52c0..1af7500 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -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") diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt index 3392323..bfdfd06 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt @@ -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 { 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 { + return loadRecentMessages(conversationIdForPublic(), limit) + } + suspend fun replacePublicMessages(messages: List) { - 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) { 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,25 +158,57 @@ 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, - reply_to = null, - client_message_id = row.clientMessageId, - reactions = null, - files = null - ) - } + .map { row: DbMessage -> row.toAppMessage() } } + private suspend fun loadRecentMessages(conversationId: String, limit: Long): List = + withContext(Dispatchers.Default) { + db.messageDatabaseQueries + .selectRecentMessagesByConversation(conversationId, limit) + .executeAsList() + .map { row: DbMessage -> row.toAppMessage() } + .reversed() + } + + private suspend fun loadPendingMessages(conversationId: String): List = + 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 = clientMessageId, + reactions = null, + files = null + ) + } + private suspend fun replaceMessages(conversationId: String, messages: List) { withContext(Dispatchers.Default) { db.messageDatabaseQueries.transaction { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index dedaa6b..e49a836 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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,64 +96,63 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { } FromChatTheme { - val navController = rememberNavController() + SharedTransitionLayout { + val navController = rememberNavController() - // Handle navigation to public chat when requested (e.g., from notification) - LaunchedEffect(startAtPublicChat) { - if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { - navController.navigate("chats/publicChat") { - launchSingleTop = true - } - } - } - - - - // Set up global auth error handler - LaunchedEffect(navController) { - ApiClient.onAuthError = { - ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login") - navController.navigate("login") { - popUpTo("chat") { inclusive = true } - } - } - } - - CompositionLocalProvider( - LocalNavController provides navController, - LocalSystemBarsVisibility provides rememberSystemBarsController() - ) { - if (startDestination != null) { - val animationSpec = tween(400) - - NavHost( - navController = navController, - startDestination = startDestination!!, - enterTransition = { - slideIntoContainer( - Start, - animationSpec = animationSpec - ) - }, - exitTransition = { - slideOutOfContainer( - Start, - animationSpec = animationSpec - ) - }, - popEnterTransition = { - slideIntoContainer( - End, - animationSpec = animationSpec - ) - }, - popExitTransition = { - slideOutOfContainer( - End, - animationSpec = animationSpec - ) + // Handle navigation to public chat when requested (e.g., from notification) + LaunchedEffect(startAtPublicChat) { + if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { + navController.navigate("chats/publicChat") { + launchSingleTop = true } - ) { + } + } + + // Set up global auth error handler + LaunchedEffect(navController) { + ApiClient.onAuthError = { + ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login") + navController.navigate("login") { + popUpTo("chat") { inclusive = true } + } + } + } + + CompositionLocalProvider( + LocalNavController provides navController, + LocalSystemBarsVisibility provides rememberSystemBarsController() + ) { + if (startDestination != null) { + val animationSpec = tween(400) + + NavHost( + navController = navController, + startDestination = startDestination!!, + enterTransition = { + slideIntoContainer( + Start, + animationSpec = animationSpec + ) + }, + exitTransition = { + slideOutOfContainer( + Start, + animationSpec = animationSpec + ) + }, + popEnterTransition = { + slideIntoContainer( + End, + animationSpec = animationSpec + ) + }, + popExitTransition = { + slideOutOfContainer( + End, + animationSpec = animationSpec + ) + } + ) { composable("serverConfig") { ServerConfigScreen() } @@ -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("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("userId")?.toIntOrNull() + val useSharedElement = handle.get("useSharedElement") == "true" + val sourceMessageId = handle.get("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 ) } @@ -214,6 +240,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { composable("about") { AboutScreen() } + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt index 52b2149..f3df4db 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt @@ -64,37 +64,37 @@ 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 - val fontPx = radius * 0.7f - val fontSp = (fontPx / density).sp + if (initials.isNotBlank()) { + val fontPx = radius * 0.7f + val fontSp = (fontPx / density).sp - val textLayout = textMeasurer.measure( - text = initials, - style = TextStyle( - color = Color.White, - fontSize = fontSp, - fontWeight = FontWeight.SemiBold + val textLayout = textMeasurer.measure( + text = initials, + style = TextStyle( + color = Color.White, + fontSize = fontSp, + fontWeight = FontWeight.SemiBold + ) ) - ) - val textWidth = textLayout.size.width.toFloat() - val textHeight = textLayout.size.height.toFloat() - val topLeft = Offset( - x = (size.width - textWidth) / 2f, - y = (size.height - textHeight) / 2f - ) + val textWidth = textLayout.size.width.toFloat() + val textHeight = textLayout.size.height.toFloat() + val topLeft = Offset( + x = (size.width - textWidth) / 2f, + y = (size.height - textHeight) / 2f + ) - drawText( - textLayoutResult = textLayout, - topLeft = topLeft - ) + drawText( + textLayoutResult = textLayout, + topLeft = topLeft + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt index bc9ad21..618a905 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt @@ -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() ?: "" } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 36df280..b50589a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -51,6 +51,8 @@ abstract class ChatPanel( private val pendingMessages = mutableMapOf>() private var onStateChange: ((ChatPanelState) -> Unit)? = null + private var batchDepth: Int = 0 + private var pendingBatchedState: ChatPanelState? = null /** * Set state change callback @@ -74,9 +76,40 @@ abstract class ChatPanel( val newState = _state.copy() Logger.d("ChatPanel", "State updated: messages=${newState.messages.size}, callback=${callback != null}") if (callback != null) { - scope.launch(Dispatchers.Main) { - Logger.d("ChatPanel", "Calling state change callback with ${newState.messages.size} messages") - callback(newState) + 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 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) + } + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 2ba0530..1aeff25 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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,62 +621,83 @@ 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, - isContextMenuOpen = contextMenuState.isOpen, - isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id, - onLongPress = { - haptic(HapticFeedbackEvent.ContextMenuOpened) - contextMenuState = ContextMenuState( - isOpen = true, - message = message, - position = tapPositionInRoot + MessageItem( + message = message, + isAuthor = message.user_id == currentUserId, + isContextMenuOpen = contextMenuState.isOpen, + isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id, + onLongPress = { + haptic(HapticFeedbackEvent.ContextMenuOpened) + contextMenuState = ContextMenuState( + isOpen = true, + message = message, + position = tapPositionInRoot + ) + }, + onTapPosition = { offset -> + tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt()) + }, + onUsernameClick = + if (panel.supportsNavigateToSenderProfile && + message.user_id != currentUserId && + message.user_id > 0 + ) { + { + ProfileCache.mergePreviewFromPublicMessage(message) + navController.navigate( + "profile/${message.user_id}" + + "?useSharedElement=true&sourceMessageId=${message.id}" + ) + } + } else { + null + }, + onImageClick = { msg, idx -> expandedImage = msg to idx }, + onImageBounds = { key, rect -> + imageThumbBounds[key] = rect + }, + expandedImageKey = expandedImageKey, + isImageClosing = isImageClosing, + showUsername = panel.showUsernamesInMessages, + 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 ) - }, - onTapPosition = { offset -> - tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt()) - }, - onUsernameClick = - if (panel.supportsNavigateToSenderProfile && - message.user_id != currentUserId && - message.user_id > 0 - ) { - { - ProfileCache.mergePreviewFromPublicMessage(message) - navController.navigate("profile/${message.user_id}") - } - } else { - null - }, - onImageClick = { msg, idx -> expandedImage = msg to idx }, - onImageBounds = { key, rect -> - imageThumbBounds[key] = rect - }, - expandedImageKey = expandedImageKey, - isImageClosing = isImageClosing, - showUsername = panel.showUsernamesInMessages, - currentUserId = currentUserId - ) - } + } else { + null + } + ) } - item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } + item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index e34e56e..79f0d5e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -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,410 +82,442 @@ 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 - ) { - var isPressed by remember { mutableStateOf(false) } - var avatarPressed by remember(message.id) { mutableStateOf(false) } - var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) } - val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f - val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f - val scale by animateFloatAsState( - targetValue = scaleTarget, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow - ), - visibilityThreshold = 0.001f, - label = "messageBubbleScale" - ) - val avatarScale by animateFloatAsState( - targetValue = avatarScaleTarget, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow - ), - visibilityThreshold = 0.001f, - label = "messageAvatarScale" - ) + // 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) + } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, - verticalAlignment = Alignment.Bottom - ) { - if (!isAuthor && showUsername) { - Box( - modifier = Modifier - .graphicsLayer( - scaleX = avatarScale, - scaleY = avatarScale, - transformOrigin = TransformOrigin.Center + // 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) } + val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f + val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f + val scale by animateFloatAsState( + targetValue = scaleTarget, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ), + visibilityThreshold = 0.001f, + label = "messageBubbleScale" + ) + val avatarScale by animateFloatAsState( + targetValue = avatarScaleTarget, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ), + visibilityThreshold = 0.001f, + label = "messageAvatarScale" + ) + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + if (!isAuthor && showUsername) { + Box( + modifier = Modifier + .graphicsLayer( + scaleX = avatarScale, + scaleY = avatarScale, + transformOrigin = TransformOrigin.Center + ) + .pointerInput(onUsernameClick, isContextMenuOpen) { + detectTapGestures( + onPress = { + if (!isContextMenuOpen) avatarPressed = true + try { + awaitRelease() + } finally { + if (!isContextMenuOpen) avatarPressed = false + } + }, + onTap = { onUsernameClick?.invoke() } ) - .pointerInput(onUsernameClick, isContextMenuOpen) { - detectTapGestures( - onPress = { - if (!isContextMenuOpen) avatarPressed = true - try { - awaitRelease() - } finally { - if (!isContextMenuOpen) avatarPressed = false - } - }, - onTap = { onUsernameClick?.invoke() } - ) - } - ) { + } + ) { + 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)) } - BoxWithConstraints( - modifier = Modifier.weight(1f, fill = false) + Spacer(modifier = Modifier.width(8.dp)) + } + + BoxWithConstraints( + modifier = Modifier.weight(1f, fill = false) + ) { + // Allow bubbles to grow up to 70% of the available row width + val maxBubbleWidth = maxWidth * 0.7f + + Column( + horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start ) { - // Allow bubbles to grow up to 70% of the available row width - val maxBubbleWidth = maxWidth * 0.7f - - Column( - horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start - ) { - // Message bubble - val isDark = isSystemInDarkTheme() - val pendingIsImage = when { - message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename) - message.pendingFileUri != null -> isImageFilename( - message.pendingFileUri.substringAfterLast('/').substringBefore('?') - ) - else -> false - } - val firstContentIsImage = ( - !showUsername || isAuthor - ) && message.reply_to == null && ( - pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true + // Message bubble + val isDark = isSystemInDarkTheme() + val pendingIsImage = when { + message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename) + message.pendingFileUri != null -> isImageFilename( + message.pendingFileUri.substringAfterLast('/').substringBefore('?') ) + else -> false + } + val firstContentIsImage = ( + !showUsername || isAuthor + ) && message.reply_to == null && ( + pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true + ) - val bubbleShape = RoundedCornerShape( - topStart = 20.dp, - topEnd = 20.dp, - bottomStart = if (isAuthor) 20.dp else 8.dp, - bottomEnd = if (isAuthor) 8.dp else 20.dp - ) + val bubbleShape = RoundedCornerShape( + topStart = 20.dp, + topEnd = 20.dp, + bottomStart = if (isAuthor) 20.dp else 8.dp, + bottomEnd = if (isAuthor) 8.dp else 20.dp + ) - val bubbleBodyGestures = - if (isContextMenuOpen) Modifier - else Modifier.pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed = true - try { - awaitRelease() - } finally { - isPressed = false - } - }, - onLongPress = { offset -> - onTapPosition(bubbleBodyPositionInRoot + offset) - onLongPress() + val bubbleBodyGestures = + if (isContextMenuOpen) Modifier + else Modifier.pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false } + }, + onLongPress = { offset -> + onTapPosition(bubbleBodyPositionInRoot + offset) + onLongPress() + } + ) + } + + Box( + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .clip(bubbleShape) + .conditional( + isAuthor, + `if` = { + it + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape( + topStart = 20.dp, + topEnd = 20.dp, + bottomStart = 20.dp, + bottomEnd = 8.dp + ), + spotColor = if (isDark) Color(0x66000000) else Color(0x33000000) + ) + .background(getMessageGradient(isDark)) + }, + `else` = { + background(MaterialTheme.colorScheme.surfaceContainerHighest) + } + ) + .graphicsLayer( + scaleX = scale, + scaleY = scale, + transformOrigin = TransformOrigin.Center + ) + .padding(top = if (firstContentIsImage) 0.dp else 6.dp) + ) { + val bubbleColumnModifier = + if (showUsername && !isAuthor) Modifier.width(IntrinsicSize.Max) + else Modifier + Column(modifier = bubbleColumnModifier) { + if (showUsername && !isAuthor) { + val usernameShape = RoundedCornerShape(6.dp) + val usernameOutset = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp) + val usernameInset = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + if (onUsernameClick != null) { + val usernameInteraction = remember(message.id) { MutableInteractionSource() } + Box( + modifier = usernameOutset + .clip(usernameShape) + .clickable( + interactionSource = usernameInteraction, + indication = LocalIndication.current, + onClick = onUsernameClick + ) + ) { + Text( + text = message.username, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = usernameInset + ) + } + } else { + Box(modifier = usernameOutset) { + Text( + text = message.username, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = usernameInset + ) + } + } + } + + val gestureWidthModifier = + if (showUsername && !isAuthor) Modifier.fillMaxWidth() + else Modifier + Box( + modifier = gestureWidthModifier + .onGloballyPositioned { coordinates -> + bubbleBodyPositionInRoot = coordinates.positionInRoot() + } + .then(bubbleBodyGestures) + ) { + Column { + // Reply preview + message.reply_to?.let { replyTo -> + Box( + Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp) + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .fillMaxWidth() + .height(IntrinsicSize.Min) + .conditional( + isAuthor, + `if` = { + background(getReplyMessageGradient(isDark)) + }, + `else` = { + background(MaterialTheme.colorScheme.surfaceVariant) + } + ) + ) { + Box( + Modifier + .background(MaterialTheme.colorScheme.primary) + .width(3.dp) + .fillMaxHeight() + ) + + Column( + Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + ) { + if (showUsername) { + Text( + text = replyTo.username, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + fontSize = 11.sp + ) + } + Text( + text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + maxLines = 1 + ) + } + } + } + } + + // Attachments (images/files) or corrupted message + if (isCorrupted) { + Text( + text = "_This message is corrupted and cannot be displayed.", + style = MaterialTheme.typography.bodyMedium, + color = if (isAuthor) { + Color.White.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } else { + val firstFile = message.files?.firstOrNull() + val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false + val hasPendingServerImage = message.pendingFileUri != null && + firstFileIsImage && + message.dmEnvelope != null + if (message.pendingFileUri != null) { + val isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false + val pendingImageFile = firstFile.takeIf { isPendingImage && hasPendingServerImage } + val imageKey = if (isPendingImage) "img_${message.id}_0" else null + AttachmentPreview( + file = pendingImageFile, + dmEnvelope = if (pendingImageFile != null) message.dmEnvelope else null, + currentUserId = if (pendingImageFile != null) currentUserId else null, + pendingFileUri = message.pendingFileUri, + pendingFilename = message.pendingFilename, + isUploading = message.uploadProgress != null, + uploadProgress = message.uploadProgress, + fileThumbnail = if (pendingImageFile != null) { + message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() } + } else { + null + }, + fileAspectRatio = if (pendingImageFile != null) { + message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f } + ?: message.pendingFileAspectRatio + } else { + message.pendingFileAspectRatio + }, + fileSizeBytes = when { + pendingImageFile != null -> message.fileSizes?.firstOrNull() + !isPendingImage -> message.fileSizes?.firstOrNull() + else -> null + }, + messageId = if (pendingImageFile != null && isPendingImage) message.id else null, + fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null, + onFileClick = null, + onImageClick = if (isPendingImage && imageKey != null) { + { onImageClick?.invoke(message, 0) } + } else { + null + }, + onImageBounds = if (isPendingImage && imageKey != null && onImageBounds != null) { + { rect -> onImageBounds.invoke(imageKey, rect) } + } else { + null + }, + isExpanded = isPendingImage && + imageKey != null && + expandedImageKey != null && + expandedImageKey == imageKey && + !isImageClosing, + isAuthor = isAuthor, + modifier = if (isPendingImage && firstContentIsImage) { + Modifier.padding(all = 2.dp) + } else { + Modifier.padding( + horizontal = if (isPendingImage) 2.dp else 12.dp, + vertical = if (isPendingImage) 2.dp else 4.dp + ) + } + ) + } + message.files?.forEachIndexed { index, file -> + if (message.pendingFileUri != null && index == 0) return@forEachIndexed + val isImage = isImageFilename(file.name) + val imageKey = if (isImage) "img_${message.id}_$index" else null + val isFirstImage = index == 0 && isImage + AttachmentPreview( + file = file, + dmEnvelope = message.dmEnvelope, + currentUserId = currentUserId, + pendingFileUri = null, + isUploading = false, + fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() }, + fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f }, + fileSizeBytes = message.fileSizes?.getOrNull(index), + messageId = if (isImage) message.id else null, + fileIndex = if (isImage) index else null, + onFileClick = null, + onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null, + onImageBounds = if (isImage && imageKey != null && onImageBounds != null) { + { rect -> onImageBounds.invoke(imageKey, rect) } + } else null, + isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, + isAuthor = isAuthor, + modifier = if (isFirstImage && firstContentIsImage && isImage) { + Modifier.padding(all = 2.dp) + } else { + Modifier.padding( + horizontal = if (isImage) 2.dp else 12.dp, + vertical = if (isImage) 2.dp else 4.dp + ) + } + ) + } + } + if (message.content.isNotBlank() && !isCorrupted) { + Text( + text = message.content, + style = MaterialTheme.typography.bodyMedium, + color = if (isAuthor) { + Color.White + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(horizontal = 12.dp) ) } - Box( - modifier = Modifier - .widthIn(max = maxBubbleWidth) - .clip(bubbleShape) - .conditional( - isAuthor, - `if` = { - it - .shadow( - elevation = 8.dp, - shape = RoundedCornerShape( - topStart = 20.dp, - topEnd = 20.dp, - bottomStart = 20.dp, - bottomEnd = 8.dp - ), - spotColor = if (isDark) Color(0x66000000) else Color(0x33000000) - ) - .background(getMessageGradient(isDark)) - }, - `else` = { - background(MaterialTheme.colorScheme.surfaceContainerHighest) - } - ) - .graphicsLayer( - scaleX = scale, - scaleY = scale, - transformOrigin = TransformOrigin.Center - ) - .padding(top = if (firstContentIsImage) 0.dp else 6.dp) - ) { - val bubbleColumnModifier = - if (showUsername && !isAuthor) Modifier.width(IntrinsicSize.Max) - else Modifier - Column(modifier = bubbleColumnModifier) { - if (showUsername && !isAuthor) { - val usernameShape = RoundedCornerShape(6.dp) - val usernameOutset = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp) - val usernameInset = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) - if (onUsernameClick != null) { - val usernameInteraction = remember(message.id) { MutableInteractionSource() } - Box( - modifier = usernameOutset - .clip(usernameShape) - .clickable( - interactionSource = usernameInteraction, - indication = LocalIndication.current, - onClick = onUsernameClick - ) - ) { - Text( - text = message.username, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = usernameInset - ) + // Timestamp, sending indicator, and edited indicator + val isSendingText = message.id < 0 && message.uploadJobId == null + Row( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (isSendingText) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = if (isAuthor) { + Color.White.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) } + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text( + text = formattedTime, + style = MaterialTheme.typography.labelSmall, + fontSize = 11.sp, + color = if (isAuthor) { + Color.White.copy(alpha = 0.7f) } else { - Box(modifier = usernameOutset) { - Text( - text = message.username, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = usernameInset - ) - } + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) } - } - - val gestureWidthModifier = - if (showUsername && !isAuthor) Modifier.fillMaxWidth() - else Modifier - Box( - modifier = gestureWidthModifier - .onGloballyPositioned { coordinates -> - bubbleBodyPositionInRoot = coordinates.positionInRoot() - } - .then(bubbleBodyGestures) - ) { - Column { - // Reply preview - message.reply_to?.let { replyTo -> - Box( - Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp) - ) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .fillMaxWidth() - .height(IntrinsicSize.Min) - .conditional( - isAuthor, - `if` = { - background(getReplyMessageGradient(isDark)) - }, - `else` = { - background(MaterialTheme.colorScheme.surfaceVariant) - } - ) - ) { - Box( - Modifier - .background(MaterialTheme.colorScheme.primary) - .width(3.dp) - .fillMaxHeight() - ) - - Column( - Modifier.padding(horizontal = 8.dp, vertical = 6.dp) - ) { - if (showUsername) { - Text( - text = replyTo.username, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - fontSize = 11.sp - ) - } - Text( - text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - maxLines = 1 - ) - } - } - } - } - - // Attachments (images/files) or corrupted message - if (isMessageCorrupted(message)) { + ) + if (message.is_edited) { + Spacer(modifier = Modifier.width(4.dp)) Text( - text = "_This message is corrupted and cannot be displayed.", - style = MaterialTheme.typography.bodyMedium, - color = if (isAuthor) { - Color.White.copy(alpha = 0.8f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) - } else { - val firstFile = message.files?.firstOrNull() - val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false - val hasPendingServerImage = message.pendingFileUri != null && - firstFileIsImage && - message.dmEnvelope != null - if (message.pendingFileUri != null) { - val isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false - val pendingImageFile = firstFile.takeIf { isPendingImage && hasPendingServerImage } - val imageKey = if (isPendingImage) "img_${message.id}_0" else null - AttachmentPreview( - file = pendingImageFile, - dmEnvelope = if (pendingImageFile != null) message.dmEnvelope else null, - currentUserId = if (pendingImageFile != null) currentUserId else null, - pendingFileUri = message.pendingFileUri, - pendingFilename = message.pendingFilename, - isUploading = message.uploadProgress != null, - uploadProgress = message.uploadProgress, - fileThumbnail = if (pendingImageFile != null) { - message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() } - } else { - null - }, - fileAspectRatio = if (pendingImageFile != null) { - message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f } - ?: message.pendingFileAspectRatio - } else { - message.pendingFileAspectRatio - }, - fileSizeBytes = when { - pendingImageFile != null -> message.fileSizes?.firstOrNull() - !isPendingImage -> message.fileSizes?.firstOrNull() - else -> null - }, - messageId = if (pendingImageFile != null && isPendingImage) message.id else null, - fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null, - onFileClick = null, - onImageClick = if (isPendingImage && imageKey != null) { - { onImageClick?.invoke(message, 0) } - } else { - null - }, - onImageBounds = if (isPendingImage && imageKey != null && onImageBounds != null) { - { rect -> onImageBounds.invoke(imageKey, rect) } - } else { - null - }, - isExpanded = isPendingImage && - imageKey != null && - expandedImageKey != null && - expandedImageKey == imageKey && - !isImageClosing, - isAuthor = isAuthor, - modifier = if (isPendingImage && firstContentIsImage) { - Modifier.padding(all = 2.dp) - } else { - Modifier.padding( - horizontal = if (isPendingImage) 2.dp else 12.dp, - vertical = if (isPendingImage) 2.dp else 4.dp - ) - } - ) - } - message.files?.forEachIndexed { index, file -> - if (message.pendingFileUri != null && index == 0) return@forEachIndexed - val isImage = isImageFilename(file.name) - val imageKey = if (isImage) "img_${message.id}_$index" else null - val isFirstImage = index == 0 && isImage - AttachmentPreview( - file = file, - dmEnvelope = message.dmEnvelope, - currentUserId = currentUserId, - pendingFileUri = null, - isUploading = false, - fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() }, - fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f }, - fileSizeBytes = message.fileSizes?.getOrNull(index), - messageId = if (isImage) message.id else null, - fileIndex = if (isImage) index else null, - onFileClick = null, - onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null, - onImageBounds = if (isImage && imageKey != null && onImageBounds != null) { - { rect -> onImageBounds.invoke(imageKey, rect) } - } else null, - isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, - isAuthor = isAuthor, - modifier = if (isFirstImage && firstContentIsImage && isImage) { - Modifier.padding(all = 2.dp) - } else { - Modifier.padding( - horizontal = if (isImage) 2.dp else 12.dp, - vertical = if (isImage) 2.dp else 4.dp - ) - } - ) - } - } - if (message.content.isNotBlank() && !isMessageCorrupted(message)) { - Text( - text = message.content, - style = MaterialTheme.typography.bodyMedium, - color = if (isAuthor) { - Color.White - } else { - MaterialTheme.colorScheme.onSurface - }, - modifier = Modifier.padding(horizontal = 12.dp) - ) - } - - // Timestamp, sending indicator, and edited indicator - val isSendingText = message.id < 0 && message.uploadJobId == null - Row( - modifier = Modifier - .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - if (isSendingText) { - CircularProgressIndicator( - modifier = Modifier.size(12.dp), - strokeWidth = 1.5.dp, - color = if (isAuthor) { - Color.White.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - } - ) - Spacer(modifier = Modifier.width(6.dp)) - } - Text( - text = formatTime(message.timestamp), + text = "(edited)", style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = if (isAuthor) { @@ -498,21 +526,8 @@ fun MessageItem( MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) } ) - if (message.is_edited) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "(edited)", - style = MaterialTheme.typography.labelSmall, - fontSize = 11.sp, - color = if (isAuthor) { - Color.White.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - } - ) - } } - } + } } } } @@ -550,4 +565,4 @@ private fun formatTime(timestamp: String): String { "" } } -} \ No newline at end of file +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt index 24c3e5e..631ef1c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -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, fromNetwork: List): Boolean { + val order = compareBy { 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) { - MessageCacheStore.upsertPublicMessage(message) + withContext(Dispatchers.Default) { + MessageCacheStore.upsertPublicMessage(message) + } } override suspend fun removeOptimisticFromCache(message: Message) { val cid = message.client_message_id ?: return - MessageCacheStore.deletePublicMessageByClientMessageId(cid) + withContext(Dispatchers.Default) { + MessageCacheStore.deletePublicMessageByClientMessageId(cid) + } } override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) { - MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed) + 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()) { + 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() - cached.forEach { message -> - addMessage(message) + addMessages(cached) + setLoading(false) + } + } + } else { + withContext(Dispatchers.Main) { + setLoading(true) + } + } + + // 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() + } + 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) } } } - - // Then refresh from network when available. - val response = ApiClient.getMessages(limit = 50) - if (response.messages.isNotEmpty()) { - clearMessages() - response.messages.forEach { message -> - addMessage(message) - } - // Persist fresh messages to cache for offline use. + 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,24 +197,27 @@ class PublicChatPanel( val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) DecryptedImageCache.invalidateForMessage(editedMsg.id) updateMessage(editedMsg.id) { editedMsg } - // Update cache to reflect edit - MessageCacheStore.replacePublicMessages(_state.messages) + 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 - MessageCacheStore.markMessageDeleted("public", deletedData.message_id) - MessageCacheStore.replacePublicMessages(_state.messages) + 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 - MessageCacheStore.replacePublicMessages(_state.messages) + withContext(Dispatchers.Default) { + MessageCacheStore.replacePublicMessages(_state.messages) + } } "typing" -> { val data = updateMessage.data ?: return diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt new file mode 100644 index 0000000..eaa2fe0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt @@ -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() + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatProfileSharedTransition.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatProfileSharedTransition.kt new file mode 100644 index 0000000..27ae61b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatProfileSharedTransition.kt @@ -0,0 +1,9 @@ +package ru.fromchat.ui.chat + +/** + * Stable shared-element keys for NavHost predictive back–compatible 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" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt index d806d2d..e8c9a3f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt @@ -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 ) } - diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt index 0177b8b..67a6dda 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt @@ -26,4 +26,9 @@ object DmPanelCache { fun remove(otherUserId: Int) { panels.remove(otherUserId)?.destroy() } + + fun clearAll() { + panels.values.forEach { it.destroy() } + panels.clear() + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 6ee8e52..5851a0f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -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) ) diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq index 23b14bd..e3cc665 100644 --- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -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 = ?;