From 139de3393b7b820fb20aac901c0cf51e39006c66 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 4 Jul 2026 20:07:43 +0300 Subject: [PATCH] Fix bugs Signed-off-by: denis0001-dev --- .../src/main/kotlin/ru/fromchat/App.kt | 117 ++- .../fcm/FromChatFirebaseMessagingService.kt | 19 +- .../notifications/NotificationHelper.kt | 52 +- .../src/main/res/values-ru/strings.xml | 5 + app/android/src/main/res/values/strings.xml | 3 + .../composeResources/values-ru/strings.xml | 4 + .../composeResources/values/strings.xml | 4 + .../kotlin/ru/fromchat/api/ApiClient.kt | 59 +- .../api/instance/InstanceIdResolve.kt | 16 +- .../api/instance/ServerConfigProbe.kt | 45 +- .../ru/fromchat/api/local/WebSocketManager.kt | 38 +- .../api/local/db/store/MessageCacheStore.kt | 164 +++- .../api/local/db/store/MessageRepository.kt | 17 +- .../api/local/messages/ChatListPreview.kt | 157 ++++ .../local/send/OutgoingMessageCoordinator.kt | 146 ++-- .../messages/publicchat/SendMessageRequest.kt | 3 +- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 6 +- .../fromchat/ui/auth/register/ProfileStep.kt | 41 - .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 15 +- .../ru/fromchat/ui/chat/panels/dm/DmPanel.kt | 85 +- .../chat/panels/publicchat/PublicChatPanel.kt | 85 +- .../panels/publicchat/PublicChatScreen.kt | 18 + .../ru/fromchat/ui/components/Shimmer.kt | 54 ++ .../fromchat/ui/main/chats/ChatListShared.kt | 101 ++- .../ui/main/chats/ChatsSearchScreen.kt | 12 +- .../ui/main/chats/ChatsSelectionState.kt | 3 +- .../ru/fromchat/ui/main/chats/ChatsTab.kt | 73 +- .../ui/main/settings/DevicesScreen.kt | 274 +++---- .../settings/server/ServerConfigScreen.kt | 76 +- .../ru/fromchat/ui/profile/ProfileScreen.kt | 740 +++++++++++------- 30 files changed, 1676 insertions(+), 756 deletions(-) create mode 100644 app/android/src/main/res/values-ru/strings.xml create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Shimmer.kt diff --git a/app/android/src/main/kotlin/ru/fromchat/App.kt b/app/android/src/main/kotlin/ru/fromchat/App.kt index dd508dd..f6e92bf 100644 --- a/app/android/src/main/kotlin/ru/fromchat/App.kt +++ b/app/android/src/main/kotlin/ru/fromchat/App.kt @@ -2,16 +2,18 @@ package ru.fromchat import android.app.Application import com.pr0gramm3r101.utils.UtilsLibrary +import com.pr0gramm3r101.utils.settings.settings import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.ApiClient -import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.notifications.NotificationHelper class App: Application() { @@ -37,52 +39,85 @@ class App: Application() { UtilsLibrary.init(this) WebSocketManager.addGlobalMessageHandler { msg -> - runCatching { - if (msg.type == "newMessage") { - fetchAndNotify() - } else if (msg.type == "dmNew") { - val dmMessageId = msg.data?.jsonObject?.get("id")?.jsonPrimitive?.content?.toIntOrNull() - fetchAndNotify( - includeDmMessages = true, - dmMessageId = dmMessageId - ) - } else if (msg.type == "updates") { - msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates -> - var shouldFetchPublic = false - var shouldFetchDm = false - var latestDmMessageId: Int? = null + GlobalScope.launch(Dispatchers.IO) { + runCatching { + val currentUserId = settings.getInt("current_user_id", -1) - for (item in updates) { - val type = item - .jsonObject["type"] - ?.jsonPrimitive - ?.content + fun isOwnPublicMessage(data: JsonObject?) = + data?.get("user_id")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId - if (type == "newMessage") { - shouldFetchPublic = true - continue - } + fun isOwnDmMessage(data: JsonObject?) = + data?.get("senderId")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId - if (type == "dmNew") { - shouldFetchDm = true - val envelopeId = item - .jsonObject["data"] - ?.jsonObject - ?.get("id") - ?.jsonPrimitive - ?.content - ?.toIntOrNull() - if (envelopeId != null) { - latestDmMessageId = envelopeId.coerceAtLeast(latestDmMessageId ?: 0) - } + when (msg.type) { + "newMessage" -> { + if (!isOwnPublicMessage(msg.data?.jsonObject)) { + fetchAndNotify() } } - if (shouldFetchPublic || shouldFetchDm) { - fetchAndNotify( - includeDmMessages = shouldFetchDm, - dmMessageId = latestDmMessageId - ) + "dmNew" -> { + if (!isOwnDmMessage(msg.data?.jsonObject)) { + fetchAndNotify( + includeDmMessages = true, + dmMessageId = msg + .data + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content + ?.toIntOrNull() + ) + } + } + + "updates" -> { + msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates -> + var shouldFetchPublic = false + var shouldFetchDm = false + var latestDmMessageId: Int? = null + + for (item in updates) { + val (type, data) = item.jsonObject.let { + Pair( + it["type"]?.jsonPrimitive?.content, + it["data"]?.jsonObject + ) + } + + when (type) { + "newMessage" -> { + if (!isOwnPublicMessage(data)) { + shouldFetchPublic = true + } + } + + "dmNew" -> { + if (!isOwnDmMessage(data)) { + shouldFetchDm = true + + data + ?.get("id") + ?.jsonPrimitive + ?.content + ?.toIntOrNull() + ?.let { + latestDmMessageId = it.coerceAtLeast( + latestDmMessageId ?: 0 + ) + } + } + } + } + } + + if (shouldFetchPublic || shouldFetchDm) { + fetchAndNotify( + includeDmMessages = shouldFetchDm, + dmMessageId = latestDmMessageId + ) + } + } } } } diff --git a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt index ac2948f..587ac5c 100644 --- a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt +++ b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt @@ -23,6 +23,7 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() { val pushData = remoteMessage.data val fallbackMessageId = pushData["message_id"]?.toIntOrNull() ?: pushData["dm_id"]?.toIntOrNull() + val senderId = pushData["sender_id"]?.toIntOrNull() val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"] val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat" val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message" @@ -33,14 +34,20 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() { ApiClient.loadPersistedData() Log.d("FromChatFCM", "Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}") } + val currentUserId = settings.getInt("current_user_id", -1) + if (senderId != null && senderId == currentUserId) { + Log.d("FromChatFCM", "Skipping push for own message senderId=$senderId") + return@launch + } if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) { NotificationHelper.showFallbackPushNotification( - applicationContext, - title, - body, - sender, - fallbackMessageId, - messageType == "dm" + context = applicationContext, + title = title, + body = body, + sender = sender, + messageId = fallbackMessageId, + isDirectMessage = false, + senderId = senderId, ) } if (isDirectMessage) { diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt index 7684e71..f2c6daf 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt @@ -27,6 +27,9 @@ import kotlinx.coroutines.launch import ru.fromchat.MainActivity import ru.fromchat.R import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.messages.ChatListPreviewStrings +import ru.fromchat.api.local.messages.buildChatListPreview +import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.MessagesResponse import ru.fromchat.api.schema.messages.dm.DmHistoryResponse @@ -55,6 +58,18 @@ object NotificationHelper { private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time" const val KEY_TEXT_REPLY = "key_text_reply" + private fun listPreviewStrings(context: Context): ChatListPreviewStrings { + val emoji = context.getString(R.string.chat_preview_image_emoji) + return ChatListPreviewStrings( + imageEmoji = emoji, + imageOnly = context.getString(R.string.chat_preview_image, emoji), + attachmentOnly = context.getString(R.string.chat_preview_attachment), + ) + } + + private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String = + buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content + fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID private fun createMessageIntent( @@ -145,7 +160,8 @@ object NotificationHelper { .get("${ServerConfig.apiBaseUrl}/messages/new") .body() .messages - Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages") + .filter { it.user_id != currentUserId } + Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)") if (messages.isNotEmpty()) { settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) CoroutineScope(Dispatchers.Main).launch { @@ -168,6 +184,7 @@ object NotificationHelper { .get("${ServerConfig.apiBaseUrl}/messages/new") .body() .messages + .filter { it.user_id != settings.getInt("current_user_id", -1) } Log.d( "NotificationHelper", "fetchAndNotify retry: fetched ${retryMessages.size} public messages" @@ -241,6 +258,8 @@ object NotificationHelper { val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet() val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0) + val previewStrings = listPreviewStrings(context) + dmMessages .filter { envelope -> envelope.id > 0 && envelope.senderId != currentUserId @@ -290,11 +309,16 @@ object NotificationHelper { "User ${envelope.senderId}" } val dmConversationUserId = envelope.senderId + val notificationBody = buildChatListPreviewFromEnvelope( + envelope = envelope, + decryptedPlaintext = plaintext, + strings = previewStrings, + )?.takeIf { it.isNotBlank() } ?: plaintext showFallbackPushNotification( context = context, title = "Direct message from $senderName", - body = plaintext, + body = notificationBody, sender = senderName, messageId = envelopeId, allowWhenPublicChatVisible = true, @@ -321,11 +345,22 @@ object NotificationHelper { allowWhenPublicChatVisible: Boolean = false, isDirectMessage: Boolean = false, targetDmUserId: Int? = null, - conversationTitle: String = "Public Chat" + conversationTitle: String = "Public Chat", + senderId: Int? = null, ) { CoroutineScope(Dispatchers.Main).launch { createChannel(context) + val currentUserId = settings.getInt("current_user_id", -1) + if (!isDirectMessage && senderId != null && senderId == currentUserId) { + Log.d("NotificationHelper", "Fallback push skipped: own public message senderId=$senderId") + return@launch + } + if (isDirectMessage && targetDmUserId != null && targetDmUserId == currentUserId) { + Log.d("NotificationHelper", "Fallback push skipped: own DM targetDmUserId=$targetDmUserId") + return@launch + } + if (isPublicChatVisible && !allowWhenPublicChatVisible) { Log.d("NotificationHelper", "Fallback push notification skipped: public chat is visible") return@launch @@ -429,6 +464,7 @@ object NotificationHelper { GlobalScope.launch { val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet() var newMessageCount = 0 + val previewStrings = listPreviewStrings(context) with(NotificationManagerCompat.from(context)) { if ( @@ -468,17 +504,17 @@ object NotificationHelper { .setStyle( NotificationCompat.MessagingStyle( Person.Builder().setName("FromChat").build() - ).setConversationTitle("Public Chat").let { - for (msg in messages.takeLast(10)) { + ).setConversationTitle("Public Chat").let { style -> + for (msg in newMessages.takeLast(10)) { val timestamp = try { Instant.parse(msg.timestamp).toEpochMilliseconds() } catch (_: Exception) { System.currentTimeMillis() } - it.addMessage( + style.addMessage( NotificationCompat.MessagingStyle.Message( - msg.content, + notificationBodyForMessage(msg, previewStrings), timestamp, Person.Builder() .setName(msg.username) @@ -487,7 +523,7 @@ object NotificationHelper { ) } - it + style } ) .setPriority(NotificationCompat.PRIORITY_HIGH) diff --git a/app/android/src/main/res/values-ru/strings.xml b/app/android/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..ec76ae2 --- /dev/null +++ b/app/android/src/main/res/values-ru/strings.xml @@ -0,0 +1,5 @@ + + Вложение + 📷 + %1$s 1 фото + diff --git a/app/android/src/main/res/values/strings.xml b/app/android/src/main/res/values/strings.xml index bd51127..b304f47 100644 --- a/app/android/src/main/res/values/strings.xml +++ b/app/android/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ FromChat + Attachment + 📷 + %1$s 1 photo \ No newline at end of file diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index ce22703..a24b289 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -61,6 +61,10 @@ Общий чат Вы: последнее сообщение Вложение + 📷 + %1$s 1 фото + Отправка сообщения + Загрузка файла Прочитано Выбрать В архив diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 3a3684b..cc1f75d 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -69,6 +69,10 @@ Main chat You: last message Attachment + 📷 + %1$s 1 photo + Sending message + Uploading file Mark as read Select Archive diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index bad1e21..d987a04 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -7,7 +7,10 @@ import com.pr0gramm3r101.utils.settings.secureSettings import com.pr0gramm3r101.utils.settings.settings import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.request.forms.formData import io.ktor.client.request.forms.submitFormWithBinaryData import io.ktor.client.plugins.HttpResponseValidator @@ -33,6 +36,7 @@ import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.MainScope +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -306,12 +310,39 @@ object ApiClient { httpProbe.get(url.trim()) }.isSuccess - suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean = + sealed interface CheckAuthResult { + data object Authenticated : CheckAuthResult + data object NotAuthenticated : CheckAuthResult + data object Unreachable : CheckAuthResult + } + + suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): CheckAuthResult = runCatching { httpProbe.get("${apiBaseUrl.trimEnd('/')}/check_auth") { bearerAuth(bearer) }.body().authenticated - }.getOrDefault(false) + }.fold( + onSuccess = { authenticated -> + if (authenticated) CheckAuthResult.Authenticated else CheckAuthResult.NotAuthenticated + }, + onFailure = { e -> + when (e) { + is TimeoutCancellationException, + is HttpRequestTimeoutException, + is SocketTimeoutException, + is ConnectTimeoutException, + -> CheckAuthResult.Unreachable + is ClientRequestException -> { + if (e.response.status.value in 400..499) { + CheckAuthResult.NotAuthenticated + } else { + CheckAuthResult.Unreachable + } + } + else -> CheckAuthResult.Unreachable + } + }, + ) suspend fun refreshServerInstanceFingerprint() { if (token.isNullOrEmpty()) return @@ -1381,17 +1412,31 @@ object ApiClient { fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") - suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { - if (_suspensionState.value.isSuspended) return + suspend fun sendMessageViaHttp( + content: String, + replyToId: Int? = null, + clientMessageId: String? = null, + ): ru.fromchat.api.schema.messages.Message { + if (_suspensionState.value.isSuspended) { + throw IllegalStateException("Account suspended") + } val payloadJson = json.encodeToString( - SendMessageRequest(content = content.trim(), reply_to_id = replyToId), + SendMessageRequest( + content = content.trim(), + reply_to_id = replyToId, + client_message_id = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }, + ), ) - http.submitFormWithBinaryData( + val response = http.submitFormWithBinaryData( url = "${ServerConfig.apiBaseUrl}/send_message", formData = formData { append("payload", payloadJson) }, - ) + ).body() + if (!response.status.equals("success", ignoreCase = true)) { + throw IllegalStateException("send_message failed: ${response.status}") + } + return response.message } // WebSocket send helpers diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdResolve.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdResolve.kt index 6d31d5c..fd12434 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdResolve.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdResolve.kt @@ -34,6 +34,7 @@ suspend fun resolveInstanceId( config: ServerConfigData, apiBaseUrl: String, forceNetwork: Boolean, + allowCachedOnFailure: Boolean = true, ): InstanceIdResolveResult { val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty() if (!forceNetwork && cached.isNotEmpty() && isValidInstanceUuid(cached)) { @@ -46,7 +47,11 @@ suspend fun resolveInstanceId( } } if (fetchResult.isFailure) { - return networkFailureToResolveResult(fetchResult.exceptionOrNull(), cached) + return networkFailureToResolveResult( + e = fetchResult.exceptionOrNull(), + cached = cached, + allowCachedOnFailure = allowCachedOnFailure, + ) } val fetched = fetchResult.getOrThrow().trim() @@ -74,19 +79,20 @@ fun apiBaseUrlFor(config: ServerConfigData): String { private fun networkFailureToResolveResult( e: Throwable?, cached: String, + allowCachedOnFailure: Boolean, ): InstanceIdResolveResult = when (e) { is TimeoutCancellationException, is HttpRequestTimeoutException, is SocketTimeoutException, -> { - if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) { InstanceIdResolveResult.Cached(cached) } else { InstanceIdResolveResult.Timeout } } is ConnectTimeoutException -> { - if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) { InstanceIdResolveResult.Cached(cached) } else { InstanceIdResolveResult.Unreachable @@ -95,14 +101,14 @@ private fun networkFailureToResolveResult( is ClientRequestException -> { if (e.response.status.value in 400..499) { InstanceIdResolveResult.Unsupported - } else if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + } else if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) { InstanceIdResolveResult.Cached(cached) } else { InstanceIdResolveResult.Unreachable } } else -> { - if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) { InstanceIdResolveResult.Cached(cached) } else { InstanceIdResolveResult.Unreachable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt index eb920c9..a3d1968 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt @@ -36,12 +36,22 @@ suspend fun probeCallsReachable(config: ServerConfigData): Boolean { }.getOrDefault(false) } +sealed interface ApplyServerResult { + data object Applied : ApplyServerResult + data object ServerUnreachable : ApplyServerResult +} + suspend fun probeServer(config: ServerConfigData): ServerProbeResult { val apiBase = apiBaseUrlFor(config) val mark = TimeSource.Monotonic.markNow() InstanceIdGuard.probeConfig = config try { - val resolve = resolveInstanceId(config, apiBase, forceNetwork = true) + val resolve = resolveInstanceId( + config = config, + apiBaseUrl = apiBase, + forceNetwork = true, + allowCachedOnFailure = false, + ) val pingMs = mark.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0) val instanceId = when (resolve) { is InstanceIdResolveResult.Cached -> resolve.instanceId @@ -78,25 +88,32 @@ suspend fun applyServerAndNavigate( onNavigateLogin: suspend () -> Unit, onNavigateChat: suspend () -> Unit, onLogoutOldHost: suspend () -> Unit, -) { +): ApplyServerResult { val apiBase = apiBaseUrlFor(config) val token = bearer.trim() if (token.isEmpty()) { applyServerConfig(config, probe.instanceId, probe.callsOk) WebSocketManager.disconnect() onNavigateLogin() - return + return ApplyServerResult.Applied } - val authOk = ApiClient.checkAuthAt(apiBase, token) - if (!authOk) { - onLogoutOldHost() - applyServerConfig(config, probe.instanceId, probe.callsOk) - WebSocketManager.disconnect() - onNavigateLogin() - return + when (val auth = ApiClient.checkAuthAt(apiBase, token)) { + ApiClient.CheckAuthResult.Authenticated -> { + applyServerConfig(config, probe.instanceId, probe.callsOk) + WebSocketManager.disconnect() + WebSocketManager.connect(forceRestart = true) + onNavigateChat() + return ApplyServerResult.Applied + } + ApiClient.CheckAuthResult.Unreachable -> { + return ApplyServerResult.ServerUnreachable + } + ApiClient.CheckAuthResult.NotAuthenticated -> { + onLogoutOldHost() + applyServerConfig(config, probe.instanceId, probe.callsOk) + WebSocketManager.disconnect() + onNavigateLogin() + return ApplyServerResult.Applied + } } - applyServerConfig(config, probe.instanceId, probe.callsOk) - WebSocketManager.disconnect() - WebSocketManager.connect(forceRestart = true) - onNavigateChat() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt index de263fa..9ddc642 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt @@ -34,6 +34,7 @@ import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.instance.InstanceIdGuard import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.db.store.ConnectionStateStore +import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.schema.websocket.WebSocketCredentials import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData @@ -61,6 +62,7 @@ object WebSocketManager { val messages = _messages.asSharedFlow() private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>() + private val sessionReadyHandlers = mutableListOf Unit>() fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) { globalHandlers += handler @@ -70,6 +72,25 @@ object WebSocketManager { globalHandlers -= handler } + fun addSessionReadyHandler(handler: suspend () -> Unit) { + sessionReadyHandlers += handler + } + + fun removeSessionReadyHandler(handler: suspend () -> Unit) { + sessionReadyHandlers -= handler + } + + private fun notifySessionReady() { + OutgoingMessageCoordinator.onTransportReady() + scope.launch { + sessionReadyHandlers.forEach { handler -> + runCatching { handler() }.onFailure { + logW("Session-ready handler failed: ${it.message}", it) + } + } + } + } + @Volatile private var connecting = false @Volatile private var session: DefaultClientWebSocketSession? = null @Volatile private var connectionJob: Job? = null @@ -190,6 +211,8 @@ object WebSocketManager { } } + notifySessionReady() + for (frame in incoming) { val text = (frame as? Frame.Text)?.readText() ?: continue logD("Received payload: $text") @@ -278,8 +301,8 @@ object WebSocketManager { var handler: ((WebSocketMessage) -> Unit)? = null return try { - if (session == null) { - logW("No active WebSocket session") + if (session == null && !waitForConnection(timeoutMs)) { + logW("No active WebSocket session after waiting") return null } @@ -324,19 +347,17 @@ object WebSocketManager { fun onNetworkLost() { logD("onNetworkLost") + session?.cancel() + session = null + connecting = false connectionJob?.cancel() connectionJob = null - disconnect() ConnectionStateStore.onConnecting() } fun onNetworkAvailable() { if (!AppForeground.isInForeground.value) return - if (session != null) { - logD("onNetworkAvailable: session active, skip") - return - } val now = Clock.System.now().toEpochMilliseconds() val prev = lastOnNetworkAvailableWallMs if (now - prev < NETWORK_AVAILABLE_DEBOUNCE_MS) { @@ -344,7 +365,8 @@ object WebSocketManager { return } lastOnNetworkAvailableWallMs = now - logD("onNetworkAvailable: reconnect") + logD("onNetworkAvailable: reconnect and flush outbox") connect(forceRestart = true) + OutgoingMessageCoordinator.onTransportReady() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index 66b8d7f..c41e18a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -3,12 +3,21 @@ package ru.fromchat.api.local.db.store import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator +import ru.fromchat.api.local.messages.ChatListPreviewState +import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.api.local.messages.buildChatListPreview +import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope +import ru.fromchat.api.local.messages.buildChatListPreviewState import ru.fromchat.api.local.db.aspectRatioFromDimensionPair import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForGroup @@ -41,13 +50,18 @@ data class CachedConversation( val otherUserId: Int, val displayName: String, val lastMessagePreview: String?, - val unreadCount: Int + val lastMessagePendingIndicator: ChatListPreviewPendingIndicator = ChatListPreviewPendingIndicator.None, + val lastMessageUploadProgress: Int? = null, + val unreadCount: Int, ) object MessageCacheStore { private val db: MessageDatabase get() = MessageDatabaseProvider.database private val outboxJson = Json { ignoreUnknownKeys = true; encodeDefaults = true } + @Volatile + var listPreviewStrings: ChatListPreviewStrings? = null + private fun instanceId(): String = CacheContext.requireActiveInstanceId() private fun conversationIdForPublic(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) @@ -82,6 +96,26 @@ object MessageCacheStore { suspend fun loadRecentPublicMessages(limit: Long): List = loadRecentMessages(conversationIdForPublic(), limit) + suspend fun loadRecentPublicChatPreviewState( + strings: ChatListPreviewStrings, + limit: Long = 1, + ): ChatListPreviewState? = withContext(Dispatchers.Default) { + val convId = conversationIdForPublic() + val iid = instanceId() + val recent = db.messageDatabaseQueries + .selectRecentMessagesByConversation(iid, convId, limit) + .executeAsList() + .firstOrNull() ?: return@withContext null + val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull() + ?: return@withContext null + buildChatListPreviewState(message, strings, ApiClient.user?.id) + .let { state -> + state.copy( + text = state.text?.trim()?.takeIf { it.isNotEmpty() }, + ) + } + } + suspend fun replacePublicMessages(messages: List) { conversationIdForPublic().let { replaceMessages( @@ -190,6 +224,7 @@ object MessageCacheStore { } suspend fun upsertDmMessage(otherUserId: Int, message: Message) { + ensureDmConversationRow(otherUserId) upsertSingle(conversationIdForDm(otherUserId), message) syncDmConversationPreviewFromCache(otherUserId) } @@ -263,8 +298,9 @@ object MessageCacheStore { suspend fun replaceDmConversations( conversations: List, - attachmentOnlyPreview: String, + previewStrings: ChatListPreviewStrings, ) { + listPreviewStrings = previewStrings val iid = instanceId() val currentUserId = ApiClient.user?.id withContext(Dispatchers.Default) { @@ -280,7 +316,7 @@ object MessageCacheStore { lastMessagePreview = buildDmListPreview( conv.lastMessage, currentUserId, - attachmentOnlyPreview, + previewStrings, ), unreadCount = conv.unreadCount, updatedAt = conv.lastMessage.timestamp, @@ -324,17 +360,10 @@ object MessageCacheStore { private suspend fun buildDmListPreview( envelope: DmEnvelope, currentUserId: Int?, - attachmentOnlyPreview: String, + previewStrings: ChatListPreviewStrings, ): String? { - val hasFiles = !envelope.files.isNullOrEmpty() val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() - ?.trim() - ?.takeIf { it.isNotEmpty() } - val previewSource = when { - decrypted != null -> decrypted - hasFiles -> attachmentOnlyPreview - else -> null - } + val previewSource = buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings) return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() } } @@ -509,34 +538,120 @@ object MessageCacheStore { suspend fun loadCachedDmConversations(): List = withContext(Dispatchers.Default) { + val iid = instanceId() + val previewStrings = listPreviewStrings + val currentUserId = ApiClient.user?.id db.messageDatabaseQueries - .selectActiveDmConversationsForInstance(instanceId()) + .selectActiveDmConversationsForInstance(iid) .executeAsList() .map { row: Conversation -> + val previewState = previewStrings?.let { strings -> + previewStateForRecentMessage(iid, row.id, strings, currentUserId) + } CachedConversation( id = row.id, otherUserId = row.otherUserId?.toInt() ?: 0, displayName = row.displayName ?: "", - lastMessagePreview = row.lastMessagePreview, - unreadCount = row.unreadCount.toInt() + lastMessagePreview = previewState?.text ?: row.lastMessagePreview, + lastMessagePendingIndicator = previewState?.pendingIndicator + ?: ChatListPreviewPendingIndicator.None, + lastMessageUploadProgress = previewState?.uploadProgress, + unreadCount = row.unreadCount.toInt(), ) } } + @OptIn(ExperimentalCoroutinesApi::class) + fun observeActiveDmConversations(instanceId: String): Flow> { + val conversationsFlow = db.messageDatabaseQueries + .selectActiveDmConversationsForInstance(instanceId) + .asFlow() + .mapToList(Dispatchers.Default) + val pendingFlow = db.messageDatabaseQueries + .selectAllPendingMessagesForInstance(instanceId) + .asFlow() + .mapToList(Dispatchers.Default) + val outboxFlow = db.messageDatabaseQueries + .selectPendingOutboxForInstance(instanceId) + .asFlow() + .mapToList(Dispatchers.Default) + return merge(conversationsFlow, pendingFlow, outboxFlow) + .mapLatest { loadCachedDmConversations() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun observePublicChatPreviewState( + instanceId: String, + strings: ChatListPreviewStrings, + ): Flow { + val convId = conversationIdForPublic() + val messagesFlow = db.messageDatabaseQueries + .selectMessagesByConversation(instanceId, convId) + .asFlow() + .mapToList(Dispatchers.Default) + val pendingFlow = db.messageDatabaseQueries + .selectAllPendingMessagesForInstance(instanceId) + .asFlow() + .mapToList(Dispatchers.Default) + val outboxFlow = db.messageDatabaseQueries + .selectPendingOutboxForInstance(instanceId) + .asFlow() + .mapToList(Dispatchers.Default) + return merge(messagesFlow, pendingFlow, outboxFlow) + .mapLatest { loadRecentPublicChatPreviewState(strings) } + } + + private fun previewStateForRecentMessage( + instanceId: String, + conversationId: String, + strings: ChatListPreviewStrings, + currentUserId: Int?, + ): ChatListPreviewState? { + val recent = db.messageDatabaseQueries + .selectRecentMessagesByConversation(instanceId, conversationId, 1) + .executeAsList() + .firstOrNull() ?: return null + val message = enrichQueuedOutboundUi( + listOf(recent.toAppMessage()), + conversationId, + ).firstOrNull() ?: return null + return buildChatListPreviewState(message, strings, currentUserId) + .let { state -> + state.copy( + text = state.text + ?.let { truncateDmListPreview(it) } + ?.takeIf { it.isNotEmpty() }, + ) + } + } + private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) { val iid = instanceId() val convId = conversationIdForDm(otherUserId) withContext(Dispatchers.Default) { - val row = db.messageDatabaseQueries - .selectConversationsForInstance(iid) - .executeAsList() - .find { it.id == convId } ?: return@withContext + var row = db.messageDatabaseQueries + .selectConversationById(iid, convId) + .executeAsOneOrNull() + if (row == null) { + ensureDmConversationRow(otherUserId) + row = db.messageDatabaseQueries + .selectConversationById(iid, convId) + .executeAsOneOrNull() + ?: return@withContext + } val recent = db.messageDatabaseQueries .selectRecentMessagesByConversation(iid, convId, 1) .executeAsList() .firstOrNull() - val rawPreview = recent?.content.orEmpty().trim() - val preview = rawPreview.takeIf { it.isNotEmpty() } + val previewStrings = listPreviewStrings + val preview = previewStrings?.let { strings -> + recent?.toAppMessage()?.let { message -> + val enriched = enrichQueuedOutboundUi(listOf(message), convId).firstOrNull() + enriched?.let { + buildChatListPreviewState(it, strings, ApiClient.user?.id).text + } + } + } ?.let { truncateDmListPreview(it) } ?.takeIf { it.isNotEmpty() } db.messageDatabaseQueries.upsertConversation( @@ -545,13 +660,14 @@ object MessageCacheStore { type = row.type, otherUserId = row.otherUserId, displayName = row.displayName, - lastMessageId = row.lastMessageId, - lastMessagePreview = preview, + lastMessageId = recent?.id ?: row.lastMessageId, + lastMessagePreview = preview ?: row.lastMessagePreview, unreadCount = row.unreadCount, - updatedAt = row.updatedAt, + updatedAt = recent?.timestamp ?: row.updatedAt, archived = row.archived, ) } + DmConversationListNotifier.notifyChanged() } private suspend fun clearConversationMessages(conversationId: String) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt index 7d820e5..ff0e723 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt @@ -2,6 +2,8 @@ package ru.fromchat.api.local.db.store import kotlinx.coroutines.flow.Flow import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.messages.ChatListPreviewState +import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForGroup @@ -29,6 +31,17 @@ object MessageRepository { suspend fun loadRecentPublicMessages(limit: Long): List = MessageCacheStore.loadRecentPublicMessages(limit) + suspend fun loadRecentPublicChatPreviewState( + strings: ChatListPreviewStrings, + limit: Long = 1, + ): ChatListPreviewState? = MessageCacheStore.loadRecentPublicChatPreviewState(strings, limit) + + fun observePublicChatPreviewState(strings: ChatListPreviewStrings): Flow = + MessageCacheStore.observePublicChatPreviewState(activeInstance(), strings) + + fun observeActiveDmConversations(): Flow> = + MessageCacheStore.observeActiveDmConversations(activeInstance()) + suspend fun replacePublicMessages(messages: List) = MessageCacheStore.replacePublicMessages(messages) @@ -66,8 +79,8 @@ object MessageRepository { suspend fun replaceDmConversations( conversations: List, - attachmentOnlyPreview: String, - ) = MessageCacheStore.replaceDmConversations(conversations, attachmentOnlyPreview) + previewStrings: ChatListPreviewStrings, + ) = MessageCacheStore.replaceDmConversations(conversations, previewStrings) suspend fun loadCachedDmConversations(): List = MessageCacheStore.loadCachedDmConversations() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt new file mode 100644 index 0000000..e8d57ac --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt @@ -0,0 +1,157 @@ +package ru.fromchat.api.local.messages + +import ru.fromchat.api.local.db.parseDmMessageContent +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile +import ru.fromchat.ui.chat.isFilenameOnlyMessageCaption +import ru.fromchat.ui.chat.isImageFilename + +enum class ChatListPreviewPendingIndicator { + None, + SendingText, + UploadingFile, +} + +data class ChatListPreviewState( + val text: String?, + val pendingIndicator: ChatListPreviewPendingIndicator = ChatListPreviewPendingIndicator.None, + val uploadProgress: Int? = null, +) { + fun displayText(default: String): String = + text?.trim()?.takeIf { it.isNotEmpty() } ?: default +} + +data class ChatListPreviewStrings( + val imageEmoji: String, + val imageOnly: String, + val attachmentOnly: String, +) { + fun imageWithCaption(caption: String): String = "$imageEmoji $caption" +} + +/** Detects image attachments from structured message/envelope fields (never from JSON substring heuristics). */ +fun messageHasImageAttachment(message: Message): Boolean { + message.files.orEmpty().forEach { file -> + if (isImageFilename(file.name)) return true + } + if (message.pendingFileUri != null) { + val pendingName = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() } + ?: message.pendingFileUri.substringAfterLast('/').substringBefore('?') + if (isImageFilename(pendingName)) return true + if (message.pendingFileAspectRatio != null) return true + } + if (!parseDmMessageContent(message.content).fileThumbnails.isNullOrEmpty()) return true + return false +} + +fun messageHasNonImageFileAttachment(message: Message): Boolean { + message.files.orEmpty().forEach { file -> + if (!isImageFilename(file.name)) return true + } + return message.pendingFileUri != null && !messageHasImageAttachment(message) +} + +fun envelopeHasImageAttachment(envelope: DmEnvelope): Boolean = + envelope.files.orEmpty().any { isImageFilename(it.name) } + +fun envelopeHasFileAttachment(envelope: DmEnvelope): Boolean = + !envelope.files.isNullOrEmpty() + +fun messagePreviewCaption(message: Message): String? = + captionFromParsedContent( + content = message.content, + files = message.files, + pendingFilename = message.pendingFilename, + ) + +private fun captionFromParsedContent( + content: String, + files: List? = null, + pendingFilename: String? = null, +): String? { + val text = parseDmMessageContent(content).text.trim().takeIf { it.isNotEmpty() } ?: return null + val probe = Message( + id = 0, + user_id = 0, + content = text, + timestamp = "", + is_read = true, + is_edited = false, + username = "", + files = files, + pendingFilename = pendingFilename, + ) + if (isFilenameOnlyMessageCaption(probe)) return null + return text +} + +fun buildChatListPreview(message: Message, strings: ChatListPreviewStrings): String? = + when { + messageHasImageAttachment(message) -> { + val caption = messagePreviewCaption(message) + if (caption != null) strings.imageWithCaption(caption) else strings.imageOnly + } + else -> { + val caption = messagePreviewCaption(message) + when { + caption != null -> caption + messageHasNonImageFileAttachment(message) -> strings.attachmentOnly + else -> null + } + } + } + +fun resolveChatListPreviewPendingIndicator( + message: Message, + currentUserId: Int?, +): Pair { + if (currentUserId == null || message.user_id != currentUserId) { + return ChatListPreviewPendingIndicator.None to null + } + if (!message.isQueuedOutbound() || !message.uploadError.isNullOrBlank()) { + return ChatListPreviewPendingIndicator.None to null + } + val hasFileUpload = !message.pendingFileUri.isNullOrBlank() || + !message.uploadJobId.isNullOrBlank() + return when { + hasFileUpload -> + ChatListPreviewPendingIndicator.UploadingFile to message.uploadProgress + message.files.isNullOrEmpty() -> + ChatListPreviewPendingIndicator.SendingText to null + else -> ChatListPreviewPendingIndicator.None to null + } +} + +fun buildChatListPreviewState( + message: Message, + strings: ChatListPreviewStrings, + currentUserId: Int?, +): ChatListPreviewState { + val (pendingIndicator, uploadProgress) = resolveChatListPreviewPendingIndicator(message, currentUserId) + return ChatListPreviewState( + text = buildChatListPreview(message, strings), + pendingIndicator = pendingIndicator, + uploadProgress = uploadProgress, + ) +} + +fun buildChatListPreviewFromEnvelope( + envelope: DmEnvelope, + decryptedPlaintext: String?, + strings: ChatListPreviewStrings, +): String? { + val hasImages = envelopeHasImageAttachment(envelope) + val hasFiles = envelopeHasFileAttachment(envelope) + val caption = decryptedPlaintext?.let { plaintext -> + captionFromParsedContent(plaintext, envelope.files) + } + return when { + hasImages -> { + if (caption != null) strings.imageWithCaption(caption) else strings.imageOnly + } + caption != null -> caption + hasFiles -> strings.attachmentOnly + else -> null + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt index 8a76b5b..ddea26b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt @@ -39,6 +39,101 @@ object OutgoingMessageCoordinator { private val drainMutex = Mutex() private val drainScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + /** Called when network or WebSocket transport is ready; drains pending outbox rows. */ + fun onTransportReady() { + kickOutboxDrain(CacheContext.activeInstanceId.value.trim()) + } + + private fun scheduleOutboxRetry(instanceId: String) { + drainScope.launch { + delay(3_000) + drainOutboxForInstance(instanceId) + } + } + + private suspend fun handlePublicOutboxSend( + instanceId: String, + row: ru.fromchat.db.Outbox, + ): Boolean { + val sendResult = runCatching { + val payload = json.decodeFromString(row.payloadJson) + ApiClient.sendMessageViaHttp( + content = payload.content, + replyToId = payload.replyToId, + clientMessageId = row.clientMessageId, + ) + } + var ok = true + sendResult.onSuccess { confirmed -> + withContext(Dispatchers.Default) { + MessageCacheStore.confirmPublicMessage( + row.clientMessageId, + confirmed.copy(client_message_id = row.clientMessageId), + ) + MessageDatabaseProvider.database.messageDatabaseQueries + .deleteOutboxItem(instanceId, row.clientMessageId) + } + }.onFailure { error -> + when { + error.isOutboundPermanentFailure() -> { + val errorKey = outboundFailureErrorKey(error) + withContext(Dispatchers.Default) { + MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId) + } + OutboundSendNotifier.emit( + OutboundSendProgress.Failed(row.clientMessageId, errorKey), + ) + } + error.isOutboundTransientFailure() -> { + ok = false + scheduleOutboxRetry(instanceId) + } + } + } + return ok + } + + private suspend fun handleDmOutboxSend( + instanceId: String, + row: ru.fromchat.db.Outbox, + ): Boolean { + val sendResult = runCatching { + val payload = json.decodeFromString(row.payloadJson) + ApiClient.sendDm( + recipientId = payload.recipientId, + plaintext = payload.plaintext, + clientMessageId = payload.clientMessageId, + replyToId = payload.replyToId, + transportFiles = payload.transportFiles, + uploadedFileIds = payload.uploadedFileIds, + ) + } + var ok = true + sendResult.onSuccess { + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries + .deleteOutboxItem(instanceId, row.clientMessageId) + } + }.onFailure { error -> + when { + error.isOutboundPermanentFailure() -> { + val errorKey = outboundFailureErrorKey(error) + withContext(Dispatchers.Default) { + MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId) + } + OutboundSendNotifier.emit( + OutboundSendProgress.Failed(row.clientMessageId, errorKey), + ) + } + error.isOutboundTransientFailure() -> { + ok = false + scheduleOutboxRetry(instanceId) + } + } + } + return ok + } + private fun kickOutboxDrain(instanceId: String) { val id = instanceId.trim() if (id.isEmpty()) return @@ -253,60 +348,19 @@ object OutgoingMessageCoordinator { for (row in rows) { when (row.kind) { KIND_SEND_PUBLIC -> { - val sendResult = runCatching { - val payload = json.decodeFromString(row.payloadJson) - ApiClient.sendMessageViaHttp(payload.content, payload.replyToId) - } - sendResult.onSuccess { - withContext(Dispatchers.Default) { - MessageDatabaseProvider.database.messageDatabaseQueries - .deleteOutboxItem(id, row.clientMessageId) - } - }.onFailure { error -> - when { - error.isOutboundPermanentFailure() -> { - val errorKey = outboundFailureErrorKey(error) - withContext(Dispatchers.Default) { - MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId) - } - OutboundSendNotifier.emit( - OutboundSendProgress.Failed(row.clientMessageId, errorKey), - ) - } - error.isOutboundTransientFailure() -> { - allOk = false - drainScope.launch { - delay(3_000) - drainOutboxForInstance(id) - } - } - } + if (!handlePublicOutboxSend(id, row)) { + allOk = false } } KIND_SEND_DM -> { - runCatching { - val payload = json.decodeFromString(row.payloadJson) - ApiClient.sendDm( - recipientId = payload.recipientId, - plaintext = payload.plaintext, - clientMessageId = payload.clientMessageId, - replyToId = payload.replyToId, - transportFiles = payload.transportFiles, - uploadedFileIds = payload.uploadedFileIds, - ) - withContext(Dispatchers.Default) { - MessageDatabaseProvider.database.messageDatabaseQueries - .deleteOutboxItem(id, row.clientMessageId) - } + if (!handleDmOutboxSend(id, row)) { + allOk = false } } KIND_SEND_DM_ATTACHMENT -> { if (!DmAttachmentOutboxHandler.process(row)) { allOk = false - drainScope.launch { - delay(3_000) - drainOutboxForInstance(id) - } + scheduleOutboxRetry(id) } } KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> Unit diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt index 30962da..0e46e6b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt @@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class SendMessageRequest( val content: String, - val reply_to_id: Int? = null + val reply_to_id: Int? = null, + val client_message_id: String? = null, ) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index daff5b2..5d30165 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -66,7 +66,6 @@ import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.send.OutgoingMessageCoordinator -import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.config.ServerConfig @@ -278,10 +277,7 @@ fun App( MainScope().launch { val instanceId = CacheContext.activeInstanceId.value.trim() if (instanceId.isNotEmpty()) { - scheduleOutboxProcessing(instanceId) - kotlinx.coroutines.withContext(Dispatchers.Default) { - OutgoingMessageCoordinator.drainOutboxForInstance(instanceId) - } + OutgoingMessageCoordinator.onTransportReady() } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt index 6f4b1e2..6c10e90 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt @@ -1,7 +1,5 @@ package ru.fromchat.ui.auth.register -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -12,7 +10,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -20,16 +17,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.about_link_privacy -import ru.fromchat.about_link_terms import ru.fromchat.auth_char_count -import ru.fromchat.auth_legal_notice_and -import ru.fromchat.auth_legal_notice_prefix import ru.fromchat.auth_step_profile_body import ru.fromchat.auth_step_profile_title import ru.fromchat.auth_username_taken @@ -38,8 +30,6 @@ import ru.fromchat.display_name_error import ru.fromchat.error_unexpected import ru.fromchat.profile_headline_bio import ru.fromchat.register_button -import ru.fromchat.legal.DocumentType -import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.auth.RegisterResult import ru.fromchat.ui.auth.register import ru.fromchat.ui.components.ActionButton @@ -70,7 +60,6 @@ internal fun profileStepPage( onSnackbar: (String) -> Unit, ): ExpressiveStepPage { val scope = rememberCoroutineScope() - val navController = LocalNavController.current val fieldColors = expressiveStepFieldColors() val colorScheme = MaterialTheme.colorScheme @@ -134,36 +123,6 @@ internal fun profileStepPage( ) }, button = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp), - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(Res.string.auth_legal_notice_prefix), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Terms)) }) { - Text( - text = stringResource(Res.string.about_link_terms), - style = MaterialTheme.typography.bodySmall, - ) - } - Text( - text = stringResource(Res.string.auth_legal_notice_and), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Privacy)) }) { - Text( - text = stringResource(Res.string.about_link_privacy), - style = MaterialTheme.typography.bodySmall, - ) - } - } ActionButton( onClick = { if (busy) return@ActionButton diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index eb14ec8..9a40b81 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -204,11 +204,13 @@ fun ChatScreen( } } - // Subscribe to other user's status when DM is visible; unsubscribe on leave - LaunchedEffect(panelState.profileUserId) { + // Subscribe to other user's status when DM is visible; re-subscribe after reconnect + LaunchedEffect(panelState.profileUserId, connectionStatus) { val userId = panelState.profileUserId if (userId != null) { - runCatching { ApiClient.sendSubscribeStatus(userId) } + if (connectionStatus == ConnectionStatus.CONNECTED) { + runCatching { ApiClient.sendSubscribeStatus(userId) } + } try { kotlinx.coroutines.awaitCancellation() } finally { @@ -302,10 +304,10 @@ fun ChatScreen( val lastSeen = data["lastSeen"]?.jsonPrimitive?.content if (userId != null) UserStatusStore.update(userId, online, lastSeen) } - "newMessage", "messageEdited", "messageDeleted", + "newMessage", "messageEdited", "messageDeleted", "sendMessage", "dmNew", "dmEdited", "dmDeleted", "typing", "stopTyping", "dmTyping", "stopDmTyping", - "registeredUserCount" -> { + "reactionUpdate", "registeredUserCount" -> { Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}") try { panel.handleWebSocketMessage(wsMessage) @@ -327,7 +329,8 @@ fun ChatScreen( if (userId != null) UserStatusStore.update(userId, online, lastSeen) } "newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted", - "dmTyping", "stopDmTyping", "registeredUserCount" -> { + "dmTyping", "stopDmTyping", "typing", "stopTyping", "reactionUpdate", + "registeredUserCount" -> { scope.launch { panel.handleWebSocketMessage(message) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index 65eb057..dfbe98a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -79,13 +79,7 @@ class DmPanel( } init { - updateState { - it.copy( - title = "", - titleAvatar = null, - profileUserId = otherUserId - ) - } + applyCachedPeerProfileOrReset() coroutineScope.launch { typingHandler.typingUsers.collect { users -> updateState { it.copy(typingUsers = users) } @@ -108,39 +102,77 @@ class DmPanel( } } coroutineScope.launch(Dispatchers.Default) { + if (_state.title.isBlank()) { + loadPeerTitleFromConversationCache() + } runCatching { ApiClient.getProfileById(otherUserId) }.onSuccess { profile -> if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { ProfileCache.evictUnusableClientPreview(otherUserId) - updateState { - it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) + if (_state.title.isBlank()) { + updateState { + it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) + } } return@onSuccess } ProfileCache.put(profile) val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty() - otherDisplayName = displayName - otherProfilePicture = profile.profilePicture - updateState { - it.copy( - title = displayName, - titleAvatar = AvatarInfo( - displayName = displayName, - profilePictureUrl = otherProfilePicture - ), - profileUserId = otherUserId - ) + if (displayName.isNotBlank()) { + applyPeerTitle(displayName, profile.profilePicture) } }.onFailure { ProfileCache.evictUnusableClientPreview(otherUserId) - updateState { - it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) + if (_state.title.isBlank()) { + updateState { + it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) + } } } } } + private fun applyCachedPeerProfileOrReset() { + val cached = ProfileCache.get(otherUserId) + val displayName = cached?.visibleDisplayName(ApiClient.user?.id).orEmpty() + if (displayName.isNotBlank()) { + applyPeerTitle(displayName, cached?.profilePicture) + } else { + updateState { + it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) + } + } + } + + private fun applyPeerTitle(displayName: String, profilePicture: String?) { + otherDisplayName = displayName + otherProfilePicture = profilePicture + updateState { + it.copy( + title = displayName, + titleAvatar = AvatarInfo( + displayName = displayName, + profilePictureUrl = profilePicture, + ), + profileUserId = otherUserId, + ) + } + } + + private suspend fun loadPeerTitleFromConversationCache() { + val conversation = runCatching { + MessageCacheStore.loadCachedDmConversations() + }.getOrNull()?.find { it.otherUserId == otherUserId } ?: return + val displayName = conversation.displayName.takeIf { it.isNotBlank() } ?: return + ProfileCache.mergeFromCachedConversation(conversation) + withContext(Dispatchers.Main) { + if (_state.title.isBlank()) { + applyPeerTitle(displayName, ProfileCache.get(otherUserId)?.profilePicture) + } + } + } + override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { val cid = clientMessageId?.trim().orEmpty() if (cid.isEmpty()) return @@ -313,14 +345,15 @@ class DmPanel( if (envelope.senderId == currentUserId) { mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) } else { - addMessage(createMessage(envelope, outcome.plaintext, outcome.isCorrupted)) + val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted) + withContext(Dispatchers.Default) { + MessageCacheStore.upsertDmMessage(otherUserId, incoming) + } + addMessage(incoming) if (envelope.replyToId != null) { val replyTo = _state.messages.find { it.id == envelope.replyToId } updateMessage(envelope.id) { it.copy(reply_to = replyTo) } } - scope.launch(Dispatchers.Default) { - MessageCacheStore.replaceDmMessages(otherUserId, _state.messages) - } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt index 4aaeee7..f906048 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt @@ -68,13 +68,18 @@ class PublicChatPanel( get() = true init { - updateState { - it.copy( - title = "", - titleAvatar = null, - publicGroupMetaLoading = true, - publicGroupMemberCount = null - ) + val cachedProfile = PublicChatProfileCache.profile + if (cachedProfile != null) { + applyPublicChatProfile(cachedProfile) + } else { + updateState { + it.copy( + title = "", + titleAvatar = null, + publicGroupMetaLoading = true, + publicGroupMemberCount = null, + ) + } } scope.launch { typingHandler.typingUsers.collect { users -> @@ -83,17 +88,13 @@ class PublicChatPanel( } } scope.launch(Dispatchers.Default) { - val cached = PublicChatProfileCache.profile - if (cached != null) { - applyPublicChatProfile(cached) - } runCatching { ApiClient.getPublicChatProfile() } .onSuccess { profile -> PublicChatProfileCache.put(profile) applyPublicChatProfile(profile) } .onFailure { - if (cached == null) { + if (cachedProfile == null) { updateState { s -> s.copy(publicGroupMetaLoading = false) } } } @@ -118,41 +119,39 @@ class PublicChatPanel( } /** - * Server often omits [Message.client_message_id] on broadcast [newMessage] / [SendMessageResponse.message]. - * Match the oldest pending optimistic row (same user, text, reply) and replace it; otherwise append. + * Match optimistic rows via [Message.client_message_id] from the server ack (never by text). */ private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) { val uid = currentUserId - if (uid == null) { - addMessage(newMsg) - return + if (uid != null && newMsg.user_id == uid) { + val cid = newMsg.client_message_id?.trim().orEmpty() + if (cid.isNotEmpty()) { + handleMessageConfirmed(cid, newMsg) + return + } + if (newMsg.id > 0 && _state.messages.any { it.id == newMsg.id }) { + return + } } - if (newMsg.user_id != uid) { - addMessage(newMsg) - return - } - if (newMsg.client_message_id != null) { - handleMessageConfirmed(newMsg.client_message_id, newMsg) - return - } - if (newMsg.id <= 0) { - addMessage(newMsg) - return - } - val pending = _state.messages.firstOrNull { msg -> - msg.id < 0 && - msg.user_id == uid && - msg.client_message_id != null && - msg.content == newMsg.content && - msg.reply_to?.id == newMsg.reply_to?.id - } - if (pending?.client_message_id != null) { - handleMessageConfirmed(pending.client_message_id, newMsg) - } else { - addMessage(newMsg) + ingestIncomingPublicMessage(newMsg) + } + + private suspend fun ingestIncomingPublicMessage(newMsg: Message) { + addMessage(newMsg) + withContext(Dispatchers.Default) { + MessageCacheStore.upsertPublicMessage(newMsg) } } + private fun mergeNetworkHistoryWithShown(shown: List, fromNetwork: List): List { + val networkIds = fromNetwork.map { it.id }.toSet() + val ahead = shown.filter { it.id > 0 && it.id !in networkIds } + if (ahead.isEmpty()) return fromNetwork + return ru.fromchat.api.local.messages.sortMessagesForChatDisplay( + ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(fromNetwork + ahead), + ) + } + override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { val cid = clientMessageId?.trim().orEmpty() if (cid.isEmpty()) return @@ -224,15 +223,17 @@ class PublicChatPanel( if (_state.isLoading) setLoading(false) } else { batchStateUpdates { + val merged = mergeNetworkHistoryWithShown(shown, response.messages) clearMessages() - addMessages(response.messages) + addMessages(merged) setHasMoreMessages(false) // TODO: Implement has_more from API setLoading(false) } } } withContext(Dispatchers.Default) { - MessageCacheStore.replacePublicMessages(response.messages) + val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, response.messages) + MessageCacheStore.replacePublicMessages(mergedForCache) } } else if (responseResult.isFailure) { val cause = responseResult.exceptionOrNull() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt index 018e31e..f2434b7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt @@ -10,11 +10,18 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.db.store.ConnectionStatus +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.ui.chat.ChatScreen import ru.fromchat.ui.chat.utils.PublicChatPanelCache +import ru.fromchat.utils.NetworkConnectivity @Composable fun PublicChatScreen( @@ -32,6 +39,8 @@ fun PublicChatScreen( } val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + val online by NetworkConnectivity.isOnline.collectAsState(initial = true) + val connectionStatus by ConnectionStateStore.status.collectAsState() LaunchedEffect(panel, activeInstanceId) { if (activeInstanceId.isBlank()) return@LaunchedEffect @@ -40,6 +49,15 @@ fun PublicChatScreen( } } + LaunchedEffect(activeInstanceId, online, connectionStatus) { + val instanceId = activeInstanceId.trim() + if (instanceId.isBlank() || !online) return@LaunchedEffect + scheduleOutboxProcessing(instanceId) + withContext(Dispatchers.Default) { + OutgoingMessageCoordinator.drainOutboxForInstance(instanceId) + } + } + LaunchedEffect(panel, activeInstanceId) { if (activeInstanceId.isBlank()) return@LaunchedEffect MessageRepository.observePublicMessages().collect { rows -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Shimmer.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Shimmer.kt new file mode 100644 index 0000000..8bedde4 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Shimmer.kt @@ -0,0 +1,54 @@ +package ru.fromchat.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape + +@Composable +fun shimmerBrush( + baseColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f), + highlightColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f), +): Brush { + val transition = rememberInfiniteTransition(label = "shimmer") + val translate by transition.animateFloat( + initialValue = -400f, + targetValue = 400f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmer_translate", + ) + return Brush.linearGradient( + colors = listOf(baseColor, highlightColor, baseColor), + start = Offset(translate, translate), + end = Offset(translate + 400f, translate + 400f), + ) +} + +@Composable +fun ShimmerBox( + modifier: Modifier = Modifier, + shape: Shape = CircleShape, +) { + Box( + modifier = modifier + .clip(shape) + .background(shimmerBrush()), + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index a438acd..d179105 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -46,6 +47,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin @@ -68,10 +71,15 @@ import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatus import ru.fromchat.api.local.db.store.visibleUsername +import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator +import ru.fromchat.api.local.messages.ChatListPreviewState import ru.fromchat.api.schema.user.User +import ru.fromchat.cd_chat_preview_sending +import ru.fromchat.cd_chat_preview_uploading import ru.fromchat.cd_chat_selected import ru.fromchat.presence_online import ru.fromchat.ui.chat.Avatar +import ru.fromchat.ui.chat.ExpressiveUploadIndicator import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.components.Text import ru.fromchat.unread_count @@ -118,7 +126,7 @@ internal fun ChatConversationsList( listFilter: ChatListFilter, conversations: List, publicChatTitle: String?, - publicLastMessagePreview: String?, + publicChatPreviewState: ChatListPreviewState?, defaultLastMessage: String, statusMap: Map, listMode: ChatsListMode, @@ -175,7 +183,7 @@ internal fun ChatConversationsList( val position = listItemPositionInGroup(0, groupCount) PublicChatRow( publicChatTitle = publicChatTitle, - publicLastMessagePreview = publicLastMessagePreview, + publicChatPreviewState = publicChatPreviewState, defaultLastMessage = defaultLastMessage, lazyIndex = ChatListLayout.PUBLIC_CHAT_ROW, listMode = listMode, @@ -507,7 +515,7 @@ internal fun ChatRowAvatar( @Composable internal fun PublicChatRow( publicChatTitle: String?, - publicLastMessagePreview: String?, + publicChatPreviewState: ChatListPreviewState?, defaultLastMessage: String, lazyIndex: Int, listMode: ChatsListMode, @@ -569,7 +577,7 @@ internal fun PublicChatRow( ) { PublicChatRowContent( publicChatTitle = publicChatTitle, - publicLastMessagePreview = publicLastMessagePreview, + publicChatPreviewState = publicChatPreviewState, defaultLastMessage = defaultLastMessage, listMode = listMode, selectionTransitionProgress = selectionTransitionProgress, @@ -591,7 +599,7 @@ internal fun PublicChatRow( @Composable internal fun PublicChatRowContent( publicChatTitle: String?, - publicLastMessagePreview: String?, + publicChatPreviewState: ChatListPreviewState?, defaultLastMessage: String, listMode: ChatsListMode, selectionTransitionProgress: Float, @@ -606,11 +614,22 @@ internal fun PublicChatRowContent( onBodyLongPress: () -> Unit, modifier: Modifier = Modifier, ) { - val preview = publicLastMessagePreview ?: defaultLastMessage + val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage ListItem( headline = publicChatTitle.orEmpty(), - supportingText = if (publicChatTitle != null) preview else null, + supportingSlot = if (publicChatTitle != null) { + { + ChatListPreviewSupportingText( + preview = preview, + pendingIndicator = publicChatPreviewState?.pendingIndicator + ?: ChatListPreviewPendingIndicator.None, + uploadProgress = publicChatPreviewState?.uploadProgress, + ) + } + } else { + null + }, containerColor = Color.Transparent, position = listItemPosition, groupItemCount = groupItemCount, @@ -783,11 +802,10 @@ internal fun DmConversationRowContent( overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.primary, ) - else -> Text( - text = preview, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, + else -> ChatListPreviewSupportingText( + preview = preview, + pendingIndicator = conversation.lastMessagePendingIndicator, + uploadProgress = conversation.lastMessageUploadProgress, ) } } @@ -863,6 +881,65 @@ internal fun chatContextMenuClampedMenuX( } internal const val ChatRowContextMenuPressScale = 0.96f + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ChatListPreviewSupportingText( + preview: String, + pendingIndicator: ChatListPreviewPendingIndicator, + uploadProgress: Int?, + modifier: Modifier = Modifier, +) { + val previewColor = MaterialTheme.colorScheme.onSurfaceVariant + val indicatorColor = previewColor.copy(alpha = 0.7f) + val sendingCd = stringResource(Res.string.cd_chat_preview_sending) + val uploadingCd = stringResource(Res.string.cd_chat_preview_uploading) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + when (pendingIndicator) { + ChatListPreviewPendingIndicator.SendingText -> { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = indicatorColor, + ) + Spacer(modifier = Modifier.width(6.dp)) + } + ChatListPreviewPendingIndicator.UploadingFile -> { + Box( + modifier = Modifier.size(12.dp), + contentAlignment = Alignment.Center, + ) { + ExpressiveUploadIndicator( + uploadProgress = uploadProgress, + modifier = Modifier.fillMaxSize(), + indicatorColor = indicatorColor, + trackColorOverride = indicatorColor.copy(alpha = 0.35f), + ) + } + Spacer(modifier = Modifier.width(6.dp)) + } + ChatListPreviewPendingIndicator.None -> Unit + } + Text( + text = preview, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = previewColor, + modifier = when (pendingIndicator) { + ChatListPreviewPendingIndicator.SendingText -> + Modifier.semantics { contentDescription = "$sendingCd. $preview" } + ChatListPreviewPendingIndicator.UploadingFile -> + Modifier.semantics { contentDescription = "$uploadingCd. $preview" } + ChatListPreviewPendingIndicator.None -> Modifier + }, + ) + } +} + internal val ChatRowPressSpring = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt index 23d7d4f..041f11a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt @@ -66,6 +66,9 @@ import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.schema.user.User import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_preview_attachment +import ru.fromchat.chat_preview_image +import ru.fromchat.chat_preview_image_emoji +import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.search_hint import ru.fromchat.search_not_found import ru.fromchat.search_not_found_message @@ -86,8 +89,13 @@ fun ChatsSearchScreen( var searchText by remember { mutableStateOf("") } val searchListState = rememberLazyListState() val statusMap by UserStatusStore.status.collectAsState() + val imageEmoji = stringResource(Res.string.chat_preview_image_emoji) + val previewStrings = ChatListPreviewStrings( + imageEmoji = imageEmoji, + imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji), + attachmentOnly = stringResource(Res.string.chat_preview_attachment), + ) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) - val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment) val searchHint = stringResource(Res.string.search_hint) val searchBarHint = stringResource(Res.string.search_title) var dmConversations by remember { mutableStateOf>(emptyList()) } @@ -163,7 +171,7 @@ fun ChatsSearchScreen( }.onSuccess { conversations -> runCatching { conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } - MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview) + MessageRepository.replaceDmConversations(conversations, previewStrings) dmConversations = MessageRepository.loadCachedDmConversations() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSelectionState.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSelectionState.kt index 8b87218..06ee68e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSelectionState.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSelectionState.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.IntSize import com.pr0gramm3r101.components.ListItemPosition import ru.fromchat.api.local.db.store.CachedConversation +import ru.fromchat.api.local.messages.ChatListPreviewState import ru.fromchat.api.local.db.store.UserStatus enum class ChatsListMode { @@ -83,7 +84,7 @@ data class ChatContextMenuOverlayUiState( val blurProgress: Float = 0f, val listFilter: ChatListFilter = ChatListFilter.Active, val publicChatTitle: String? = null, - val publicLastMessagePreview: String? = null, + val publicChatPreviewState: ChatListPreviewState? = null, val publicChatLink: String? = null, val defaultLastMessage: String = "", val conversations: List = emptyList(), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index a8529ad..067c29c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -88,6 +88,9 @@ import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStatus +import ru.fromchat.api.local.messages.ChatListPreviewState +import ru.fromchat.api.local.messages.ChatListPreviewStrings +import ru.fromchat.api.local.db.store.MessageCacheStore import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache @@ -100,6 +103,8 @@ import ru.fromchat.chat_delete_confirm_body import ru.fromchat.chat_delete_confirm_title import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_preview_attachment +import ru.fromchat.chat_preview_image +import ru.fromchat.chat_preview_image_emoji import ru.fromchat.chats_selected_count import ru.fromchat.config.ServerConfig import ru.fromchat.search_title @@ -281,7 +286,7 @@ fun ChatsTab( val connectionStatus by ConnectionStateStore.status.collectAsState() val online by NetworkConnectivity.isOnline.collectAsState(initial = true) var dmConversations by remember { mutableStateOf>(emptyList()) } - var publicLastMessagePreview by remember { mutableStateOf(null) } + var publicChatPreviewState by remember { mutableStateOf(null) } var publicChatProfile by remember { mutableStateOf(PublicChatProfileCache.profile) } val searchBarHint = stringResource(Res.string.search_title) val tabListState = rememberLazyListState() @@ -289,9 +294,22 @@ fun ChatsTab( var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } val statusSubscriptionScope = rememberCoroutineScope() val suspensionState by ApiClient.suspensionState.collectAsState() - val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment) + val imageEmoji = stringResource(Res.string.chat_preview_image_emoji) + val previewStrings = ChatListPreviewStrings( + imageEmoji = imageEmoji, + imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji), + attachmentOnly = stringResource(Res.string.chat_preview_attachment), + ) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) + LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) { + MessageCacheStore.listPreviewStrings = previewStrings + } + + SideEffect { + MessageCacheStore.listPreviewStrings = previewStrings + } + var listMode by remember { mutableStateOf(ChatsListMode.Normal) } var publicChatSelected by remember { mutableStateOf(false) } var selectedOtherUserIds by remember { mutableStateOf>(emptySet()) } @@ -399,7 +417,7 @@ fun ChatsTab( onDispose { chatContextMenuOverlay.clear() } } - LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) { + LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch, connectionStatus) { snapshotFlow { if (!isVisible) { emptySet() @@ -415,8 +433,10 @@ fun ChatsTab( } .distinctUntilChanged() .collect { visibleIds -> - (visibleIds - subscribedDmUserIds).forEach { userId -> - runCatching { ApiClient.sendSubscribeStatus(userId) } + if (connectionStatus == ConnectionStatus.CONNECTED) { + visibleIds.forEach { userId -> + runCatching { ApiClient.sendSubscribeStatus(userId) } + } } (subscribedDmUserIds - visibleIds).forEach { userId -> @@ -441,6 +461,32 @@ fun ChatsTab( val serverConfig by ServerConfig.serverConfig.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) { + if (activeInstanceId.isBlank()) { + publicChatPreviewState = null + return@LaunchedEffect + } + publicChatProfile = PublicChatProfileCache.profile + runCatching { + publicChatPreviewState = MessageRepository.loadRecentPublicChatPreviewState(previewStrings) + } + } + + LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) { + if (activeInstanceId.isBlank()) return@LaunchedEffect + MessageRepository.observeActiveDmConversations().collect { conversations -> + conversations.forEach { ProfileCache.mergeFromCachedConversation(it) } + dmConversations = conversations + } + } + + LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) { + if (activeInstanceId.isBlank()) return@LaunchedEffect + MessageRepository.observePublicChatPreviewState(previewStrings).collect { state -> + publicChatPreviewState = state + } + } + LaunchedEffect(serverConfig, activeInstanceId) { if (activeInstanceId.isBlank()) return@LaunchedEffect @@ -451,21 +497,12 @@ fun ChatsTab( dmConversations = conversations } - runCatching { - publicLastMessagePreview = MessageRepository - .loadRecentPublicMessages(1) - .lastOrNull() - ?.content - ?.trim() - ?.takeIf { it.isNotEmpty() } - } - runCatching { ApiClient.getDmConversations() }.onSuccess { conversations -> runCatching { conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } - MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview) + MessageRepository.replaceDmConversations(conversations, previewStrings) dmConversations = MessageRepository.loadCachedDmConversations() } } @@ -652,7 +689,7 @@ fun ChatsTab( listFilter = ChatListFilter.Active, conversations = dmConversations, publicChatTitle = publicChatTitle, - publicLastMessagePreview = publicLastMessagePreview, + publicChatPreviewState = publicChatPreviewState, defaultLastMessage = defaultLastMessage, statusMap = statusMap, listMode = listMode, @@ -813,7 +850,7 @@ fun ChatsTab( blurProgress = chatContextMenuOverlay.blurProgress, listFilter = ChatListFilter.Active, publicChatTitle = publicChatTitle, - publicLastMessagePreview = publicLastMessagePreview, + publicChatPreviewState = publicChatPreviewState, publicChatLink = publicChatLink, defaultLastMessage = defaultLastMessage, conversations = dmConversations, @@ -1238,7 +1275,7 @@ private fun ChatContextMenuOverlay( ChatContextMenuTarget.Public -> { PublicChatRowContent( publicChatTitle = uiState.publicChatTitle, - publicLastMessagePreview = uiState.publicLastMessagePreview, + publicChatPreviewState = uiState.publicChatPreviewState, defaultLastMessage = uiState.defaultLastMessage, listMode = uiState.listMode, selectionTransitionProgress = uiState.selectionTransitionProgress, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt index 7ca9b1f..3f81d7f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt @@ -2,12 +2,14 @@ package ru.fromchat.ui.main.settings +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding @@ -66,6 +68,8 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res @@ -96,6 +100,16 @@ import ru.fromchat.ui.components.HazeActionButton import ru.fromchat.ui.components.Text import ru.fromchat.unknown +private const val DEVICE_SESSIONS_POLL_INTERVAL_MS = 15_000L + +private fun enrichDevicesList(list: List): List { + val currentIndex = list.indexOfFirst { it.current } + if (currentIndex < 0) return list + return list + .toMutableList() + .also { it[currentIndex] = deviceSessionForCurrentDevice(it[currentIndex]) } +} + private fun formatDeviceLine(d: DeviceSessionInfo, fallbackLabel: String) = listOfNotNull( d.deviceName, @@ -339,8 +353,10 @@ fun DevicesScreen(onBack: () -> Unit) { val snackbarHostState = remember { SnackbarHostState() } val initialCache = remember { Settings.readDeviceSessionsCache() } - var devices by remember { mutableStateOf(initialCache) } - var loading by remember { mutableStateOf(initialCache == null) } + var devices by remember { + mutableStateOf(initialCache?.let(::enrichDevicesList).orEmpty()) + } + var refreshing by remember { mutableStateOf(initialCache == null) } var sheetDevice by remember { mutableStateOf(null) } var sheetSigningOut by remember { mutableStateOf(false) } var showLogoutAllConfirm by remember { mutableStateOf(false) } @@ -351,41 +367,27 @@ fun DevicesScreen(onBack: () -> Unit) { sheetSigningOut = false } - fun reload() { - scope.launch { - if (devices == null) loading = true + suspend fun fetchDevices() { + refreshing = true - devices = runCatching { ApiClient.listDevices() } - .let { - if (it.isSuccess) { - Settings.writeDeviceSessionsCache(it.getOrNull()!!) - it.getOrNull()!! - } else { - snackbarHostState.showSnackbar(errUnexpected) + runCatching { ApiClient.listDevices() } + .onSuccess { list -> + Settings.writeDeviceSessionsCache(list) + devices = enrichDevicesList(list) + } + .onFailure { + snackbarHostState.showSnackbar(errUnexpected) + } - if (devices == null) { - emptyList() - } else { - devices - } - } - }?.let { - it.indexOfFirst { it.current }.let { index -> - it - .toMutableList() - .also { - it[index] = deviceSessionForCurrentDevice(it[index]) - } - .toList() - } - } - - loading = false - } + refreshing = false } LaunchedEffect(Unit) { - reload() + fetchDevices() + while (isActive) { + delay(DEVICE_SESSIONS_POLL_INTERVAL_MS) + fetchDevices() + } } Scaffold( @@ -414,7 +416,7 @@ fun DevicesScreen(onBack: () -> Unit) { ) }, bottomBar = { - if (devices?.let { it.size > 1 } == true) { + if (devices.size > 1) { HazeActionButton( hazeState = hazeState, onClick = { showLogoutAllConfirm = true } @@ -424,114 +426,97 @@ fun DevicesScreen(onBack: () -> Unit) { } } ) { innerPadding -> - if (loading) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .hazeSource(hazeState), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - } - } else { - val active_sessions = stringResource(Res.string.settings_devices_active_sessions) + val activeSessionsTitle = stringResource(Res.string.settings_devices_active_sessions) + val (currentDevice, sessionList) = remember(devices) { + val mutable = devices.toMutableList() + val current = mutable.firstOrNull { it.current } + current?.let { mutable.remove(it) } + current to mutable + } - LazyColumn( - modifier = Modifier - .hazeSource(hazeState) - .padding() - .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), - contentPadding = innerPadding - ) { - item { - Column(Modifier.fillMaxWidth()) { - ExpressiveIconFrame( - icon = Icons.Filled.Devices, - modifier = Modifier.align(Alignment.CenterHorizontally), - materialPolygon = MaterialShapes.VerySunny - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.settings_devices_title), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(16.dp)) - } - } - - var currentDevice: DeviceSessionInfo? = null - val sessionList = devices!! - .toMutableList() - .also { mutable -> - currentDevice = mutable.first { it.current }.also { - mutable.remove(it) - } - } - .toList() - - @Composable - fun Device( - device: DeviceSessionInfo, - divider: Boolean - ) { - val osLogo = remember(device) { deviceSessionLogoResource(device) } - - ListItem( - headline = remember(device) { - deviceHeadline(device, unknownDeviceLabel) - }, - supportingText = stringResource( - Res.string.settings_devices_last_active, - formatDeviceLastSeen(device.lastSeen) - ), - leadingContent = { - if (osLogo != null) { - AsyncImage( - model = Res.getUri(osLogo), - contentDescription = null, - modifier = Modifier.size(24.dp), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) - ) - } else { - Icon( - imageVector = remember(device) { - deviceSessionIcon( - device - ) - }, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - divider = divider, - onClick = { sheetDevice = device } + LazyColumn( + modifier = Modifier + .hazeSource(hazeState) + .padding() + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), + contentPadding = innerPadding + ) { + item { + Column(Modifier.fillMaxWidth()) { + ExpressiveIconFrame( + icon = Icons.Filled.Devices, + modifier = Modifier.align(Alignment.CenterHorizontally), + materialPolygon = MaterialShapes.VerySunny ) - } - if (currentDevice != null) { - item { - Category( - title = stringResource(Res.string.settings_devices_this_device), - margin = PaddingValues(bottom = 20.dp) - ) { - Device(currentDevice, false) + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(Res.string.settings_devices_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + } + } + + @Composable + fun Device( + device: DeviceSessionInfo, + divider: Boolean + ) { + val osLogo = remember(device) { deviceSessionLogoResource(device) } + + ListItem( + headline = remember(device) { + deviceHeadline(device, unknownDeviceLabel) + }, + supportingText = stringResource( + Res.string.settings_devices_last_active, + formatDeviceLastSeen(device.lastSeen) + ), + leadingContent = { + if (osLogo != null) { + AsyncImage( + model = Res.getUri(osLogo), + contentDescription = null, + modifier = Modifier.size(24.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) + ) + } else { + Icon( + imageVector = remember(device) { + deviceSessionIcon(device) + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + }, + divider = divider, + onClick = { sheetDevice = device } + ) + } + + if (currentDevice != null) { + item { + Category( + title = stringResource(Res.string.settings_devices_this_device), + margin = PaddingValues(bottom = 20.dp) + ) { + Device(currentDevice, false) } } + } + if (sessionList.isNotEmpty()) { Category( margin = PaddingValues(bottom = 20.dp), - title = active_sessions + title = activeSessionsTitle ) { sessionList.forEachIndexed { index, it -> item { @@ -540,6 +525,23 @@ fun DevicesScreen(onBack: () -> Unit) { } } } + + item { + AnimatedVisibility( + visible = refreshing, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } } } @@ -564,7 +566,7 @@ fun DevicesScreen(onBack: () -> Unit) { }.onSuccess { sheetState.hide() sheetDevice = null - reload() + fetchDevices() }.onFailure { snackbarHostState.showSnackbar(errUnexpected) } @@ -587,7 +589,7 @@ fun DevicesScreen(onBack: () -> Unit) { showLogoutAllConfirm = false scope.launch { runCatching { ApiClient.revokeAllOtherDeviceSessions() } - .onSuccess { reload() } + .onSuccess { fetchDevices() } .onFailure { snackbarHostState.showSnackbar(errUnexpected) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt index d229929..f4527da 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt @@ -98,6 +98,7 @@ import ru.fromchat.config.DEFAULT_CALLS_PORT import ru.fromchat.config.ServerConfigData import ru.fromchat.config.Settings import ru.fromchat.api.instance.ServerProbeResult +import ru.fromchat.api.instance.ApplyServerResult import ru.fromchat.api.instance.applyServerAndNavigate import ru.fromchat.api.instance.probeServer import ru.fromchat.save_continue @@ -305,15 +306,9 @@ fun ServerConfigScreen() { busy = true val tentative = buildTentativeConfig() ?: return@launch - val probe = if ( - lastProbedConfig == tentative && lastProbe != null - ) { - lastProbe!! - } else { - probeServer(tentative).also { - lastProbe = it - lastProbedConfig = tentative - } + val probe = probeServer(tentative).also { + lastProbe = it + lastProbedConfig = tentative } when (probe) { @@ -332,38 +327,45 @@ fun ServerConfigScreen() { } is ServerProbeResult.Supported -> { - Settings.lastKnownServerInstanceId = - probe.instanceId - - applyServerAndNavigate( - probe = probe, - config = tentative, - bearer = ApiClient.token?.trim().orEmpty(), - onNavigateLogin = { - withContext(Dispatchers.Main) { - navController.navigateAndWipeBackStack("auth") - } - }, - onNavigateChat = { - withContext(Dispatchers.Main) { - if (!navController.popBackStack()) { - navController.navigate("chat") { - popUpTo("welcome") { - inclusive = true + when ( + applyServerAndNavigate( + probe = probe, + config = tentative, + bearer = ApiClient.token?.trim().orEmpty(), + onNavigateLogin = { + withContext(Dispatchers.Main) { + navController.navigateAndWipeBackStack("auth") + } + }, + onNavigateChat = { + withContext(Dispatchers.Main) { + if (!navController.popBackStack()) { + navController.navigate("chat") { + popUpTo("welcome") { + inclusive = true + } } } } - } - }, - onLogoutOldHost = { - withContext(Dispatchers.Main) { - WebSocketManager.disconnect() - runCatching { ApiClient.logout() } - ApiClient.clearMemorySession() - navController.navigateAndWipeBackStack("auth") - } + }, + onLogoutOldHost = { + withContext(Dispatchers.Main) { + WebSocketManager.disconnect() + runCatching { ApiClient.logout() } + ApiClient.clearMemorySession() + navController.navigateAndWipeBackStack("auth") + } + }, + ) + ) { + ApplyServerResult.Applied -> { + Settings.lastKnownServerInstanceId = + probe.instanceId } - ) + ApplyServerResult.ServerUnreachable -> { + snackbarHostState.showSnackbar(strSnackbarApiFail) + } + } } } } finally { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 67c0c1d..6a1a5c0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -18,6 +18,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -25,17 +28,21 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableIntStateOf import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.navigation.NavController import com.pr0gramm3r101.components.ListItemPosition +import com.pr0gramm3r101.utils.SupportClipboardManager import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile @@ -73,8 +80,9 @@ import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.TextButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration @@ -114,6 +122,7 @@ import ru.fromchat.Logger import ru.fromchat.Res import ru.fromchat.action_copy import ru.fromchat.action_edit +import ru.fromchat.action_retry_send import ru.fromchat.api.ApiClient import ru.fromchat.api.calls.CallStore import ru.fromchat.api.local.db.store.ProfileCache @@ -143,8 +152,10 @@ import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.components.ShimmerBox import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.showReplacingSnackbar +import ru.fromchat.utils.RegistrationDateFormatStrings import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatProfileRegistrationDate import ru.fromchat.utils.haptic.HapticFeedbackEvent @@ -275,6 +286,8 @@ fun ProfileScreen( mutableStateOf(hasDisplayableProfile(state.profile, initialDisplayName, ownUserId)) } + var reloadAttempt by remember(lookupKey) { mutableIntStateOf(0) } + val latestUi by rememberUpdatedState(state) val backStackEntry = navController.currentBackStackEntry @@ -296,7 +309,11 @@ fun ProfileScreen( } } - LaunchedEffect(lookupMode, lookupIdentifier) { + LaunchedEffect(lookupMode, lookupIdentifier, reloadAttempt) { + if (reloadAttempt > 0) { + state = latestUi.copy(isLoading = true, error = null) + } + Logger.d( "ProfileScreen", "load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId" @@ -355,7 +372,7 @@ fun ProfileScreen( val resolvedErrorMessage = when { err is ClientRequestException && err.response.status.value == 404 -> profileNotFound - else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed + else -> profileLoadFailed } if (err is ClientRequestException) { @@ -410,6 +427,7 @@ fun ProfileScreen( val headlineVerification = stringResource(Res.string.profile_headline_verification) val verifiedSupport = stringResource(Res.string.profile_verified_support) val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support) + val labelRetry = stringResource(Res.string.action_retry_send) val profile = state.profile ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId) if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) { @@ -418,7 +436,8 @@ fun ProfileScreen( val statusMap by UserStatusStore.status.collectAsState() val lastSeenFormatStrings = rememberLastSeenFormatStrings() val loadError = state.error - val showLoadingSpinner = state.isLoading && !hasShownContent.value + val hasDisplayable = hasDisplayableProfile(profile, initialDisplayName, ownUserId) + val showSkeleton = !hasDisplayable && (state.isLoading || loadError != null) val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id val displayName = profile?.visibleDisplayName(currentProfileUserId) @@ -426,7 +445,7 @@ fun ProfileScreen( ?: "?" val usernameForLinks = profile?.visibleUsername(currentProfileUserId) - val resolvedProfile = profile?.takeIf { loadError == null && !showLoadingSpinner } + val resolvedProfile = profile?.takeIf { !showSkeleton } val profileLink = resolvedProfile?.let { usernameForLinks?.let { name -> "https://fromchat.ru/@$name" } ?: "https://fromchat.ru/?u=${it.id}" @@ -539,6 +558,18 @@ fun ProfileScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { when { + showSkeleton && !hideAvatar -> { + item { + ShimmerBox( + modifier = Modifier + .padding(top = profileAvatarTop) + .size(104.dp), + shape = CircleShape, + ) + } + item { Spacer(Modifier.height(12.dp)) } + } + useSharedAvatar -> { item { with(sharedTransitionScope) { @@ -597,271 +628,59 @@ fun ProfileScreen( } } - when { - showLoadingSpinner -> { - item { - CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) - } - } - - loadError != null -> { - item { - Text( - text = when (loadError) { - ProfileLoadError.Generic -> profileLoadFailed - is ProfileLoadError.Message -> loadError.text - }, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 24.dp) - ) - } - } - - resolvedProfile != null -> { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - ContextMenuPressable( - pressScale = ProfileDisplayNamePressScale, - onContextMenuOpen = openContextMenuHaptic, - contextMenu = { - item(Icons.Rounded.ContentCopy, labelCopy) { - clipboardManager.setText(AnnotatedString(displayName)) - } - if (isOwnProfile) { - item(Icons.Rounded.Edit, labelEdit) { - navController.navigate( - ProfileRoutes.editRoute(EditProfileFocusField.DisplayName), - ) - } - } - }, - ) { - Text( - text = displayName, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - StatusBadge( - verified = resolvedProfile.verified, - userId = resolvedProfile.id, - ) - } - } - - item { Spacer(Modifier.height(4.dp)) } - - item { - AnimatedContent( - targetState = when { - typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}" - statusState?.online == true -> "online" - else -> "offline" - }, - transitionSpec = { - (slideInVertically { it / 2 } + fadeIn()) togetherWith - (slideOutVertically { -it / 2 } + fadeOut()) - }, - label = "profile_status_${resolvedProfile.id}" - ) { animatedState -> - if (animatedState.startsWith("typing:")) { - TypingIndicator(typingUsers = typingUsers) + item { + AnimatedContent( + targetState = showSkeleton, + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + label = "profile_body", + ) { skeleton -> + if (skeleton) { + ProfileSkeletonBody( + onRetry = if (loadError != null) { + { reloadAttempt++ } } else { - Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - color = if (animatedState == "online") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - } - } - - item { Spacer(Modifier.height(24.dp)) } - - item { - ProfileActionButtonRow( - actions = profileActions.orEmpty(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + null + }, + retryLabel = labelRetry, + ) + } else if (resolvedProfile != null) { + ProfileLoadedBody( + resolvedProfile = resolvedProfile, + displayName = displayName, + isOwnProfile = isOwnProfile, + typingUsers = typingUsers, + statusState = statusState, + statusText = statusText, + profileActions = profileActions.orEmpty(), + showDetailsSection = showDetailsSection, + showDetailsUsername = showDetailsUsername, + showDetailsMemberSince = showDetailsMemberSince, + showDetailsBio = showDetailsBio, + showDetailsVerify = showDetailsVerify, + headlineUsername = headlineUsername, + headlineMemberSince = headlineMemberSince, + headlineBio = headlineBio, + headlineVerification = headlineVerification, + usernameForLinks = usernameForLinks, + verifiedSupport = verifiedSupport, + verifyPromptSupport = verifyPromptSupport, + registrationDateStrings = registrationDateStrings, + listItemIconTint = listItemIconTint, + labelCopy = labelCopy, + labelEdit = labelEdit, + detailsBringIntoView = detailsBringIntoView, + clipboardManager = clipboardManager, + clipboard = clipboard, + navController = navController, + scope = scope, + openContextMenuHaptic = openContextMenuHaptic, + onProfileUpdated = { updated -> + state = state.copy(profile = updated) + ProfileCache.put(updated) + }, ) - } - - if (showDetailsSection) { - val detailCount = listOf( - showDetailsUsername, - showDetailsMemberSince, - showDetailsBio, - showDetailsVerify, - ).count { it } - var detailIndex = 0 - - Category( - modifier = Modifier.bringIntoViewRequester(detailsBringIntoView), - margin = PaddingValues( - start = 16.dp, - end = 16.dp, - top = 28.dp, - bottom = 20.dp, - ), - roundedCorners = false, - ) { - if (showDetailsUsername) { - val position = listItemPositionInGroup(detailIndex, detailCount) - detailIndex++ - item { - ListItem( - headline = headlineUsername, - supportingText = usernameForLinks.orEmpty(), - divider = true, - position = position, - groupItemCount = detailCount, - onContextMenuOpen = openContextMenuHaptic, - leadingContent = { - Icon( - imageVector = Icons.Filled.AlternateEmail, - contentDescription = null, - tint = listItemIconTint, - ) - }, - contextMenu = { - item(Icons.Rounded.ContentCopy, labelCopy) { - clipboardManager.setText( - AnnotatedString(usernameForLinks.orEmpty()), - ) - } - if (isOwnProfile) { - item(Icons.Rounded.Edit, labelEdit) { - navController.navigate( - ProfileRoutes.editRoute(EditProfileFocusField.Username), - ) - } - } - }, - ) - } - } - - if (showDetailsMemberSince) { - val memberSinceText = formatProfileRegistrationDate( - resolvedProfile.createdAt, - registrationDateStrings, - ).orEmpty() - val position = listItemPositionInGroup(detailIndex, detailCount) - detailIndex++ - item { - ListItem( - headline = headlineMemberSince, - supportingText = memberSinceText, - divider = true, - position = position, - groupItemCount = detailCount, - onContextMenuOpen = openContextMenuHaptic, - leadingContent = { - Icon( - imageVector = Icons.Filled.CalendarMonth, - contentDescription = null, - tint = listItemIconTint, - ) - }, - contextMenu = { - item(Icons.Rounded.ContentCopy, labelCopy) { - clipboardManager.setText(AnnotatedString(memberSinceText)) - } - }, - ) - } - } - - if (showDetailsBio) { - val position = listItemPositionInGroup(detailIndex, detailCount) - detailIndex++ - item { - ListItem( - headline = headlineBio, - supportingSlot = { - ProfileBioMarkdown( - content = resolvedProfile.bio.orEmpty(), - ) - }, - divider = true, - position = position, - groupItemCount = detailCount, - onContextMenuOpen = openContextMenuHaptic, - leadingContent = { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = null, - tint = listItemIconTint, - ) - }, - contextMenu = { - item(Icons.Rounded.ContentCopy, labelCopy) { - scope.launch { - clipboard.setText(resolvedProfile.bio.orEmpty()) - } - } - if (isOwnProfile) { - item(Icons.Rounded.Edit, labelEdit) { - navController.navigate( - ProfileRoutes.editRoute(EditProfileFocusField.Bio), - ) - } - } - }, - ) - } - } - - if (showDetailsVerify) { - val position = listItemPositionInGroup(detailIndex, detailCount) - detailIndex++ - item { - ListItem( - headline = headlineVerification, - supportingText = if (resolvedProfile.verified == true) { - verifiedSupport - } else { - verifyPromptSupport - }, - divider = true, - position = position, - groupItemCount = detailCount, - leadingContent = { - Icon( - imageVector = Icons.Filled.Verified, - contentDescription = null, - tint = listItemIconTint, - ) - }, - onClick = if (ApiClient.user?.id == 1) { - { - scope.launch { - val result = withContext(Dispatchers.Default) { - runCatching { - ApiClient.verifyUser(resolvedProfile.id) - }.getOrNull() - } - result?.verified?.let { newVerified -> - val updated = - state.profile?.copy(verified = newVerified) - state = state.copy(profile = updated) - updated?.let { ProfileCache.put(it) } - } - } - } - } else null, - ) - } - } - } } } } @@ -1211,6 +1030,391 @@ private fun targetWeightForPress( return (count * ProfileActionDefaultWeight - ProfileActionPressExpansionRatio) / (count - 1) } +@Composable +private fun ProfileSkeletonBody( + onRetry: (() -> Unit)?, + retryLabel: String, + modifier: Modifier = Modifier, +) { + val barShape = RoundedCornerShape(8.dp) + val pillShape = MaterialTheme.shapes.extraLarge + val listBarShape = RoundedCornerShape(4.dp) + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShimmerBox( + modifier = Modifier + .width(168.dp) + .height(28.dp), + shape = barShape, + ) + Spacer(Modifier.height(8.dp)) + ShimmerBox( + modifier = Modifier + .width(112.dp) + .height(16.dp), + shape = barShape, + ) + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(3) { + ShimmerBox( + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = pillShape, + ) + } + } + Category( + margin = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 28.dp, + bottom = 20.dp, + ), + roundedCorners = false, + ) { + ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true) + ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true) + ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = false) + } + if (onRetry != null) { + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onRetry) { + Text( + text = retryLabel, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ProfileSkeletonListRow( + listBarShape: RoundedCornerShape, + showDivider: Boolean, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ShimmerBox( + modifier = Modifier.size(24.dp), + shape = CircleShape, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.38f) + .height(14.dp), + shape = listBarShape, + ) + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.62f) + .height(12.dp), + shape = listBarShape, + ) + } + } + if (showDivider) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) + } +} + +@Composable +private fun ProfileLoadedBody( + resolvedProfile: UserProfile, + displayName: String, + isOwnProfile: Boolean, + typingUsers: List, + statusState: UserStatus?, + statusText: String, + profileActions: List, + showDetailsSection: Boolean, + showDetailsUsername: Boolean, + showDetailsMemberSince: Boolean, + showDetailsBio: Boolean, + showDetailsVerify: Boolean, + headlineUsername: String, + headlineMemberSince: String, + headlineBio: String, + headlineVerification: String, + usernameForLinks: String?, + verifiedSupport: String, + verifyPromptSupport: String, + registrationDateStrings: RegistrationDateFormatStrings, + listItemIconTint: Color, + labelCopy: String, + labelEdit: String, + detailsBringIntoView: BringIntoViewRequester, + clipboardManager: ClipboardManager, + clipboard: SupportClipboardManager, + navController: NavController, + scope: CoroutineScope, + openContextMenuHaptic: () -> Unit, + onProfileUpdated: (UserProfile) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ContextMenuPressable( + pressScale = ProfileDisplayNamePressScale, + onContextMenuOpen = openContextMenuHaptic, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + clipboardManager.setText(AnnotatedString(displayName)) + } + if (isOwnProfile) { + item(Icons.Rounded.Edit, labelEdit) { + navController.navigate( + ProfileRoutes.editRoute(EditProfileFocusField.DisplayName), + ) + } + } + }, + ) { + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + StatusBadge( + verified = resolvedProfile.verified, + userId = resolvedProfile.id, + ) + } + + Spacer(Modifier.height(4.dp)) + + AnimatedContent( + targetState = when { + typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}" + statusState?.online == true -> "online" + else -> "offline" + }, + transitionSpec = { + (slideInVertically { it / 2 } + fadeIn()) togetherWith + (slideOutVertically { -it / 2 } + fadeOut()) + }, + label = "profile_status_${resolvedProfile.id}", + ) { animatedState -> + if (animatedState.startsWith("typing:")) { + TypingIndicator(typingUsers = typingUsers) + } else { + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = if (animatedState == "online") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + + ProfileActionButtonRow( + actions = profileActions, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + if (showDetailsSection) { + val detailCount = listOf( + showDetailsUsername, + showDetailsMemberSince, + showDetailsBio, + showDetailsVerify, + ).count { it } + var detailIndex = 0 + + Category( + modifier = Modifier.bringIntoViewRequester(detailsBringIntoView), + margin = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 28.dp, + bottom = 20.dp, + ), + roundedCorners = false, + ) { + if (showDetailsUsername) { + val position = listItemPositionInGroup(detailIndex, detailCount) + detailIndex++ + ListItem( + headline = headlineUsername, + supportingText = usernameForLinks.orEmpty(), + divider = true, + position = position, + groupItemCount = detailCount, + onContextMenuOpen = openContextMenuHaptic, + leadingContent = { + Icon( + imageVector = Icons.Filled.AlternateEmail, + contentDescription = null, + tint = listItemIconTint, + ) + }, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + clipboardManager.setText( + AnnotatedString(usernameForLinks.orEmpty()), + ) + } + if (isOwnProfile) { + item(Icons.Rounded.Edit, labelEdit) { + navController.navigate( + ProfileRoutes.editRoute(EditProfileFocusField.Username), + ) + } + } + }, + ) + } + + if (showDetailsMemberSince) { + val memberSinceText = formatProfileRegistrationDate( + resolvedProfile.createdAt, + registrationDateStrings, + ).orEmpty() + val position = listItemPositionInGroup(detailIndex, detailCount) + detailIndex++ + ListItem( + headline = headlineMemberSince, + supportingText = memberSinceText, + divider = true, + position = position, + groupItemCount = detailCount, + onContextMenuOpen = openContextMenuHaptic, + leadingContent = { + Icon( + imageVector = Icons.Filled.CalendarMonth, + contentDescription = null, + tint = listItemIconTint, + ) + }, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + clipboardManager.setText(AnnotatedString(memberSinceText)) + } + }, + ) + } + + if (showDetailsBio) { + val position = listItemPositionInGroup(detailIndex, detailCount) + detailIndex++ + ListItem( + headline = headlineBio, + supportingSlot = { + ProfileBioMarkdown( + content = resolvedProfile.bio.orEmpty(), + ) + }, + divider = true, + position = position, + groupItemCount = detailCount, + onContextMenuOpen = openContextMenuHaptic, + leadingContent = { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = listItemIconTint, + ) + }, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + scope.launch { + clipboard.setText(resolvedProfile.bio.orEmpty()) + } + } + if (isOwnProfile) { + item(Icons.Rounded.Edit, labelEdit) { + navController.navigate( + ProfileRoutes.editRoute(EditProfileFocusField.Bio), + ) + } + } + }, + ) + } + + if (showDetailsVerify) { + val position = listItemPositionInGroup(detailIndex, detailCount) + detailIndex++ + ListItem( + headline = headlineVerification, + supportingText = if (resolvedProfile.verified == true) { + verifiedSupport + } else { + verifyPromptSupport + }, + divider = true, + position = position, + groupItemCount = detailCount, + leadingContent = { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = null, + tint = listItemIconTint, + ) + }, + onClick = if (ApiClient.user?.id == 1) { + { + scope.launch { + val result = withContext(Dispatchers.Default) { + runCatching { + ApiClient.verifyUser(resolvedProfile.id) + }.getOrNull() + } + result?.verified?.let { newVerified -> + onProfileUpdated( + resolvedProfile.copy(verified = newVerified), + ) + } + } + } + } else null, + ) + } + } + } + } +} + @Composable private fun ProfileBioMarkdown( content: String,