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(file("keys/keystore.properties").inputStream())
|
||||||
load(FileInputStream(file("keys/keystore.properties")))
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
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,25 +158,57 @@ 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(),
|
|
||||||
content = row.content,
|
|
||||||
timestamp = row.timestamp,
|
|
||||||
is_read = row.isRead != 0L,
|
|
||||||
is_edited = row.isEdited != 0L,
|
|
||||||
username = "", // Filled from network; cache focuses on content & ordering.
|
|
||||||
profile_picture = null,
|
|
||||||
verified = null,
|
|
||||||
reply_to = null,
|
|
||||||
client_message_id = row.clientMessageId,
|
|
||||||
reactions = null,
|
|
||||||
files = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = clientMessageId,
|
||||||
|
reactions = 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) {
|
||||||
db.messageDatabaseQueries.transaction {
|
db.messageDatabaseQueries.transaction {
|
||||||
|
|||||||
@@ -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,64 +96,63 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FromChatTheme {
|
FromChatTheme {
|
||||||
val navController = rememberNavController()
|
SharedTransitionLayout {
|
||||||
|
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)
|
||||||
LaunchedEffect(startAtPublicChat) {
|
LaunchedEffect(startAtPublicChat) {
|
||||||
if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
|
if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
|
||||||
navController.navigate("chats/publicChat") {
|
navController.navigate("chats/publicChat") {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Set up global auth error handler
|
|
||||||
LaunchedEffect(navController) {
|
|
||||||
ApiClient.onAuthError = {
|
|
||||||
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
|
|
||||||
navController.navigate("login") {
|
|
||||||
popUpTo("chat") { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalNavController provides navController,
|
|
||||||
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
|
||||||
) {
|
|
||||||
if (startDestination != null) {
|
|
||||||
val animationSpec = tween<IntOffset>(400)
|
|
||||||
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = startDestination!!,
|
|
||||||
enterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
Start,
|
|
||||||
animationSpec = animationSpec
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
Start,
|
|
||||||
animationSpec = animationSpec
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
End,
|
|
||||||
animationSpec = animationSpec
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
End,
|
|
||||||
animationSpec = animationSpec
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
) {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up global auth error handler
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
ApiClient.onAuthError = {
|
||||||
|
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
|
||||||
|
navController.navigate("login") {
|
||||||
|
popUpTo("chat") { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalNavController provides navController,
|
||||||
|
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
||||||
|
) {
|
||||||
|
if (startDestination != null) {
|
||||||
|
val animationSpec = tween<IntOffset>(400)
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination!!,
|
||||||
|
enterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
Start,
|
||||||
|
animationSpec = animationSpec
|
||||||
|
)
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
Start,
|
||||||
|
animationSpec = animationSpec
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
End,
|
||||||
|
animationSpec = animationSpec
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
End,
|
||||||
|
animationSpec = animationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
composable("serverConfig") {
|
composable("serverConfig") {
|
||||||
ServerConfigScreen()
|
ServerConfigScreen()
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +240,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
|||||||
composable("about") {
|
composable("about") {
|
||||||
AboutScreen()
|
AboutScreen()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,37 +64,37 @@ 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
|
||||||
|
|
||||||
val textLayout = textMeasurer.measure(
|
val textLayout = textMeasurer.measure(
|
||||||
text = initials,
|
text = initials,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = fontSp,
|
fontSize = fontSp,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
val textWidth = textLayout.size.width.toFloat()
|
val textWidth = textLayout.size.width.toFloat()
|
||||||
val textHeight = textLayout.size.height.toFloat()
|
val textHeight = textLayout.size.height.toFloat()
|
||||||
val topLeft = Offset(
|
val topLeft = Offset(
|
||||||
x = (size.width - textWidth) / 2f,
|
x = (size.width - textWidth) / 2f,
|
||||||
y = (size.height - textHeight) / 2f
|
y = (size.height - textHeight) / 2f
|
||||||
)
|
)
|
||||||
|
|
||||||
drawText(
|
drawText(
|
||||||
textLayoutResult = textLayout,
|
textLayoutResult = textLayout,
|
||||||
topLeft = topLeft
|
topLeft = topLeft
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +76,40 @@ 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) {
|
||||||
scope.launch(Dispatchers.Main) {
|
if (batchDepth > 0) {
|
||||||
Logger.d("ChatPanel", "Calling state change callback with ${newState.messages.size} messages")
|
pendingBatchedState = newState
|
||||||
callback(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,62 +621,83 @@ 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,
|
isContextMenuOpen = contextMenuState.isOpen,
|
||||||
isContextMenuOpen = contextMenuState.isOpen,
|
isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id,
|
||||||
isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id,
|
onLongPress = {
|
||||||
onLongPress = {
|
haptic(HapticFeedbackEvent.ContextMenuOpened)
|
||||||
haptic(HapticFeedbackEvent.ContextMenuOpened)
|
contextMenuState = ContextMenuState(
|
||||||
contextMenuState = ContextMenuState(
|
isOpen = true,
|
||||||
isOpen = true,
|
message = message,
|
||||||
message = message,
|
position = tapPositionInRoot
|
||||||
position = tapPositionInRoot
|
)
|
||||||
|
},
|
||||||
|
onTapPosition = { offset ->
|
||||||
|
tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt())
|
||||||
|
},
|
||||||
|
onUsernameClick =
|
||||||
|
if (panel.supportsNavigateToSenderProfile &&
|
||||||
|
message.user_id != currentUserId &&
|
||||||
|
message.user_id > 0
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
ProfileCache.mergePreviewFromPublicMessage(message)
|
||||||
|
navController.navigate(
|
||||||
|
"profile/${message.user_id}" +
|
||||||
|
"?useSharedElement=true&sourceMessageId=${message.id}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onImageClick = { msg, idx -> expandedImage = msg to idx },
|
||||||
|
onImageBounds = { key, rect ->
|
||||||
|
imageThumbBounds[key] = rect
|
||||||
|
},
|
||||||
|
expandedImageKey = expandedImageKey,
|
||||||
|
isImageClosing = isImageClosing,
|
||||||
|
showUsername = panel.showUsernamesInMessages,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
|
sharedAvatarNavKey =
|
||||||
|
if (
|
||||||
|
panel.supportsNavigateToSenderProfile &&
|
||||||
|
sharedTransitionScope != null &&
|
||||||
|
animatedVisibilityScope != null &&
|
||||||
|
message.user_id != currentUserId &&
|
||||||
|
message.user_id > 0
|
||||||
|
) {
|
||||||
|
publicChatProfileSharedAvatarKey(
|
||||||
|
message.user_id,
|
||||||
|
message.id
|
||||||
)
|
)
|
||||||
},
|
} else {
|
||||||
onTapPosition = { offset ->
|
null
|
||||||
tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt())
|
}
|
||||||
},
|
)
|
||||||
onUsernameClick =
|
|
||||||
if (panel.supportsNavigateToSenderProfile &&
|
|
||||||
message.user_id != currentUserId &&
|
|
||||||
message.user_id > 0
|
|
||||||
) {
|
|
||||||
{
|
|
||||||
ProfileCache.mergePreviewFromPublicMessage(message)
|
|
||||||
navController.navigate("profile/${message.user_id}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onImageClick = { msg, idx -> expandedImage = msg to idx },
|
|
||||||
onImageBounds = { key, rect ->
|
|
||||||
imageThumbBounds[key] = rect
|
|
||||||
},
|
|
||||||
expandedImageKey = expandedImageKey,
|
|
||||||
isImageClosing = isImageClosing,
|
|
||||||
showUsername = panel.showUsernamesInMessages,
|
|
||||||
currentUserId = currentUserId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,410 +82,442 @@ 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)
|
|
||||||
),
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
var isPressed by remember { mutableStateOf(false) }
|
|
||||||
var avatarPressed by remember(message.id) { mutableStateOf(false) }
|
|
||||||
var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) }
|
|
||||||
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
|
|
||||||
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
|
|
||||||
val scale by animateFloatAsState(
|
|
||||||
targetValue = scaleTarget,
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessMediumLow
|
|
||||||
),
|
|
||||||
visibilityThreshold = 0.001f,
|
|
||||||
label = "messageBubbleScale"
|
|
||||||
)
|
|
||||||
val avatarScale by animateFloatAsState(
|
|
||||||
targetValue = avatarScaleTarget,
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessMediumLow
|
|
||||||
),
|
|
||||||
visibilityThreshold = 0.001f,
|
|
||||||
label = "messageAvatarScale"
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
|
||||||
modifier = Modifier
|
// composition (N messages ⇒ N concurrent animations + huge JIT), causing main-thread jank.
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
var isPressed by remember { mutableStateOf(false) }
|
||||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
var avatarPressed by remember(message.id) { mutableStateOf(false) }
|
||||||
verticalAlignment = Alignment.Bottom
|
var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) }
|
||||||
) {
|
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
|
||||||
if (!isAuthor && showUsername) {
|
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
|
||||||
Box(
|
val scale by animateFloatAsState(
|
||||||
modifier = Modifier
|
targetValue = scaleTarget,
|
||||||
.graphicsLayer(
|
animationSpec = spring(
|
||||||
scaleX = avatarScale,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
scaleY = avatarScale,
|
stiffness = Spring.StiffnessMediumLow
|
||||||
transformOrigin = TransformOrigin.Center
|
),
|
||||||
|
visibilityThreshold = 0.001f,
|
||||||
|
label = "messageBubbleScale"
|
||||||
|
)
|
||||||
|
val avatarScale by animateFloatAsState(
|
||||||
|
targetValue = avatarScaleTarget,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow
|
||||||
|
),
|
||||||
|
visibilityThreshold = 0.001f,
|
||||||
|
label = "messageAvatarScale"
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
if (!isAuthor && showUsername) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = avatarScale,
|
||||||
|
scaleY = avatarScale,
|
||||||
|
transformOrigin = TransformOrigin.Center
|
||||||
|
)
|
||||||
|
.pointerInput(onUsernameClick, isContextMenuOpen) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
if (!isContextMenuOpen) avatarPressed = true
|
||||||
|
try {
|
||||||
|
awaitRelease()
|
||||||
|
} finally {
|
||||||
|
if (!isContextMenuOpen) avatarPressed = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = { onUsernameClick?.invoke() }
|
||||||
)
|
)
|
||||||
.pointerInput(onUsernameClick, isContextMenuOpen) {
|
}
|
||||||
detectTapGestures(
|
) {
|
||||||
onPress = {
|
val navSharedKey = sharedAvatarNavKey
|
||||||
if (!isContextMenuOpen) avatarPressed = true
|
val navStScope = sharedTransitionScope
|
||||||
try {
|
val navVisScope = animatedVisibilityScope
|
||||||
awaitRelease()
|
if (navSharedKey != null && navStScope != null && navVisScope != null) {
|
||||||
} finally {
|
with(navStScope) {
|
||||||
if (!isContextMenuOpen) avatarPressed = false
|
Avatar(
|
||||||
}
|
profilePictureUrl = message.profile_picture,
|
||||||
},
|
displayName = message.username,
|
||||||
onTap = { onUsernameClick?.invoke() }
|
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BoxWithConstraints(
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
modifier = Modifier.weight(1f, fill = false)
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
) {
|
||||||
|
// Allow bubbles to grow up to 70% of the available row width
|
||||||
|
val maxBubbleWidth = maxWidth * 0.7f
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
||||||
) {
|
) {
|
||||||
// Allow bubbles to grow up to 70% of the available row width
|
// Message bubble
|
||||||
val maxBubbleWidth = maxWidth * 0.7f
|
val isDark = isSystemInDarkTheme()
|
||||||
|
val pendingIsImage = when {
|
||||||
Column(
|
message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename)
|
||||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
message.pendingFileUri != null -> isImageFilename(
|
||||||
) {
|
message.pendingFileUri.substringAfterLast('/').substringBefore('?')
|
||||||
// Message bubble
|
|
||||||
val isDark = isSystemInDarkTheme()
|
|
||||||
val pendingIsImage = when {
|
|
||||||
message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename)
|
|
||||||
message.pendingFileUri != null -> isImageFilename(
|
|
||||||
message.pendingFileUri.substringAfterLast('/').substringBefore('?')
|
|
||||||
)
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
val firstContentIsImage = (
|
|
||||||
!showUsername || isAuthor
|
|
||||||
) && message.reply_to == null && (
|
|
||||||
pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
|
||||||
)
|
)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
val firstContentIsImage = (
|
||||||
|
!showUsername || isAuthor
|
||||||
|
) && message.reply_to == null && (
|
||||||
|
pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
||||||
|
)
|
||||||
|
|
||||||
val bubbleShape = RoundedCornerShape(
|
val bubbleShape = RoundedCornerShape(
|
||||||
topStart = 20.dp,
|
topStart = 20.dp,
|
||||||
topEnd = 20.dp,
|
topEnd = 20.dp,
|
||||||
bottomStart = if (isAuthor) 20.dp else 8.dp,
|
bottomStart = if (isAuthor) 20.dp else 8.dp,
|
||||||
bottomEnd = if (isAuthor) 8.dp else 20.dp
|
bottomEnd = if (isAuthor) 8.dp else 20.dp
|
||||||
)
|
)
|
||||||
|
|
||||||
val bubbleBodyGestures =
|
val bubbleBodyGestures =
|
||||||
if (isContextMenuOpen) Modifier
|
if (isContextMenuOpen) Modifier
|
||||||
else Modifier.pointerInput(Unit) {
|
else Modifier.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
isPressed = true
|
isPressed = true
|
||||||
try {
|
try {
|
||||||
awaitRelease()
|
awaitRelease()
|
||||||
} finally {
|
} finally {
|
||||||
isPressed = false
|
isPressed = false
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPress = { offset ->
|
|
||||||
onTapPosition(bubbleBodyPositionInRoot + offset)
|
|
||||||
onLongPress()
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onLongPress = { offset ->
|
||||||
|
onTapPosition(bubbleBodyPositionInRoot + offset)
|
||||||
|
onLongPress()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = maxBubbleWidth)
|
||||||
|
.clip(bubbleShape)
|
||||||
|
.conditional(
|
||||||
|
isAuthor,
|
||||||
|
`if` = {
|
||||||
|
it
|
||||||
|
.shadow(
|
||||||
|
elevation = 8.dp,
|
||||||
|
shape = RoundedCornerShape(
|
||||||
|
topStart = 20.dp,
|
||||||
|
topEnd = 20.dp,
|
||||||
|
bottomStart = 20.dp,
|
||||||
|
bottomEnd = 8.dp
|
||||||
|
),
|
||||||
|
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
|
||||||
|
)
|
||||||
|
.background(getMessageGradient(isDark))
|
||||||
|
},
|
||||||
|
`else` = {
|
||||||
|
background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
transformOrigin = TransformOrigin.Center
|
||||||
|
)
|
||||||
|
.padding(top = if (firstContentIsImage) 0.dp else 6.dp)
|
||||||
|
) {
|
||||||
|
val bubbleColumnModifier =
|
||||||
|
if (showUsername && !isAuthor) Modifier.width(IntrinsicSize.Max)
|
||||||
|
else Modifier
|
||||||
|
Column(modifier = bubbleColumnModifier) {
|
||||||
|
if (showUsername && !isAuthor) {
|
||||||
|
val usernameShape = RoundedCornerShape(6.dp)
|
||||||
|
val usernameOutset = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp)
|
||||||
|
val usernameInset = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
|
if (onUsernameClick != null) {
|
||||||
|
val usernameInteraction = remember(message.id) { MutableInteractionSource() }
|
||||||
|
Box(
|
||||||
|
modifier = usernameOutset
|
||||||
|
.clip(usernameShape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = usernameInteraction,
|
||||||
|
indication = LocalIndication.current,
|
||||||
|
onClick = onUsernameClick
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message.username,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = usernameInset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(modifier = usernameOutset) {
|
||||||
|
Text(
|
||||||
|
text = message.username,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = usernameInset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val gestureWidthModifier =
|
||||||
|
if (showUsername && !isAuthor) Modifier.fillMaxWidth()
|
||||||
|
else Modifier
|
||||||
|
Box(
|
||||||
|
modifier = gestureWidthModifier
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
bubbleBodyPositionInRoot = coordinates.positionInRoot()
|
||||||
|
}
|
||||||
|
.then(bubbleBodyGestures)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// Reply preview
|
||||||
|
message.reply_to?.let { replyTo ->
|
||||||
|
Box(
|
||||||
|
Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.conditional(
|
||||||
|
isAuthor,
|
||||||
|
`if` = {
|
||||||
|
background(getReplyMessageGradient(isDark))
|
||||||
|
},
|
||||||
|
`else` = {
|
||||||
|
background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.width(3.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
if (showUsername) {
|
||||||
|
Text(
|
||||||
|
text = replyTo.username,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments (images/files) or corrupted message
|
||||||
|
if (isCorrupted) {
|
||||||
|
Text(
|
||||||
|
text = "_This message is corrupted and cannot be displayed.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isAuthor) {
|
||||||
|
Color.White.copy(alpha = 0.8f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val firstFile = message.files?.firstOrNull()
|
||||||
|
val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false
|
||||||
|
val hasPendingServerImage = message.pendingFileUri != null &&
|
||||||
|
firstFileIsImage &&
|
||||||
|
message.dmEnvelope != null
|
||||||
|
if (message.pendingFileUri != null) {
|
||||||
|
val isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false
|
||||||
|
val pendingImageFile = firstFile.takeIf { isPendingImage && hasPendingServerImage }
|
||||||
|
val imageKey = if (isPendingImage) "img_${message.id}_0" else null
|
||||||
|
AttachmentPreview(
|
||||||
|
file = pendingImageFile,
|
||||||
|
dmEnvelope = if (pendingImageFile != null) message.dmEnvelope else null,
|
||||||
|
currentUserId = if (pendingImageFile != null) currentUserId else null,
|
||||||
|
pendingFileUri = message.pendingFileUri,
|
||||||
|
pendingFilename = message.pendingFilename,
|
||||||
|
isUploading = message.uploadProgress != null,
|
||||||
|
uploadProgress = message.uploadProgress,
|
||||||
|
fileThumbnail = if (pendingImageFile != null) {
|
||||||
|
message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
fileAspectRatio = if (pendingImageFile != null) {
|
||||||
|
message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f }
|
||||||
|
?: message.pendingFileAspectRatio
|
||||||
|
} else {
|
||||||
|
message.pendingFileAspectRatio
|
||||||
|
},
|
||||||
|
fileSizeBytes = when {
|
||||||
|
pendingImageFile != null -> message.fileSizes?.firstOrNull()
|
||||||
|
!isPendingImage -> message.fileSizes?.firstOrNull()
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
messageId = if (pendingImageFile != null && isPendingImage) message.id else null,
|
||||||
|
fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null,
|
||||||
|
onFileClick = null,
|
||||||
|
onImageClick = if (isPendingImage && imageKey != null) {
|
||||||
|
{ onImageClick?.invoke(message, 0) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onImageBounds = if (isPendingImage && imageKey != null && onImageBounds != null) {
|
||||||
|
{ rect -> onImageBounds.invoke(imageKey, rect) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
isExpanded = isPendingImage &&
|
||||||
|
imageKey != null &&
|
||||||
|
expandedImageKey != null &&
|
||||||
|
expandedImageKey == imageKey &&
|
||||||
|
!isImageClosing,
|
||||||
|
isAuthor = isAuthor,
|
||||||
|
modifier = if (isPendingImage && firstContentIsImage) {
|
||||||
|
Modifier.padding(all = 2.dp)
|
||||||
|
} else {
|
||||||
|
Modifier.padding(
|
||||||
|
horizontal = if (isPendingImage) 2.dp else 12.dp,
|
||||||
|
vertical = if (isPendingImage) 2.dp else 4.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
message.files?.forEachIndexed { index, file ->
|
||||||
|
if (message.pendingFileUri != null && index == 0) return@forEachIndexed
|
||||||
|
val isImage = isImageFilename(file.name)
|
||||||
|
val imageKey = if (isImage) "img_${message.id}_$index" else null
|
||||||
|
val isFirstImage = index == 0 && isImage
|
||||||
|
AttachmentPreview(
|
||||||
|
file = file,
|
||||||
|
dmEnvelope = message.dmEnvelope,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
pendingFileUri = null,
|
||||||
|
isUploading = false,
|
||||||
|
fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() },
|
||||||
|
fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f },
|
||||||
|
fileSizeBytes = message.fileSizes?.getOrNull(index),
|
||||||
|
messageId = if (isImage) message.id else null,
|
||||||
|
fileIndex = if (isImage) index else null,
|
||||||
|
onFileClick = null,
|
||||||
|
onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null,
|
||||||
|
onImageBounds = if (isImage && imageKey != null && onImageBounds != null) {
|
||||||
|
{ rect -> onImageBounds.invoke(imageKey, rect) }
|
||||||
|
} else null,
|
||||||
|
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
|
||||||
|
isAuthor = isAuthor,
|
||||||
|
modifier = if (isFirstImage && firstContentIsImage && isImage) {
|
||||||
|
Modifier.padding(all = 2.dp)
|
||||||
|
} else {
|
||||||
|
Modifier.padding(
|
||||||
|
horizontal = if (isImage) 2.dp else 12.dp,
|
||||||
|
vertical = if (isImage) 2.dp else 4.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message.content.isNotBlank() && !isCorrupted) {
|
||||||
|
Text(
|
||||||
|
text = message.content,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isAuthor) {
|
||||||
|
Color.White
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
// Timestamp, sending indicator, and edited indicator
|
||||||
modifier = Modifier
|
val isSendingText = message.id < 0 && message.uploadJobId == null
|
||||||
.widthIn(max = maxBubbleWidth)
|
Row(
|
||||||
.clip(bubbleShape)
|
modifier = Modifier
|
||||||
.conditional(
|
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
|
||||||
isAuthor,
|
horizontalArrangement = Arrangement.End,
|
||||||
`if` = {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
it
|
) {
|
||||||
.shadow(
|
if (isSendingText) {
|
||||||
elevation = 8.dp,
|
CircularProgressIndicator(
|
||||||
shape = RoundedCornerShape(
|
modifier = Modifier.size(12.dp),
|
||||||
topStart = 20.dp,
|
strokeWidth = 1.5.dp,
|
||||||
topEnd = 20.dp,
|
color = if (isAuthor) {
|
||||||
bottomStart = 20.dp,
|
Color.White.copy(alpha = 0.7f)
|
||||||
bottomEnd = 8.dp
|
} else {
|
||||||
),
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
|
|
||||||
)
|
|
||||||
.background(getMessageGradient(isDark))
|
|
||||||
},
|
|
||||||
`else` = {
|
|
||||||
background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.graphicsLayer(
|
|
||||||
scaleX = scale,
|
|
||||||
scaleY = scale,
|
|
||||||
transformOrigin = TransformOrigin.Center
|
|
||||||
)
|
|
||||||
.padding(top = if (firstContentIsImage) 0.dp else 6.dp)
|
|
||||||
) {
|
|
||||||
val bubbleColumnModifier =
|
|
||||||
if (showUsername && !isAuthor) Modifier.width(IntrinsicSize.Max)
|
|
||||||
else Modifier
|
|
||||||
Column(modifier = bubbleColumnModifier) {
|
|
||||||
if (showUsername && !isAuthor) {
|
|
||||||
val usernameShape = RoundedCornerShape(6.dp)
|
|
||||||
val usernameOutset = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp)
|
|
||||||
val usernameInset = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
|
||||||
if (onUsernameClick != null) {
|
|
||||||
val usernameInteraction = remember(message.id) { MutableInteractionSource() }
|
|
||||||
Box(
|
|
||||||
modifier = usernameOutset
|
|
||||||
.clip(usernameShape)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = usernameInteraction,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
onClick = onUsernameClick
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = message.username,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = usernameInset
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formattedTime,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = if (isAuthor) {
|
||||||
|
Color.White.copy(alpha = 0.7f)
|
||||||
} else {
|
} else {
|
||||||
Box(modifier = usernameOutset) {
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
Text(
|
|
||||||
text = message.username,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = usernameInset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
if (message.is_edited) {
|
||||||
val gestureWidthModifier =
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
if (showUsername && !isAuthor) Modifier.fillMaxWidth()
|
|
||||||
else Modifier
|
|
||||||
Box(
|
|
||||||
modifier = gestureWidthModifier
|
|
||||||
.onGloballyPositioned { coordinates ->
|
|
||||||
bubbleBodyPositionInRoot = coordinates.positionInRoot()
|
|
||||||
}
|
|
||||||
.then(bubbleBodyGestures)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
// Reply preview
|
|
||||||
message.reply_to?.let { replyTo ->
|
|
||||||
Box(
|
|
||||||
Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(IntrinsicSize.Min)
|
|
||||||
.conditional(
|
|
||||||
isAuthor,
|
|
||||||
`if` = {
|
|
||||||
background(getReplyMessageGradient(isDark))
|
|
||||||
},
|
|
||||||
`else` = {
|
|
||||||
background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.primary)
|
|
||||||
.width(3.dp)
|
|
||||||
.fillMaxHeight()
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
if (showUsername) {
|
|
||||||
Text(
|
|
||||||
text = replyTo.username,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontSize = 11.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachments (images/files) or corrupted message
|
|
||||||
if (isMessageCorrupted(message)) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "_This message is corrupted and cannot be displayed.",
|
text = "(edited)",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (isAuthor) {
|
|
||||||
Color.White.copy(alpha = 0.8f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val firstFile = message.files?.firstOrNull()
|
|
||||||
val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false
|
|
||||||
val hasPendingServerImage = message.pendingFileUri != null &&
|
|
||||||
firstFileIsImage &&
|
|
||||||
message.dmEnvelope != null
|
|
||||||
if (message.pendingFileUri != null) {
|
|
||||||
val isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false
|
|
||||||
val pendingImageFile = firstFile.takeIf { isPendingImage && hasPendingServerImage }
|
|
||||||
val imageKey = if (isPendingImage) "img_${message.id}_0" else null
|
|
||||||
AttachmentPreview(
|
|
||||||
file = pendingImageFile,
|
|
||||||
dmEnvelope = if (pendingImageFile != null) message.dmEnvelope else null,
|
|
||||||
currentUserId = if (pendingImageFile != null) currentUserId else null,
|
|
||||||
pendingFileUri = message.pendingFileUri,
|
|
||||||
pendingFilename = message.pendingFilename,
|
|
||||||
isUploading = message.uploadProgress != null,
|
|
||||||
uploadProgress = message.uploadProgress,
|
|
||||||
fileThumbnail = if (pendingImageFile != null) {
|
|
||||||
message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
fileAspectRatio = if (pendingImageFile != null) {
|
|
||||||
message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f }
|
|
||||||
?: message.pendingFileAspectRatio
|
|
||||||
} else {
|
|
||||||
message.pendingFileAspectRatio
|
|
||||||
},
|
|
||||||
fileSizeBytes = when {
|
|
||||||
pendingImageFile != null -> message.fileSizes?.firstOrNull()
|
|
||||||
!isPendingImage -> message.fileSizes?.firstOrNull()
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
messageId = if (pendingImageFile != null && isPendingImage) message.id else null,
|
|
||||||
fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null,
|
|
||||||
onFileClick = null,
|
|
||||||
onImageClick = if (isPendingImage && imageKey != null) {
|
|
||||||
{ onImageClick?.invoke(message, 0) }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onImageBounds = if (isPendingImage && imageKey != null && onImageBounds != null) {
|
|
||||||
{ rect -> onImageBounds.invoke(imageKey, rect) }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
isExpanded = isPendingImage &&
|
|
||||||
imageKey != null &&
|
|
||||||
expandedImageKey != null &&
|
|
||||||
expandedImageKey == imageKey &&
|
|
||||||
!isImageClosing,
|
|
||||||
isAuthor = isAuthor,
|
|
||||||
modifier = if (isPendingImage && firstContentIsImage) {
|
|
||||||
Modifier.padding(all = 2.dp)
|
|
||||||
} else {
|
|
||||||
Modifier.padding(
|
|
||||||
horizontal = if (isPendingImage) 2.dp else 12.dp,
|
|
||||||
vertical = if (isPendingImage) 2.dp else 4.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
message.files?.forEachIndexed { index, file ->
|
|
||||||
if (message.pendingFileUri != null && index == 0) return@forEachIndexed
|
|
||||||
val isImage = isImageFilename(file.name)
|
|
||||||
val imageKey = if (isImage) "img_${message.id}_$index" else null
|
|
||||||
val isFirstImage = index == 0 && isImage
|
|
||||||
AttachmentPreview(
|
|
||||||
file = file,
|
|
||||||
dmEnvelope = message.dmEnvelope,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
pendingFileUri = null,
|
|
||||||
isUploading = false,
|
|
||||||
fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() },
|
|
||||||
fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f },
|
|
||||||
fileSizeBytes = message.fileSizes?.getOrNull(index),
|
|
||||||
messageId = if (isImage) message.id else null,
|
|
||||||
fileIndex = if (isImage) index else null,
|
|
||||||
onFileClick = null,
|
|
||||||
onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null,
|
|
||||||
onImageBounds = if (isImage && imageKey != null && onImageBounds != null) {
|
|
||||||
{ rect -> onImageBounds.invoke(imageKey, rect) }
|
|
||||||
} else null,
|
|
||||||
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
|
|
||||||
isAuthor = isAuthor,
|
|
||||||
modifier = if (isFirstImage && firstContentIsImage && isImage) {
|
|
||||||
Modifier.padding(all = 2.dp)
|
|
||||||
} else {
|
|
||||||
Modifier.padding(
|
|
||||||
horizontal = if (isImage) 2.dp else 12.dp,
|
|
||||||
vertical = if (isImage) 2.dp else 4.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (message.content.isNotBlank() && !isMessageCorrupted(message)) {
|
|
||||||
Text(
|
|
||||||
text = message.content,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (isAuthor) {
|
|
||||||
Color.White
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timestamp, sending indicator, and edited indicator
|
|
||||||
val isSendingText = message.id < 0 && message.uploadJobId == null
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
if (isSendingText) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(12.dp),
|
|
||||||
strokeWidth = 1.5.dp,
|
|
||||||
color = if (isAuthor) {
|
|
||||||
Color.White.copy(alpha = 0.7f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = formatTime(message.timestamp),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
color = if (isAuthor) {
|
color = if (isAuthor) {
|
||||||
@@ -498,21 +526,8 @@ fun MessageItem(
|
|||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (message.is_edited) {
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "(edited)",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = if (isAuthor) {
|
|
||||||
Color.White.copy(alpha = 0.7f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
MessageCacheStore.upsertPublicMessage(message)
|
withContext(Dispatchers.Default) {
|
||||||
|
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
|
||||||
MessageCacheStore.deletePublicMessageByClientMessageId(cid)
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageCacheStore.deletePublicMessageByClientMessageId(cid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
|
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
|
||||||
MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed)
|
withContext(Dispatchers.Default) {
|
||||||
|
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.
|
||||||
|
runCatching { MessageCacheStore.loadRecentPublicMessages(limit = 128) }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
batchStateUpdates {
|
||||||
clearMessages()
|
clearMessages()
|
||||||
cached.forEach { message ->
|
addMessages(cached)
|
||||||
addMessage(message)
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Refresh from network; this may be fast or slow, but runs entirely off main.
|
||||||
|
val response = withContext(Dispatchers.Default) {
|
||||||
|
runCatching { ApiClient.getMessages(limit = 50) }.getOrNull()
|
||||||
|
}
|
||||||
|
if (response != null && response.messages.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val shown = _state.messages
|
||||||
|
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) {
|
||||||
|
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
|
||||||
|
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
||||||
|
if (_state.isLoading) setLoading(false)
|
||||||
|
} else {
|
||||||
|
batchStateUpdates {
|
||||||
|
clearMessages()
|
||||||
|
addMessages(response.messages)
|
||||||
|
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
// Then refresh from network when available.
|
|
||||||
val response = ApiClient.getMessages(limit = 50)
|
|
||||||
if (response.messages.isNotEmpty()) {
|
|
||||||
clearMessages()
|
|
||||||
response.messages.forEach { message ->
|
|
||||||
addMessage(message)
|
|
||||||
}
|
|
||||||
// Persist fresh messages to cache for offline use.
|
|
||||||
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,24 +197,27 @@ 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
|
||||||
|
|||||||
@@ -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