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 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 # ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions. - 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 local.properties
app/android/keys app/android/keys
releases /releases
release app/android/release
app/android/debug
*.xcuserstate *.xcuserstate
xcuserdata xcuserdata
+18 -5
View File
@@ -1,6 +1,5 @@
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import java.io.FileInputStream
import java.util.Properties import java.util.Properties
plugins { plugins {
@@ -68,20 +67,34 @@ extensions.configure<ApplicationExtension> {
} }
signingConfigs { signingConfigs {
create("release") {
val keystoreProperties = Properties().apply { val keystoreProperties = Properties().apply {
load(FileInputStream(file("keys/keystore.properties"))) load(file("keys/keystore.properties").inputStream())
} }
create("release") {
storeFile = file("keys/release.jks") storeFile = file("keys/release.jks")
keyAlias = "key0" keyAlias = "key0"
storePassword = keystoreProperties["storePassword"].toString() storePassword = keystoreProperties["releaseStorePassword"].toString()
keyPassword = keystoreProperties["keyPassword"].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 enableV3Signing = true
} }
} }
buildTypes { buildTypes {
debug {
applicationIdSuffix = ".beta"
versionNameSuffix = "-beta"
signingConfig = signingConfigs.getByName("debug")
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = 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:name=".notifications.NotificationReplyReceiver"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="ru.fromchat.NOTIFICATION_REPLY" /> <action android:name="${applicationId}.NOTIFICATION_REPLY" />
</intent-filter> </intent-filter>
</receiver> </receiver>
</application> </application>
@@ -49,11 +49,14 @@ object NotificationHelper {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
private fun notificationReplyAction(context: Context) =
"${context.packageName}.NOTIFICATION_REPLY"
private fun createReplyIntent(context: Context) = PendingIntent.getBroadcast( private fun createReplyIntent(context: Context) = PendingIntent.getBroadcast(
context, context,
SUMMARY_NOTIFICATION_ID, SUMMARY_NOTIFICATION_ID,
Intent(context, NotificationReplyReceiver::class.java).apply { Intent(context, NotificationReplyReceiver::class.java).apply {
action = "ru.fromchat.NOTIFICATION_REPLY" action = notificationReplyAction(context)
putExtra("notification_id", SUMMARY_NOTIFICATION_ID) putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
}, },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE 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.Json
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.chat.PublicChatPanelCache
import ru.fromchat.ui.dm.DmPanelCache
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -446,6 +448,8 @@ object ApiClient {
token = null token = null
user = null user = null
runCatching { ProfileCache.clear() } runCatching { ProfileCache.clear() }
runCatching { DmPanelCache.clearAll() }
runCatching { PublicChatPanelCache.clear() }
} }
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
@@ -2,8 +2,10 @@ package ru.fromchat.api.db
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmConversation import ru.fromchat.api.DmConversation
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.ProfileCache
import ru.fromchat.db.Conversation import ru.fromchat.db.Conversation
import ru.fromchat.db.MessageDatabase import ru.fromchat.db.MessageDatabase
import ru.fromchat.db.Message as DbMessage import ru.fromchat.db.Message as DbMessage
@@ -28,12 +30,23 @@ object MessageCacheStore {
private fun conversationIdForPublic(): String = "public" private fun conversationIdForPublic(): String = "public"
private fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId" private fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId"
/**
* Full history for a conversation (unbounded). Prefer [loadRecentPublicMessages] when opening UI.
*/
suspend fun loadPublicMessages(): List<Message> { suspend fun loadPublicMessages(): List<Message> {
return loadMessages(conversationIdForPublic()) 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>) { suspend fun replacePublicMessages(messages: List<Message>) {
val pending = loadPublicMessages().filter { it.id < 0 } val pending = loadPendingMessages(conversationIdForPublic())
val stillPending = pending.filter { p -> val stillPending = pending.filter { p ->
val cid = p.client_message_id val cid = p.client_message_id
cid == null || messages.none { it.client_message_id == cid } cid == null || messages.none { it.client_message_id == cid }
@@ -56,7 +69,7 @@ object MessageCacheStore {
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) { suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
val pending = loadDmMessages(otherUserId).filter { it.id < 0 } val pending = loadPendingMessages(convId)
val stillPending = pending.filter { p -> val stillPending = pending.filter { p ->
val cid = p.client_message_id val cid = p.client_message_id
cid == null || messages.none { it.client_message_id == cid } cid == null || messages.none { it.client_message_id == cid }
@@ -145,24 +158,56 @@ object MessageCacheStore {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessagesByConversation(conversationId) .selectMessagesByConversation(conversationId)
.executeAsList() .executeAsList()
.map { row: DbMessage -> .map { row: DbMessage -> row.toAppMessage() }
Message( }
id = row.id.toInt(),
user_id = row.userId.toInt(), private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> =
content = row.content, withContext(Dispatchers.Default) {
timestamp = row.timestamp, db.messageDatabaseQueries
is_read = row.isRead != 0L, .selectRecentMessagesByConversation(conversationId, limit)
is_edited = row.isEdited != 0L, .executeAsList()
username = "", // Filled from network; cache focuses on content & ordering. .map { row: DbMessage -> row.toAppMessage() }
profile_picture = null, .reversed()
verified = null, }
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, reply_to = null,
client_message_id = row.clientMessageId, client_message_id = clientMessageId,
reactions = null, reactions = null,
files = null files = null
) )
} }
}
private suspend fun replaceMessages(conversationId: String, messages: List<Message>) { private suspend fun replaceMessages(conversationId: String, messages: List<Message>) {
withContext(Dispatchers.Default) { 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.End
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -18,9 +19,11 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
@@ -93,6 +96,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
} }
FromChatTheme { FromChatTheme {
SharedTransitionLayout {
val navController = rememberNavController() val navController = rememberNavController()
// Handle navigation to public chat when requested (e.g., from notification) // 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 // Set up global auth error handler
LaunchedEffect(navController) { LaunchedEffect(navController) {
ApiClient.onAuthError = { ApiClient.onAuthError = {
@@ -187,19 +189,43 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
} }
composable("chats/publicChat") { composable("chats/publicChat") {
PublicChatScreen(scrollToMessageId = scrollToMessageId) PublicChatScreen(
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
)
} }
composable("debug") { composable("debug") {
DebugApiScreen() DebugApiScreen()
} }
composable("profile/{userId}") { backStackEntry -> composable(
val userId = backStackEntry.savedStateHandle.get<String>("userId")?.toIntOrNull() 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( ProfileScreen(
userId = userId, userId = userId,
onBack = { navController.navigateUp() }, 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) { if (imageLoadFailed || profilePictureUrl == null) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val radius = size.minDimension / 2f val radius = size.minDimension / 2f
// Background gradient circle
drawCircle( drawCircle(
brush = gradient, brush = gradient,
radius = radius, radius = radius,
center = center center = center
) )
// Letters sized relative to radius if (initials.isNotBlank()) {
val fontPx = radius * 0.7f val fontPx = radius * 0.7f
val fontSp = (fontPx / density).sp 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) * Get initials from display name (first 2 words, first letter of each)
*/ */
fun getInitials(displayName: String): String { fun getInitials(displayName: String): String {
val words = displayName.trim().split("\\s+".toRegex()) val words = displayName.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }
return when { return when {
words.isEmpty() -> "?" words.isEmpty() -> ""
words.size == 1 -> { words.size == 1 -> {
val word = words[0] val word = words[0]
if (word.length >= 2) { if (word.length >= 2) {
word.take(2).uppercase() word.take(2).uppercase()
} else { } else {
word.uppercase() + "?" (word + word).take(2).uppercase()
} }
} }
else -> { 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 val pendingMessages = mutableMapOf<String, Pair<Job, Message>>()
private var onStateChange: ((ChatPanelState) -> Unit)? = null private var onStateChange: ((ChatPanelState) -> Unit)? = null
private var batchDepth: Int = 0
private var pendingBatchedState: ChatPanelState? = null
/** /**
* Set state change callback * Set state change callback
@@ -74,12 +76,43 @@ abstract class ChatPanel(
val newState = _state.copy() val newState = _state.copy()
Logger.d("ChatPanel", "State updated: messages=${newState.messages.size}, callback=${callback != null}") Logger.d("ChatPanel", "State updated: messages=${newState.messages.size}, callback=${callback != null}")
if (callback != null) { if (callback != null) {
if (batchDepth > 0) {
pendingBatchedState = newState
} else {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
Logger.d("ChatPanel", "Calling state change callback with ${newState.messages.size} messages") Logger.d("ChatPanel", "Calling state change callback with ${newState.messages.size} messages")
callback(newState) 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() private val addMessageMutex = Mutex()
@@ -50,6 +50,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
@@ -126,11 +127,6 @@ fun ChatScreen(
panelState = panel.getState() panelState = panel.getState()
} }
// Debug: Log state changes
LaunchedEffect(panelState.messages.size) {
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
}
LaunchedEffect(panelState.titleAvatar) { LaunchedEffect(panelState.titleAvatar) {
onTitleAvatarChange?.invoke(panelState.titleAvatar) onTitleAvatarChange?.invoke(panelState.titleAvatar)
} }
@@ -141,7 +137,9 @@ fun ChatScreen(
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val navController = LocalNavController.current val navController = LocalNavController.current
val profileUserId = panelState.profileUserId 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 currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
@@ -171,10 +169,9 @@ fun ChatScreen(
val messageIndex = messages.indexOfFirst { it.id == messageId } val messageIndex = messages.indexOfFirst { it.id == messageId }
if (messageIndex != -1) { if (messageIndex != -1) {
scope.launch { scope.launch {
listState.animateScrollToItem( // reverseLayout list: index 0 = bottom spacer, 1..n = newest..oldest
index = messages.size - 1 - messageIndex, val lazyIndex = 1 + (messages.size - 1 - messageIndex)
scrollOffset = 0 listState.animateScrollToItem(index = lazyIndex, scrollOffset = 0)
)
} }
} }
} }
@@ -270,32 +267,30 @@ fun ChatScreen(
} }
// Scroll to bottom when new messages arrive. // Scroll to bottom when new messages arrive.
// - Initial composition: jump (no animation) to avoid jank. // LazyColumn uses reverseLayout + chronological messages asReversed(): index 0 is bottom inset, 1..n newest→oldest.
// - Subsequent messages: always scroll when we sent (last message is ours); otherwise only if near bottom. // - Initial: after one frame, scrollToItem(0) so the first list composition/layout is not merged with scroll in one VSYNC.
// - Scroll to last item (totalItemsCount - 1) so new message appears at bottom; delay to allow layout. // - Later: same anchor; "near bottom" = smallest visible index is near 0.
var didInitialScroll by remember(panel) { mutableStateOf(false) } var didInitialScroll by remember(panel) { mutableStateOf(false) }
LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) { LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) {
if (panelState.messages.isEmpty()) return@LaunchedEffect if (panelState.messages.isEmpty()) return@LaunchedEffect
val lastMessage = panelState.messages.lastOrNull() val lastMessage = panelState.messages.lastOrNull()
val lastIsOurs = lastMessage?.user_id == currentUserId val lastIsOurs = lastMessage?.user_id == currentUserId
val totalItems = 2 + panelState.messages.size // top spacer + messages + bottom spacer
val lastIndex = totalItems - 1
if (!didInitialScroll) { if (!didInitialScroll) {
didInitialScroll = true didInitialScroll = true
listState.scrollToItem(lastIndex) withFrameNanos { }
listState.scrollToItem(0)
return@LaunchedEffect return@LaunchedEffect
} }
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } ?: Int.MAX_VALUE
val isNearBottom = lastVisibleIndex >= (totalItems - 3) val isNearBottom = minVisibleIndex <= 2
if (lastIsOurs || isNearBottom) { if (lastIsOurs || isNearBottom) {
delay(100) // Allow new item to be composed and laid out delay(100)
listState.animateScrollToItem(lastIndex) listState.animateScrollToItem(0)
// Re-scroll after layout may change (e.g. aspect ratio update)
delay(150) delay(150)
listState.animateScrollToItem(lastIndex) listState.animateScrollToItem(0)
} }
} }
@@ -340,7 +335,7 @@ fun ChatScreen(
val avatar = panelState.titleAvatar val avatar = panelState.titleAvatar
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() } val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
?: panelState.title.takeIf { it.isNotBlank() } ?: panelState.title.takeIf { it.isNotBlank() }
?: "?" ?: ""
with(sharedTransitionScope) { with(sharedTransitionScope) {
Avatar( Avatar(
profilePictureUrl = avatar?.profilePictureUrl, profilePictureUrl = avatar?.profilePictureUrl,
@@ -626,21 +621,23 @@ fun ChatScreen(
} else { } else {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState),
userScrollEnabled = !contextMenuState.isOpen, 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(
items = panelState.messages, items = panelState.messages.asReversed(),
key = { msg -> key = { msg ->
msg.client_message_id ?: "id_${msg.id}_${msg.timestamp}" msg.client_message_id ?: "id_${msg.id}_${msg.timestamp}"
} }
) { message -> ) { message ->
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) } var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
Box(modifier = Modifier.hazeSource(hazeState)) {
MessageItem( MessageItem(
message = message, message = message,
isAuthor = message.user_id == currentUserId, isAuthor = message.user_id == currentUserId,
@@ -664,7 +661,10 @@ fun ChatScreen(
) { ) {
{ {
ProfileCache.mergePreviewFromPublicMessage(message) ProfileCache.mergePreviewFromPublicMessage(message)
navController.navigate("profile/${message.user_id}") navController.navigate(
"profile/${message.user_id}" +
"?useSharedElement=true&sourceMessageId=${message.id}"
)
} }
} else { } else {
null null
@@ -676,12 +676,28 @@ fun ChatScreen(
expandedImageKey = expandedImageKey, expandedImageKey = expandedImageKey,
isImageClosing = isImageClosing, isImageClosing = isImageClosing,
showUsername = panel.showUsernamesInMessages, 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 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.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring 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.LocalIndication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -86,20 +82,22 @@ fun MessageItem(
expandedImageKey: String? = null, expandedImageKey: String? = null,
isImageClosing: Boolean = false, isImageClosing: Boolean = false,
isContextMenuOpen: Boolean = false, isContextMenuOpen: Boolean = false,
isContextMenuForThisMessage: Boolean = false isContextMenuForThisMessage: Boolean = false,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarNavKey: String? = null
) { ) {
AnimatedVisibility( // Cache derived values per message to avoid recomputing in every recomposition.
visible = true, val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
enter = fadeIn(animationSpec = tween(300)) + slideInVertically( isMessageCorrupted(message)
initialOffsetY = { 20 }, }
animationSpec = tween(300) val formattedTime = remember(message.timestamp) {
), formatTime(message.timestamp)
exit = fadeOut(animationSpec = tween(200)) + slideOutVertically( }
targetOffsetY = { -10 },
animationSpec = tween(200) // 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.
modifier = modifier
) {
var isPressed by remember { mutableStateOf(false) } var isPressed by remember { mutableStateOf(false) }
var avatarPressed by remember(message.id) { mutableStateOf(false) } var avatarPressed by remember(message.id) { mutableStateOf(false) }
var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) } var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) }
@@ -125,7 +123,7 @@ fun MessageItem(
) )
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, 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( Avatar(
profilePictureUrl = message.profile_picture, profilePictureUrl = message.profile_picture,
displayName = message.username, displayName = message.username,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
} }
}
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
@@ -347,7 +363,7 @@ fun MessageItem(
} }
// Attachments (images/files) or corrupted message // Attachments (images/files) or corrupted message
if (isMessageCorrupted(message)) { if (isCorrupted) {
Text( Text(
text = "_This message is corrupted and cannot be displayed.", text = "_This message is corrupted and cannot be displayed.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -455,7 +471,7 @@ fun MessageItem(
) )
} }
} }
if (message.content.isNotBlank() && !isMessageCorrupted(message)) { if (message.content.isNotBlank() && !isCorrupted) {
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -489,7 +505,7 @@ fun MessageItem(
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
Text( Text(
text = formatTime(message.timestamp), text = formattedTime,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp, fontSize = 11.sp,
color = if (isAuthor) { color = if (isAuthor) {
@@ -519,7 +535,6 @@ fun MessageItem(
} }
} }
} }
}
} }
@ExperimentalTime @ExperimentalTime
@@ -1,7 +1,9 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.MessageDeletedData import ru.fromchat.api.MessageDeletedData
@@ -24,6 +26,25 @@ class PublicChatPanel(
private val typingHandler = PublicChatTypingHandler(scope) private val typingHandler = PublicChatTypingHandler(scope)
private var messagesLoaded = false 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 override val supportsNavigateToSenderProfile: Boolean
get() = true get() = true
@@ -49,50 +70,83 @@ class PublicChatPanel(
} }
override suspend fun persistOptimisticMessage(message: Message) { override suspend fun persistOptimisticMessage(message: Message) {
withContext(Dispatchers.Default) {
MessageCacheStore.upsertPublicMessage(message) MessageCacheStore.upsertPublicMessage(message)
} }
}
override suspend fun removeOptimisticFromCache(message: Message) { override suspend fun removeOptimisticFromCache(message: Message) {
val cid = message.client_message_id ?: return val cid = message.client_message_id ?: return
withContext(Dispatchers.Default) {
MessageCacheStore.deletePublicMessageByClientMessageId(cid) MessageCacheStore.deletePublicMessageByClientMessageId(cid)
} }
}
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) { override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
withContext(Dispatchers.Default) {
MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed) MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed)
} }
}
override suspend fun loadMessages() { override suspend fun loadMessages() {
if (messagesLoaded) return if (messagesLoaded) return
setLoading(true) messagesLoaded = true
try {
// First, try to show cached messages immediately for offline / fast startup. // 1) Read cache off main first. Do NOT setLoading(true) before this: that forced an extra
runCatching { // frame (spinner + msgs=0) and a second heavy recomposition before the cached list applied.
val cached = MessageCacheStore.loadPublicMessages() val cached = withContext(Dispatchers.Default) {
if (cached.isNotEmpty()) { // Bounded read: public conversation can accumulate many rows; SQLDelight is thin — the cost is SQLite I/O.
clearMessages() runCatching { MessageCacheStore.loadRecentPublicMessages(limit = 128) }.getOrDefault(emptyList())
cached.forEach { message ->
addMessage(message)
} }
if (cached.isNotEmpty()) {
withContext(Dispatchers.Main) {
batchStateUpdates {
clearMessages()
addMessages(cached)
setLoading(false)
}
}
} else {
withContext(Dispatchers.Main) {
setLoading(true)
} }
} }
// Then refresh from network when available. // 2) Refresh from network; this may be fast or slow, but runs entirely off main.
val response = ApiClient.getMessages(limit = 50) val response = withContext(Dispatchers.Default) {
if (response.messages.isNotEmpty()) { runCatching { ApiClient.getMessages(limit = 50) }.getOrNull()
clearMessages()
response.messages.forEach { message ->
addMessage(message)
} }
// 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) MessageCacheStore.replacePublicMessages(response.messages)
} }
setHasMoreMessages(false) // TODO: Implement has_more from API } else if (cached.isEmpty()) {
messagesLoaded = true // Nothing to show at all; hide spinner so the user is not stuck.
} catch (_: Exception) { withContext(Dispatchers.Main) {
// Keep whatever cached state we have; no-op on error. if (_state.isLoading) setLoading(false)
} finally { if (_state.hasMoreMessages) setHasMoreMessages(false)
setLoading(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() val oldestMessage = messages.first()
setLoadingMore(true) setLoadingMore(true)
try { 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()) { if (response.messages.isNotEmpty()) {
// Prepend older messages (they come in reverse chronological order) // Prepend older messages (they come in reverse chronological order)
updateState { currentState -> updateState { currentState ->
@@ -141,25 +197,28 @@ class PublicChatPanel(
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id) DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { editedMsg } updateMessage(editedMsg.id) { editedMsg }
// Update cache to reflect edit withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.replacePublicMessages(_state.messages)
} }
}
"messageDeleted" -> { "messageDeleted" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data) val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
DecryptedImageCache.invalidateForMessage(deletedData.message_id) DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id) removeMessage(deletedData.message_id)
// Mark deleted in cache withContext(Dispatchers.Default) {
MessageCacheStore.markMessageDeleted("public", deletedData.message_id) MessageCacheStore.markMessageDeleted("public", deletedData.message_id)
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.replacePublicMessages(_state.messages)
} }
}
"reactionUpdate" -> { "reactionUpdate" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data) val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
handleReactionUpdate(reactionUpdate) handleReactionUpdate(reactionUpdate)
// Re-write cache so reactions are updated withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.replacePublicMessages(_state.messages)
} }
}
"typing" -> { "typing" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data) 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 package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.ui.isPublicChatVisible import ru.fromchat.ui.isPublicChatVisible
@Composable @Composable
fun PublicChatScreen(scrollToMessageId: Int? = null) { fun PublicChatScreen(
val scope = rememberCoroutineScope() scrollToMessageId: Int? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
) {
val currentUserId = ApiClient.user?.id val currentUserId = ApiClient.user?.id
// Create panel instance // Reuse one panel for the session (like DM [DmPanelCache]); avoids full reload on every visit.
val panel = remember { val panel = remember(currentUserId) {
PublicChatPanel( PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
chatName = "General Chat",
currentUserId = currentUserId,
scope = scope
)
} }
// Load messages on first appear LaunchedEffect(panel) {
LaunchedEffect(Unit) { if (panel.getState().messages.isEmpty()) {
scope.launch {
panel.loadMessages() panel.loadMessages()
} }
} }
@@ -42,7 +40,8 @@ fun PublicChatScreen(scrollToMessageId: Int? = null) {
ChatScreen( ChatScreen(
panel = panel, panel = panel,
currentUserId = currentUserId, currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedContentScope
) )
} }
@@ -26,4 +26,9 @@ object DmPanelCache {
fun remove(otherUserId: Int) { fun remove(otherUserId: Int) {
panels.remove(otherUserId)?.destroy() 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.api.UserProfile
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey
import ru.fromchat.ui.scaleOnPress import ru.fromchat.ui.scaleOnPress
private data class ProfileUiState( private data class ProfileUiState(
@@ -100,6 +101,9 @@ fun ProfileScreen(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarKey: Any? = 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, initialDisplayName: String? = null,
onOpenSettings: () -> Unit = {} onOpenSettings: () -> Unit = {}
) { ) {
@@ -188,9 +192,17 @@ fun ProfileScreen(
?: initialDisplayName?.takeIf { it.isNotBlank() } ?: 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 && val useSharedAvatar = sharedTransitionScope != null &&
animatedVisibilityScope != null && animatedVisibilityScope != null &&
sharedAvatarKey != null effectiveSharedAvatarKey != null
Column( Column(
modifier = Modifier modifier = Modifier
@@ -201,15 +213,18 @@ fun ProfileScreen(
) { ) {
when { when {
useSharedAvatar -> { useSharedAvatar -> {
with(sharedTransitionScope) { val sharedKey = checkNotNull(effectiveSharedAvatarKey)
val stScope = checkNotNull(sharedTransitionScope)
val visScope = checkNotNull(animatedVisibilityScope)
with(stScope) {
Avatar( Avatar(
profilePictureUrl = profile?.profilePicture, profilePictureUrl = profile?.profilePicture,
displayName = displayName, displayName = displayName,
modifier = Modifier modifier = Modifier
.padding(top = 16.dp) .padding(top = 16.dp)
.sharedElement( .sharedElement(
rememberSharedContentState(key = sharedAvatarKey), rememberSharedContentState(key = sharedKey),
animatedVisibilityScope = animatedVisibilityScope animatedVisibilityScope = visScope
) )
.size(128.dp) .size(128.dp)
) )
@@ -44,6 +44,21 @@ FROM message
WHERE conversationId = ? AND deletedFlag = 0 WHERE conversationId = ? AND deletedFlag = 0
ORDER BY timestamp ASC; 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: deleteMessagesForConversation:
DELETE FROM message DELETE FROM message
WHERE conversationId = ?; WHERE conversationId = ?;