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 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
|
# 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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
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 = ?;
|
||||||
|
|||||||
Reference in New Issue
Block a user