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