From dfda05465b42e480a48f0d0d3097ede1cee44b64 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 4 Apr 2026 00:39:39 +0300 Subject: [PATCH] Translate to Russian --- .cursor/rules/android-build-deploy-device.mdc | 2 +- .cursor/rules/general.mdc | 2 +- .cursor/rules/strings-simple-i18n.mdc | 17 + .../ru/fromchat/ui/debug/DebugApiScreen.kt | 340 +------------- .../composeResources/values-ru/strings.xml | 122 ++++- .../composeResources/values/strings.xml | 178 ++++--- .../kotlin/ru/fromchat/crypto/DmCrypto.kt | 4 +- .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 38 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 42 +- .../ui/chat/ImageFullscreenPreview.kt | 21 +- .../ru/fromchat/ui/chat/MessageContextMenu.kt | 12 +- .../ru/fromchat/ui/chat/MessageDisplayName.kt | 25 + .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 22 +- .../ru/fromchat/ui/chat/PublicChatPanel.kt | 24 +- .../fromchat/ui/chat/PublicChatPanelCache.kt | 29 +- .../ru/fromchat/ui/chat/PublicChatScreen.kt | 8 +- .../ui/debug/DebugApiScreenContent.kt | 444 ++++++++++++++++++ .../kotlin/ru/fromchat/ui/main/ChatsTab.kt | 42 +- .../kotlin/ru/fromchat/ui/main/MainScreen.kt | 7 +- .../kotlin/ru/fromchat/ui/main/SettingsTab.kt | 30 +- .../ru/fromchat/ui/profile/ProfileScreen.kt | 68 ++- .../ru/fromchat/ui/profile/StatusBadge.kt | 13 +- .../ru/fromchat/ui/profile/VerifyButton.kt | 7 +- .../fromchat/ui/setup/ServerConfigScreen.kt | 11 +- .../ru/fromchat/utils/LastSeenFormat.kt | 135 ++++++ .../kotlin/ru/fromchat/utils/RelativeTime.kt | 61 --- .../fromchat/ui/debug/DebugApiScreen.ios.kt | 285 +---------- 27 files changed, 1111 insertions(+), 878 deletions(-) create mode 100644 .cursor/rules/strings-simple-i18n.mdc create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt delete mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt diff --git a/.cursor/rules/android-build-deploy-device.mdc b/.cursor/rules/android-build-deploy-device.mdc index 80dd976..b144e5d 100644 --- a/.cursor/rules/android-build-deploy-device.mdc +++ b/.cursor/rules/android-build-deploy-device.mdc @@ -14,7 +14,7 @@ Whenever you change anything under **`app:android`**, **`app:shared`** (`commonM 5. For **each** chosen device `id`: **`mobile_install_app`** with `path` = absolute path to `app/android/build/outputs/apk/debug/android-debug.apk` in this repo. -6. For **each** same device: **`mobile_launch_app`** with `packageName` **`ru.fromchat.beta`** (debug `applicationIdSuffix`). +6. For **each** same device: **`mobile_launch_app`** with `packageName` **`ru.fromchat.beta`**. The debug APK from `assembleDebug` uses `applicationIdSuffix = ".beta"` (`app/android/build.gradle.kts`), so the on-device package is **`ru.fromchat.beta`**, not `ru.fromchat`—`ru.fromchat` will fail to launch after a debug install. (Release / no-suffix id is `ru.fromchat`.) Also run **`:app:shared:compileKotlinIosArm64`** (and fix errors) when shared Kotlin changes should stay valid for iOS—either with the same Gradle invocation as in `general.mdc` or right after the Android APK build. diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index df73b7d..1532b51 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -6,7 +6,7 @@ When working with the mobile app: - After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors. -- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected (see that rule for APK path and `ru.fromchat.beta`). +- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected. For **`mobile_launch_app`** after a debug install, use package **`ru.fromchat.beta`** (not `ru.fromchat`); details in that rule. # ULTIMATE SILENCE & EFFICIENCY POLICY - ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions. diff --git a/.cursor/rules/strings-simple-i18n.mdc b/.cursor/rules/strings-simple-i18n.mdc new file mode 100644 index 0000000..fed68f4 --- /dev/null +++ b/.cursor/rules/strings-simple-i18n.mdc @@ -0,0 +1,17 @@ +--- +alwaysApply: true +--- + +# UI strings (shared module) + +## No hardcoded user-visible text +- Do not put English or Russian (or other) UI copy directly in Kotlin/Compose for screens users see. Use Compose Multiplatform resources: `app/shared/src/commonMain/composeResources/values/strings.xml` (default English) and `values-ru/strings.xml` (Russian), **same `name` keys** in both files. +- Use `stringResource(Res.string.*)` in `@Composable` (and `import ru.fromchat.*` when needed so `Res.string` extensions resolve). Internal-only tokens (cache keys, shared-element keys, protocol constants) may stay in code. + +## When you add or change copy +- Add or update the string in **both** `values/strings.xml` and `values-ru/strings.xml` in the **same** change. +- Prefer **plain language** short words, short sentences; avoid jargon unless necessary (e.g. say “server address” rather than “URL” in labels when it fits). + +## Debug API screen — excluded +- The **Debug API** screen (`ru.fromchat.ui.debug`) is not for standard users. You may **hardcode English (or any) strings directly in Kotlin** for that screen and **do not** need to add matching keys in `values/strings.xml` / `values-ru/strings.xml` or keep translations in sync. +- The **entry row** that opens Debug API (e.g. in Settings) stays in compose resources like other settings copy. diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt index 1f1f916..6668c61 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt @@ -1,346 +1,8 @@ package ru.fromchat.ui.debug -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material3.Button -import androidx.compose.material3.DividerDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.pr0gramm3r101.utils.crypto.Base64 -import com.pr0gramm3r101.utils.settings.settings -import kotlinx.coroutines.launch -import ru.fromchat.api.ApiClient -import ru.fromchat.api.SendDmFile -import ru.fromchat.crypto.IdentityKeyManager -import ru.fromchat.crypto.decryptEnvelope -import ru.fromchat.crypto.transport.TransportCrypto -import ru.fromchat.ui.LocalNavController -@OptIn(ExperimentalMaterial3Api::class) @Composable actual fun DebugApiScreen() { - val navController = LocalNavController.current - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - - var statusMessage by rememberSaveable { mutableStateOf("") } - var profileResult by rememberSaveable { mutableStateOf(null) } - var conversationsResult by rememberSaveable { mutableStateOf(null) } - var historyResult by rememberSaveable { mutableStateOf(null) } - var historyUserId by rememberSaveable { mutableStateOf("0") } - var decryptedMessage by rememberSaveable { mutableStateOf(null) } - var sendRecipientId by rememberSaveable { mutableStateOf("") } - var sendMessageText by rememberSaveable { mutableStateOf("") } - - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) - } - }, - title = { - Text(text = "Debug API") - }, - actions = { - IconButton(onClick = {}) { - Icon(imageVector = Icons.Filled.BugReport, contentDescription = null) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .verticalScroll(scrollState) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = statusMessage.takeIf { it.isNotBlank() } - ?: "Status will appear here", - style = MaterialTheme.typography.bodyMedium - ) - - Button( - onClick = { - scope.launch { - runCatching { ApiClient.getOwnProfile() } - .onSuccess { - profileResult = ApiClient.json.encodeToString(it) - statusMessage = "Profile loaded" - } - .onFailure { - statusMessage = it.message ?: "An unknown error occurred" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Load own profile") - } - - Text( - text = profileResult ?: "No data loaded yet", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - scope.launch { - runCatching { ApiClient.getDmConversations() } - .onSuccess { - conversationsResult = ApiClient.json.encodeToString(it) - statusMessage = "Conversations loaded" - } - .onFailure { - statusMessage = it.message ?: "An unknown error occurred" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Load DM conversations") - } - - Text( - text = conversationsResult ?: "No data loaded yet", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = historyUserId, - onValueChange = { historyUserId = it }, - label = { Text(text = "History user ID") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = historyUserId.toIntOrNull() - if (userId == null) { - statusMessage = "Enter a valid user ID" - return@Button - } - - scope.launch { - runCatching { ApiClient.getDmHistory(userId) } - .onSuccess { - historyResult = ApiClient.json.encodeToString(it) - statusMessage = "DM history loaded" - } - .onFailure { - statusMessage = it.message ?: "An unknown error occurred" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Load DM history") - } - - Text( - text = historyResult ?: "No data loaded yet", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = DividerDefaults.Thickness, - color = DividerDefaults.color - ) - - Text( - text = "DM Send Test", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = sendRecipientId, - onValueChange = { sendRecipientId = it }, - label = { Text(text = "Recipient user ID") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = sendMessageText, - onValueChange = { sendMessageText = it }, - label = { Text(text = "Message text") }, - singleLine = false, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = sendRecipientId.toIntOrNull() - if (userId == null) { - statusMessage = "Enter a valid recipient ID" - return@Button - } - if (sendMessageText.isBlank()) { - statusMessage = "Enter a message to send" - return@Button - } - - scope.launch { - runCatching { - ApiClient.sendDm( - recipientId = userId, - plaintext = sendMessageText.trim(), - replyToId = null - ) - }.onSuccess { - statusMessage = "DM sent successfully" - }.onFailure { - statusMessage = it.message ?: "Failed to send DM" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Send DM") - } - - Text( - text = "Phase 1: File Protocol Test", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - scope.launch { - runCatching { - val fileBytes = "test file".encodeToByteArray() - val transportKey = ApiClient.getTransportPublicKey() - val (msgCipher, secret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( - plaintext = "test file", - transportPublicKeyB64 = transportKey.publicKeyB64 - ) - try { - val transportBlob = TransportCrypto.encryptFileForTransport( - fileBytes = fileBytes, - transportPublicKeyB64 = transportKey.publicKeyB64, - ephemeralSecretKey = secret - ) - val sendFile = SendDmFile( - encryptedFileDataB64 = Base64.encode(transportBlob), - filename = "test.txt", - fileSize = fileBytes.size.toLong() - ) - ApiClient.sendDm( - recipientId = 2, - plaintext = "test file", - transportFiles = listOf(sendFile), - preparedTransport = msgCipher - ) - } finally { - secret.fill(0) - } - }.onSuccess { - statusMessage = "Protocol test file sent to user 2" - }.onFailure { - statusMessage = it.message ?: "Failed to send protocol test file" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Send test.txt to user 2") - } - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = DividerDefaults.Thickness, - color = DividerDefaults.color - ) - - Text( - text = "DM Decryption Test", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = historyUserId.toIntOrNull() - if (userId == null) { - statusMessage = "Enter a valid user ID" - return@Button - } - - scope.launch { - try { - // Restore keys from local storage if available - IdentityKeyManager.restoreFromLocal() - - // Fetch DM history - val history = ApiClient.getDmHistory(userId) - if (history.messages.isEmpty()) { - statusMessage = "No messages found" - decryptedMessage = null - return@launch - } - - // Get current user ID - val currentUserId = settings.getInt("current_user_id", 0) - if (currentUserId == 0) { - statusMessage = "Current user ID not found" - decryptedMessage = null - return@launch - } - - // Try to decrypt the first envelope - val firstEnvelope = history.messages.first() - val plaintext = decryptEnvelope(firstEnvelope, currentUserId) - decryptedMessage = "Decrypted: $plaintext" - statusMessage = "Decryption successful" - } catch (e: Exception) { - statusMessage = "Decryption failed: ${e.message}" - decryptedMessage = "Error: ${e.message}\n${e.stackTraceToString()}" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Decrypt First DM") - } - - Text( - text = decryptedMessage ?: "No decrypted message yet", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - } - } + DebugApiScreenContent() } diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index da00d2d..c5ecffd 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -1,14 +1,126 @@ + + FromChat + Назад + Настройки + Главная + О приложении Версия 1.0 Telegram MAX Сайт 100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере. - - + Добро пожаловать! + Войти + Войдите в свой аккаунт + Регистрация + Создать новый аккаунт + Создать аккаунт + Имя пользователя + Пароль + Пароль ещё раз + Как вас видят другие + От 1 до 64 символов + Заполните все поля + Имя пользователя — от 3 до 20 символов + Пароль — от 5 до 50 символов + Пароли не совпадают + Чаты + Контакты + Профиль + Личные чаты + Скоро… + Общий чат + Вы: последнее сообщение + Напишите сообщение… + Соединение + Обновление + Группа + %1$d человек + Позвонить + Вы + Человек %1$d + Это сообщение не удалось показать. + (изменено) + Ответ %1$s + Сообщение не показывается + Правка сообщения + Ответить + Изменить + Удалить + Сохранить + Закрыть + Убрать + Выбрать фото + Выбрать файл + Отправить + Профиль + Не получилось загрузить профиль + Настройки + Написать + Скопировать ссылку + Ссылка скопирована + О человеке + Имя пользователя + Дата регистрации + О себе + Официальный аккаунт + Это официальный аккаунт + Нажмите, чтобы сделать аккаунт официальным (только админы) + Сделать официальным + Снять официальный статус + Официальный аккаунт + Похож на официальный аккаунт + В сети + Недавно заходил + Сегодня в %1$s + Вчера в %1$s + %1$s в %2$s + %1$d %2$s в %3$s + %1$d %2$s %3$d в %4$s + понедельник + вторник + среда + четверг + пятница + суббота + воскресенье + янв + фев + мар + апр + май + июн + июл + авг + сен + окт + ноя + дек + Подключение к серверу + Введите адрес сервера, к которому хотите подключиться + Адрес сервера + example.com + Защищённое соединение + Сохранить и продолжить + Сменить сервер + Подключиться к альтернативному серверу FromChat и выйти из аккаунта. + Выйти + Material You + Цвета как на обоях. Работает с Android 12 и новее. + Оформление + Как на телефоне + Светлое + Тёмное + Отладка API + Просмотр ответов API профиля и личных сообщений. + Что-то пошло не так + Неверное имя пользователя или пароль + Не удалось подключиться. Проверьте интернет. + Что-то пошло не так. Попробуйте ещё раз. %1$s печатает… %1$s и %2$s печатают… - %1$s, %2$s и еще %3$d печатают… + %1$s, %2$s и ещё %3$d печатают… + Ещё + +%1$d - - diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index a097f82..b76be73 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -1,88 +1,160 @@ + FromChat - + Back Settings Home - About app + About Version 1.0 Telegram MAX Website 100% free and open messenger. Supports self-hosted installation on your own server. - + Welcome! - Sign In - Sign in to your account - Registration + Log in + Log in to your account + Sign up Create a new account - Register + Create account Username Password - Confirm Password - Display Name - Display name must be between 1 and 64 characters - + Password again + Name shown to others + Use between 1 and 64 characters + - Please fill in all fields - Username must be between 3 and 20 characters - Password must be between 5 and 50 characters - Passwords do not match - + Please fill in every field + Username must be 3 to 20 characters + Password must be 5 to 50 characters + The two passwords don’t match + Chats Contacts - Direct Messages - Coming soon... - General Chat - You: Last message - Type a message... - - - Server Configuration - Enter server details to connect - Server URL - example.com - Use HTTPS - Save & Continue + Profile + Private chats + Coming soon… + Main chat + You: last message + Write a message… + + + Connecting + Updating + Group + %1$d people + Call + + + You + Person %1$d + This message could not be shown. + (edited) + Reply to %1$s + Can’t show this message + Edit message + + + Reply + Edit + Delete + Save + + + Close + Remove + Choose photo + Choose file + Send + + + Profile + Couldn’t load this profile + Settings + Chat + Copy link + Link copied + About this person + Username + Joined + About + Verified account + This account is verified + Tap to verify (admins only) + + + Verify + Remove verification + + + Verified account + May be a verified account + + + Online + Active recently + Today at %1$s + Yesterday at %1$s + %1$s at %2$s + %1$d %2$s at %3$s + %1$d %2$s %3$d at %4$s + Monday + Tuesday + Wednesday + Thursday + Friday + Saturday + Sunday + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec + + + Connect to a server + Enter the server you want to use + Server address + example.com + Use a secure connection + Save and continue - - Change Server - Changes the FromChat instance you are connecting to. This will log you out of your account. - Logout - Enable Material You theming - Enable the dynamic theme based on your wallpaper. Works only on Android 12 or higher. - Theme - As system + Change server + Connect to an alternative FromChat server and sign out. + Log out + Material You + Match colors to your wallpaper. Works on Android 12 and up. + Look + Same as phone Light Dark Debug API Inspect profile and DM endpoints used by the client. - Status will appear here - Profile loaded - Load own profile - No data loaded yet - Conversations loaded - Load DM conversations - History user ID - Enter a valid user ID - DM history loaded - Load DM history - + - Unexpected error - Invalid username or password - Connection error - An unknown error occurred - + Something went wrong + Wrong username or password + Couldn’t connect. Check your internet. + Something went wrong. Please try again. + %1$s is typing… %1$s and %2$s are typing… %1$s, %2$s and %3$d more are typing… More + +%1$d diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt index ae98dcf..16898bc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt @@ -5,7 +5,9 @@ import ru.fromchat.api.DmEnvelope import ru.fromchat.crypto.dm.DmCrypto /** Shown in the UI when [DmCiphertextCorruptedException] is caught while decrypting a DM. */ -const val CorruptedDmMessagePlaceholder = "This message is corrupted and can't be displayed." +/** Must match [ru.fromchat.Res.string.message_corrupted] (Compose resources). */ +const val CorruptedDmMessagePlaceholder = + "Сообщение повреждено и не может быть отображено." /** * Unwrap a MEK (Message Encryption Key) using the appropriate wrapping key diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index 57bd15d..fb7d084 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -70,7 +70,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.Message -import ru.fromchat.message_placeholder +import ru.fromchat.* @Composable private fun AnimatedPreviewBar( @@ -101,6 +101,7 @@ private fun PreviewBar( icon: ImageVector, title: String, subtitle: String, + closeContentDescription: String, onClose: () -> Unit ) { Row( @@ -137,7 +138,7 @@ private fun PreviewBar( IconButton(onClick = onClose) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Close", + contentDescription = closeContentDescription, tint = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -148,6 +149,7 @@ private fun PreviewBar( private fun AttachmentChip( attachment: SelectedAttachment, onRemove: () -> Unit, + removeContentDescription: String, modifier: Modifier = Modifier ) { Row( @@ -186,7 +188,7 @@ private fun AttachmentChip( IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Remove", + contentDescription = removeContentDescription, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -206,7 +208,8 @@ fun ChatInput( onClearReply: () -> Unit, onClearEdit: () -> Unit, hazeState: HazeState, - recipientId: Int? = null + recipientId: Int? = null, + currentUserId: Int? = null ) { val scope = rememberCoroutineScope() var typingJob by remember { mutableStateOf(null) } @@ -251,6 +254,13 @@ fun ChatInput( } val canSend = text.isNotBlank() || attachments.isNotEmpty() + val cdClose = stringResource(Res.string.cd_close) + val cdRemove = stringResource(Res.string.cd_remove) + val cdPickImage = stringResource(Res.string.cd_pick_image) + val cdPickFile = stringResource(Res.string.cd_pick_file) + val cdSend = stringResource(Res.string.cd_send) + val corruptedShort = stringResource(Res.string.message_corrupted_short) + val editingTitle = stringResource(Res.string.message_editing_title) Box( modifier = Modifier @@ -277,28 +287,31 @@ fun ChatInput( ) { AnimatedPreviewBar(replyTo) { replyTo -> val replySubtitle = if (replyTo.isContentCorrupted) { - "Corrupted message" + corruptedShort } else { replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "" } + val replyName = messageDisplayUsername(replyTo, currentUserId) PreviewBar( icon = Icons.AutoMirrored.Filled.Reply, - title = "Replying to ${replyTo.username}", + title = stringResource(Res.string.message_replying_to, replyName), subtitle = replySubtitle, + closeContentDescription = cdClose, onClose = { onClearReply() } ) } AnimatedPreviewBar(editingMessage) { message -> val subtitle = if (message.isContentCorrupted) { - "Corrupted message" + corruptedShort } else { message.content.take(50) + if (message.content.length > 50) "..." else "" } PreviewBar( icon = Icons.Filled.Edit, - title = "Editing message", + title = editingTitle, subtitle = subtitle, + closeContentDescription = cdClose, onClose = { onClearEdit() } ) } @@ -318,7 +331,8 @@ fun ChatInput( attachments.forEach { attachment -> AttachmentChip( attachment = attachment, - onRemove = { attachments = attachments.filter { it.id != attachment.id } } + onRemove = { attachments = attachments.filter { it.id != attachment.id } }, + removeContentDescription = cdRemove ) } } @@ -332,14 +346,14 @@ fun ChatInput( IconButton(onClick = { launchImagePicker() }) { Icon( imageVector = Icons.Default.Image, - contentDescription = "Pick image", + contentDescription = cdPickImage, tint = MaterialTheme.colorScheme.onSurfaceVariant ) } IconButton(onClick = { launchFilePicker() }) { Icon( imageVector = Icons.Default.AttachFile, - contentDescription = "Pick file", + contentDescription = cdPickFile, tint = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -397,7 +411,7 @@ fun ChatInput( ) { Icon( imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send", + contentDescription = cdSend, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp) ) 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 a5557e3..1f048c0 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 @@ -74,6 +74,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.api.ProfileCache import ru.fromchat.api.AttachmentUploadJob @@ -85,7 +86,6 @@ import ru.fromchat.api.UserStatusStore import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketUpdatesData -import ru.fromchat.back import ru.fromchat.core.Logger import ru.fromchat.net.NetworkConnectivity import ru.fromchat.ui.ConnectingEllipsis @@ -95,6 +95,7 @@ import ru.fromchat.ui.rememberHapticFeedback import ru.fromchat.ui.chat.getImageAspectRatio import ru.fromchat.ui.scaleOnPress import ru.fromchat.utils.formatLastSeen +import ru.fromchat.utils.rememberLastSeenFormatStrings import kotlin.time.Clock @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @@ -144,6 +145,11 @@ fun ChatScreen( val statusMap by UserStatusStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState() val online by NetworkConnectivity.isOnline.collectAsState(initial = true) + val lastSeenFormat = rememberLastSeenFormatStrings() + val statusConnecting = stringResource(Res.string.status_connecting) + val statusUpdating = stringResource(Res.string.status_updating) + val chatGroupLabel = stringResource(Res.string.chat_group_label) + val cdCall = stringResource(Res.string.cd_call) LaunchedEffect(currentTypingUsers) { Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") } @@ -413,7 +419,7 @@ fun ChatScreen( panelState.profileUserId != null -> { val userStatus = statusMap[panelState.profileUserId] val statusText = userStatus?.let { - formatLastSeen(it.online, it.lastSeen) + formatLastSeen(it.online, it.lastSeen, lastSeenFormat) }.orEmpty() if (statusText.isNotEmpty()) { "presence:$statusText" @@ -434,12 +440,23 @@ fun ChatScreen( ) { key -> when { key == "updating" -> { - Text( - text = "Updating...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp) - ) + val st = MaterialTheme.typography.bodySmall + val col = MaterialTheme.colorScheme.onSurfaceVariant + Row( + modifier = Modifier.padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = statusUpdating, + style = st, + color = col + ) + ConnectingEllipsis( + fontSize = st.fontSize, + color = col, + baseStyle = st + ) + } } key == "connecting" -> { val st = MaterialTheme.typography.bodySmall @@ -449,7 +466,7 @@ fun ChatScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Connecting", + text = statusConnecting, style = st, color = col ) @@ -477,7 +494,7 @@ fun ChatScreen( } key == "group" -> { Text( - text = "group", + text = chatGroupLabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp) @@ -486,7 +503,7 @@ fun ChatScreen( key.startsWith("members:") -> { val n = key.removePrefix("members:").toIntOrNull() ?: 0 Text( - text = "$n members", + text = stringResource(Res.string.chat_members_count, n), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp) @@ -513,7 +530,7 @@ fun ChatScreen( IconButton(onClick = { /* TODO: Handle call */ }) { Icon( imageVector = Icons.Default.Call, - contentDescription = "Call" + contentDescription = cdCall ) } } @@ -547,6 +564,7 @@ fun ChatScreen( ChatInput( text = inputText, onTextChange = { inputText = it }, + currentUserId = currentUserId, onSend = { text, attachments -> if (editingMessage != null) { scope.launch { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt index 0a9ea42..1986ffa 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -67,8 +67,11 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res import kotlinx.datetime.toLocalDateTime import ru.fromchat.api.Message +import ru.fromchat.* import ru.fromchat.ui.BackHandler import ru.fromchat.ui.LocalSystemBarsVisibility import kotlin.math.abs @@ -121,6 +124,12 @@ fun ImageFullscreenPreview( thumbnailBounds: Rect? = null ) { val file = message.files?.getOrNull(fileIndex) ?: return + val cdBack = stringResource(Res.string.back) + val cdMenu = stringResource(Res.string.more) + val labelReply = stringResource(Res.string.action_reply) + val labelSave = stringResource(Res.string.action_save) + val labelDelete = stringResource(Res.string.action_delete) + val headerName = messageDisplayUsername(message, currentUserId) val envelope = message.dmEnvelope val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex) @@ -596,7 +605,7 @@ fun ImageFullscreenPreview( IconButton(onClick = { dismissRequested = true }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = cdBack, tint = Color.White ) } @@ -605,7 +614,7 @@ fun ImageFullscreenPreview( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = message.username, + text = headerName, style = MaterialTheme.typography.titleMedium, color = Color.White ) @@ -620,7 +629,7 @@ fun ImageFullscreenPreview( IconButton(onClick = { menuExpanded = true }) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = "Menu", + contentDescription = cdMenu, tint = Color.White ) } @@ -634,7 +643,7 @@ fun ImageFullscreenPreview( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.AutoMirrored.Filled.Reply, null, tint = Color.White) Spacer(Modifier.width(8.dp)) - Text("Reply", color = Color.White) + Text(labelReply, color = Color.White) } }, onClick = { @@ -648,7 +657,7 @@ fun ImageFullscreenPreview( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.SaveAlt, null, tint = Color.White) Spacer(Modifier.width(8.dp)) - Text("Save", color = Color.White) + Text(labelSave, color = Color.White) } }, onClick = { @@ -661,7 +670,7 @@ fun ImageFullscreenPreview( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.Delete, null, tint = Color.White) Spacer(Modifier.width(8.dp)) - Text("Delete", color = Color.White) + Text(labelDelete, color = Color.White) } }, onClick = { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index ac6dc87..4ff9737 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -46,7 +46,10 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res import ru.fromchat.api.Message +import ru.fromchat.* import ru.fromchat.ui.scaleOnPress data class ContextMenuState( @@ -257,6 +260,9 @@ private fun ContextMenuContent( val menuColor = MaterialTheme.colorScheme.surfaceContainer val edgePadding = 8.dp val itemSpacing = 2.dp + val labelReply = stringResource(Res.string.action_reply) + val labelEdit = stringResource(Res.string.action_edit) + val labelDelete = stringResource(Res.string.action_delete) Box(modifier = containerModifier) { Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape)) @@ -268,20 +274,20 @@ private fun ContextMenuContent( ) { ContextMenuItem( icon = Icons.AutoMirrored.Filled.Reply, - text = "Reply", + text = labelReply, onClick = { onReply(message) } ) if (isAuthor) { ContextMenuItem( icon = Icons.Default.Edit, - text = "Edit", + text = labelEdit, onClick = { onEdit(message) } ) } if (isAuthor) { ContextMenuItem( icon = Icons.Default.Delete, - text = "Delete", + text = labelDelete, onClick = { onDelete(message) }, isError = true ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt new file mode 100644 index 0000000..3dd8f87 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt @@ -0,0 +1,25 @@ +package ru.fromchat.ui.chat + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.Message +import ru.fromchat.* + +private val userIdUsernamePattern = Regex("^User (\\d+)$") + +/** + * Resolves [Message.username] for display: localized «Вы», «Пользователь N», or server-provided name. + */ +@Composable +fun messageDisplayUsername(message: Message, currentUserId: Int?): String { + if (currentUserId != null && message.user_id == currentUserId) { + return stringResource(Res.string.message_sender_you) + } + val m = userIdUsernamePattern.matchEntire(message.username) + if (m != null) { + val id = m.groupValues[1].toIntOrNull() + if (id != null) return stringResource(Res.string.user_fallback, id) + } + return message.username +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 5643bde..7f516d3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -53,7 +53,10 @@ import androidx.compose.ui.unit.sp import com.pr0gramm3r101.utils.conditional import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res import ru.fromchat.api.Message +import ru.fromchat.* import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -95,6 +98,10 @@ fun MessageItem( val formattedTime = remember(message.timestamp) { formatTime(message.timestamp) } + val corruptedBody = stringResource(Res.string.message_corrupted) + val editedSuffix = stringResource(Res.string.message_edited_suffix) + val displayUsername = messageDisplayUsername(message, currentUserId) + val replyRef = message.reply_to // No AnimatedVisibility here: visible=true still ran enter transitions for every item on first // composition (N messages ⇒ N concurrent animations + huge JIT), causing main-thread jank. @@ -316,7 +323,7 @@ fun MessageItem( ) ) { Text( - text = message.username, + text = displayUsername, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, @@ -326,7 +333,7 @@ fun MessageItem( } else { Box(modifier = usernameOutset) { Text( - text = message.username, + text = displayUsername, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, @@ -348,7 +355,8 @@ fun MessageItem( ) { Column { // Reply preview - message.reply_to?.let { replyTo -> + replyRef?.let { replyToMsg -> + val replyName = messageDisplayUsername(replyToMsg, currentUserId) Box( Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp) ) { @@ -379,7 +387,7 @@ fun MessageItem( ) { if (showUsername) { Text( - text = replyTo.username, + text = replyName, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, @@ -387,7 +395,7 @@ fun MessageItem( ) } Text( - text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "", + text = replyToMsg.content.take(50) + if (replyToMsg.content.length > 50) "..." else "", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp, @@ -401,7 +409,7 @@ fun MessageItem( // Attachments (images/files) or corrupted message if (isCorrupted) { Text( - text = "_This message is corrupted and cannot be displayed.", + text = corruptedBody, style = MaterialTheme.typography.bodyMedium, color = if (isAuthor) { Color.White.copy(alpha = 0.8f) @@ -553,7 +561,7 @@ fun MessageItem( if (message.is_edited) { Spacer(modifier = Modifier.width(4.dp)) Text( - text = "(edited)", + text = editedSuffix, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = if (isAuthor) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt index 5a7303a..c0d630c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -18,11 +18,14 @@ import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.core.Logger class PublicChatPanel( - chatName: String, + /** Stable cache / panel id (not localized; hardcoded in [PublicChatPanelCache]). */ + panelKey: String, + /** Shown in the app bar and avatars. */ + displayTitle: String, currentUserId: Int?, scope: CoroutineScope ) : ChatPanel( - id = "public-$chatName", + id = "public-$panelKey", currentUserId = currentUserId, scope = scope ) { @@ -57,8 +60,8 @@ class PublicChatPanel( init { updateState { it.copy( - title = chatName, - titleAvatar = AvatarInfo(displayName = chatName, profilePictureUrl = null), + title = displayTitle, + titleAvatar = AvatarInfo(displayName = displayTitle, profilePictureUrl = null), publicGroupMetaLoading = true, publicGroupMemberCount = null ) @@ -82,6 +85,19 @@ class PublicChatPanel( } } + /** When locale changes, keep the same panel but refresh the visible title. */ + fun applyDisplayTitle(title: String) { + updateState { s -> + s.copy( + title = title, + titleAvatar = AvatarInfo( + displayName = title, + profilePictureUrl = s.titleAvatar?.profilePictureUrl + ) + ) + } + } + private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) { updateMessage(reactionUpdate.message_id) { message -> message.copy(reactions = reactionUpdate.reactions) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt index eaa2fe0..d2bf869 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt @@ -3,6 +3,7 @@ package ru.fromchat.ui.chat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob + /** * Single retained [PublicChatPanel] for the app session (same idea as [ru.fromchat.ui.dm.DmPanelCache]). * Navigating away from public chat used to dispose [remember] and recreate the panel, so every open @@ -13,13 +14,14 @@ import kotlinx.coroutines.SupervisorJob * collectors and [ChatPanel] state callbacks keep working after the composable is left and re-entered. */ object PublicChatPanelCache { - private const val GENERAL_CHAT_NAME = "General Chat" + private const val GeneralPublicPanelKey = "general" private var supervisorJob = SupervisorJob() private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate) private var panel: PublicChatPanel? = null - private var cachedChatName: String? = null + private var cachedPanelKey: String? = null + private var cachedDisplayTitle: String? = null private var cachedUserId: Int? = null private fun ensureScope() { @@ -29,25 +31,31 @@ object PublicChatPanelCache { } } - fun getOrCreateGeneralChat(currentUserId: Int?): PublicChatPanel = - getOrCreate(GENERAL_CHAT_NAME, currentUserId) - - fun getOrCreate(chatName: String, currentUserId: Int?): PublicChatPanel { + /** + * @param displayTitle Localized title from e.g. [ru.fromchat.Res.string.public_chat]. + */ + fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel { ensureScope() if ( panel != null && - cachedChatName == chatName && + cachedPanelKey == GeneralPublicPanelKey && cachedUserId == currentUserId ) { + if (cachedDisplayTitle != displayTitle) { + cachedDisplayTitle = displayTitle + panel!!.applyDisplayTitle(displayTitle) + } return panel!! } panel?.destroy() panel = PublicChatPanel( - chatName = chatName, + panelKey = GeneralPublicPanelKey, + displayTitle = displayTitle, currentUserId = currentUserId, scope = panelScope ) - cachedChatName = chatName + cachedPanelKey = GeneralPublicPanelKey + cachedDisplayTitle = displayTitle cachedUserId = currentUserId return panel!! } @@ -55,7 +63,8 @@ object PublicChatPanelCache { fun clear() { panel?.destroy() panel = null - cachedChatName = null + cachedPanelKey = null + cachedDisplayTitle = null cachedUserId = null supervisorJob.cancel() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt index d242967..c8a84e3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt @@ -6,8 +6,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.ui.isPublicChatVisible +import ru.fromchat.* @Composable fun PublicChatScreen( @@ -16,10 +19,11 @@ fun PublicChatScreen( animatedContentScope: AnimatedContentScope? = null ) { val currentUserId = ApiClient.user?.id + val publicChatTitle = stringResource(Res.string.public_chat) // Reuse one panel for the session (like DM [DmPanelCache]); avoids full reload on every visit. - val panel = remember(currentUserId) { - PublicChatPanelCache.getOrCreateGeneralChat(currentUserId) + val panel = remember(currentUserId, publicChatTitle) { + PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId) } LaunchedEffect(panel) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt new file mode 100644 index 0000000..8794e76 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt @@ -0,0 +1,444 @@ +package ru.fromchat.ui.debug + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material3.Button +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.crypto.Base64 +import com.pr0gramm3r101.utils.settings.settings +import kotlinx.coroutines.launch +import ru.fromchat.api.ApiClient +import ru.fromchat.api.SendDmFile +import ru.fromchat.crypto.IdentityKeyManager +import ru.fromchat.crypto.decryptEnvelope +import ru.fromchat.crypto.transport.TransportCrypto +import ru.fromchat.ui.LocalNavController + +/** + * English-only copy: the Debug API screen is not for end users; strings are not in compose + * resources (see project i18n rules). + */ +private object DebugScreenText { + const val ScreenTitle = "Debug API" + const val NavSection = "Quick navigation" + const val OpenProfile = "Profile screen" + const val OpenProfileDesc = "Your profile with chat and link actions" + const val OpenDmUser2 = "DM (user 2)" + const val OpenDmUser2Desc = "Open a direct message with user ID 2" + const val BugIconCd = "Debug" + const val StatusPlaceholder = "Status will appear here" + const val UnknownError = "Something went wrong. Please try again." + const val ProfileLoaded = "Profile loaded" + const val LoadOwnProfile = "Load own profile" + const val NotLoaded = "No data loaded yet" + const val ConversationsLoaded = "Conversations loaded" + const val LoadConversations = "Load DM conversations" + const val HistoryUserIdLabel = "History user ID" + const val InvalidUserId = "Enter a valid user ID" + const val HistoryLoaded = "DM history loaded" + const val LoadHistory = "Load DM history" + const val DmSendTest = "DM send test" + const val RecipientUserId = "Recipient user ID" + const val MessageText = "Message text" + const val InvalidRecipient = "Enter a valid recipient ID" + const val EnterMessage = "Enter a message to send" + const val DmSent = "DM sent" + const val DmSendFailed = "Failed to send DM" + const val SendDm = "Send DM" + const val Phase1FileProtocol = "Phase 1: file protocol test" + const val SendTestFileBtn = "Send test.txt to user 2" + const val ProtocolSent = "Protocol test file sent to user 2" + const val ProtocolFailed = "Failed to send protocol test file" + const val DecryptTest = "DM decryption test" + const val DecryptFirstDm = "Decrypt first DM" + const val NoMessages = "No messages" + const val CurrentUserMissing = "Current user ID not found" + const val DecryptedPrefix = "Decrypted: " + const val DecryptSuccess = "Decryption successful" + const val DecryptFailedPrefix = "Decryption failed: " + const val ErrorLabel = "Error: " + const val NoDecryptedYet = "No decrypted message yet" + const val OpenAction = "Open" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugApiScreenContent() { + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val currentUserId = ApiClient.user?.id ?: 0 + + var statusMessage by rememberSaveable { mutableStateOf("") } + var profileResult by rememberSaveable { mutableStateOf(null) } + var conversationsResult by rememberSaveable { mutableStateOf(null) } + var historyResult by rememberSaveable { mutableStateOf(null) } + var historyUserId by rememberSaveable { mutableStateOf("0") } + var decryptedMessage by rememberSaveable { mutableStateOf(null) } + var sendRecipientId by rememberSaveable { mutableStateOf("") } + var sendMessageText by rememberSaveable { mutableStateOf("") } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + title = { + Text(text = DebugScreenText.ScreenTitle) + }, + actions = { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Filled.BugReport, + contentDescription = DebugScreenText.BugIconCd + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = DebugScreenText.NavSection, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = DebugScreenText.OpenProfile, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = DebugScreenText.OpenProfileDesc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + if (currentUserId != 0) { + navController.navigate("profile/$currentUserId") + } + }, + enabled = currentUserId != 0, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.OpenAction) + } + Text( + text = DebugScreenText.OpenDmUser2, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = DebugScreenText.OpenDmUser2Desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { navController.navigate("dm/2") }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.OpenAction) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + Text( + text = statusMessage.takeIf { it.isNotBlank() } ?: DebugScreenText.StatusPlaceholder, + style = MaterialTheme.typography.bodyMedium + ) + + Button( + onClick = { + scope.launch { + runCatching { ApiClient.getOwnProfile() } + .onSuccess { + profileResult = ApiClient.json.encodeToString(it) + statusMessage = DebugScreenText.ProfileLoaded + } + .onFailure { + statusMessage = it.message ?: DebugScreenText.UnknownError + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.LoadOwnProfile) + } + + Text( + text = profileResult ?: DebugScreenText.NotLoaded, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + scope.launch { + runCatching { ApiClient.getDmConversations() } + .onSuccess { + conversationsResult = ApiClient.json.encodeToString(it) + statusMessage = DebugScreenText.ConversationsLoaded + } + .onFailure { + statusMessage = it.message ?: DebugScreenText.UnknownError + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.LoadConversations) + } + + Text( + text = conversationsResult ?: DebugScreenText.NotLoaded, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = historyUserId, + onValueChange = { historyUserId = it }, + label = { Text(text = DebugScreenText.HistoryUserIdLabel) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + val userId = historyUserId.toIntOrNull() + if (userId == null) { + statusMessage = DebugScreenText.InvalidUserId + return@Button + } + + scope.launch { + runCatching { ApiClient.getDmHistory(userId) } + .onSuccess { + historyResult = ApiClient.json.encodeToString(it) + statusMessage = DebugScreenText.HistoryLoaded + } + .onFailure { + statusMessage = it.message ?: DebugScreenText.UnknownError + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.LoadHistory) + } + + Text( + text = historyResult ?: DebugScreenText.NotLoaded, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + Text( + text = DebugScreenText.DmSendTest, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = sendRecipientId, + onValueChange = { sendRecipientId = it }, + label = { Text(text = DebugScreenText.RecipientUserId) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = sendMessageText, + onValueChange = { sendMessageText = it }, + label = { Text(text = DebugScreenText.MessageText) }, + singleLine = false, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + val userId = sendRecipientId.toIntOrNull() + if (userId == null) { + statusMessage = DebugScreenText.InvalidRecipient + return@Button + } + if (sendMessageText.isBlank()) { + statusMessage = DebugScreenText.EnterMessage + return@Button + } + + scope.launch { + runCatching { + ApiClient.sendDm( + recipientId = userId, + plaintext = sendMessageText.trim(), + replyToId = null + ) + }.onSuccess { + statusMessage = DebugScreenText.DmSent + }.onFailure { + statusMessage = it.message ?: DebugScreenText.DmSendFailed + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.SendDm) + } + + Text( + text = DebugScreenText.Phase1FileProtocol, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + scope.launch { + runCatching { + val fileBytes = "test file".encodeToByteArray() + val transportKey = ApiClient.getTransportPublicKey() + val (msgCipher, secret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( + plaintext = "test file", + transportPublicKeyB64 = transportKey.publicKeyB64 + ) + try { + val transportBlob = TransportCrypto.encryptFileForTransport( + fileBytes = fileBytes, + transportPublicKeyB64 = transportKey.publicKeyB64, + ephemeralSecretKey = secret + ) + val sendFile = SendDmFile( + encryptedFileDataB64 = Base64.encode(transportBlob), + filename = "test.txt", + fileSize = fileBytes.size.toLong() + ) + ApiClient.sendDm( + recipientId = 2, + plaintext = "test file", + transportFiles = listOf(sendFile), + preparedTransport = msgCipher + ) + } finally { + secret.fill(0) + } + }.onSuccess { + statusMessage = DebugScreenText.ProtocolSent + }.onFailure { + statusMessage = it.message ?: DebugScreenText.ProtocolFailed + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.SendTestFileBtn) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + Text( + text = DebugScreenText.DecryptTest, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + val userId = historyUserId.toIntOrNull() + if (userId == null) { + statusMessage = DebugScreenText.InvalidUserId + return@Button + } + + scope.launch { + try { + IdentityKeyManager.restoreFromLocal() + + val history = ApiClient.getDmHistory(userId) + if (history.messages.isEmpty()) { + statusMessage = DebugScreenText.NoMessages + decryptedMessage = null + return@launch + } + + val currentId = settings.getInt("current_user_id", 0) + if (currentId == 0) { + statusMessage = DebugScreenText.CurrentUserMissing + decryptedMessage = null + return@launch + } + + val firstEnvelope = history.messages.first() + val plaintext = decryptEnvelope(firstEnvelope, currentId) + decryptedMessage = DebugScreenText.DecryptedPrefix + plaintext + statusMessage = DebugScreenText.DecryptSuccess + } catch (e: Exception) { + statusMessage = DebugScreenText.DecryptFailedPrefix + (e.message.orEmpty()) + decryptedMessage = + DebugScreenText.ErrorLabel + (e.message.orEmpty()) + "\n" + e.stackTraceToString() + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.DecryptFirstDm) + } + + Text( + text = decryptedMessage ?: DebugScreenText.NoDecryptedYet, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt index da3b605..a4ce74d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt @@ -41,15 +41,14 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.ProfileCache import ru.fromchat.api.db.CachedConversation import ru.fromchat.api.db.MessageCacheStore -import ru.fromchat.chat_last_mesaage import ru.fromchat.net.NetworkConnectivity -import ru.fromchat.public_chat import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.branding.FromChatBrandTitle @@ -119,6 +118,10 @@ fun ChatsTab() { else -> "fromchat" } + val connectingTitle = stringResource(Res.string.status_connecting) + val updatingTitle = stringResource(Res.string.status_updating) + val brandTitle = stringResource(Res.string.app_name) + Scaffold( topBar = { TopAppBar( @@ -162,7 +165,7 @@ fun ChatsTab() { horizontalArrangement = Arrangement.Start ) { Text( - text = "Connecting", + text = connectingTitle, style = style, color = color, maxLines = 1, @@ -176,16 +179,29 @@ fun ChatsTab() { } } "updating" -> { - Text( + val style = MaterialTheme.typography.titleLarge + val color = MaterialTheme.colorScheme.onSurface + Row( modifier = Modifier.fillMaxWidth(), - text = "Updating...", - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = updatingTitle, + style = style, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + ConnectingEllipsis( + fontSize = style.fontSize, + color = color, + baseStyle = style + ) + } } else -> { - FromChatBrandTitle(text = "FromChat") + FromChatBrandTitle(text = brandTitle) } } } @@ -233,7 +249,9 @@ fun ChatsTab() { val avatarUrl = cached?.profilePicture val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } ?: cached?.username?.takeIf { it.isNotBlank() } - ?: conv.displayName.ifBlank { "User ${conv.otherUserId}" } + ?: conv.displayName.ifBlank { + stringResource(Res.string.user_fallback, conv.otherUserId) + } val preview = conv.lastMessagePreview?.trim().orEmpty() ListItem( leadingContent = { @@ -264,7 +282,7 @@ fun ChatsTab() { }, trailingContent = { if (conv.unreadCount > 0) { - Text("+${conv.unreadCount}") + Text(stringResource(Res.string.unread_count, conv.unreadCount)) } }, modifier = Modifier.clickable { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index 1cade73..15e132d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -25,11 +25,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.* import ru.fromchat.api.ApiClient -import ru.fromchat.chats -import ru.fromchat.coming_soon -import ru.fromchat.contacts -import ru.fromchat.settings import ru.fromchat.utils.exclude import ru.fromchat.ui.profile.ProfileScreen @@ -62,7 +59,7 @@ fun MainScreen(onLogout: () -> Unit = {}) { NavigationBarItem( selected = selectedTab == "profile", onClick = { selectedTab = "profile" }, - label = { Text("Profile") }, + label = { Text(stringResource(Res.string.profile)) }, icon = { Icon(Icons.Filled.Person, contentDescription = null) } ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt index c5d2bd4..9b754e4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt @@ -48,6 +48,7 @@ import com.pr0gramm3r101.utils.materialYouAvailable import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.* import ru.fromchat.about import ru.fromchat.api.ApiClient import ru.fromchat.api.WebSocketManager @@ -203,8 +204,8 @@ fun SettingsTab( ) ListItem( - headline = "Debug API", - supportingText = "Inspect profile and DM endpoints used by the client.", + headline = stringResource(Res.string.debug_tools), + supportingText = stringResource(Res.string.debug_tools_d), onClick = { navController.navigate("debug") }, @@ -216,31 +217,6 @@ fun SettingsTab( } ) - val currentUserId = ApiClient.user?.id ?: 0 - ListItem( - headline = "Profile screen", - supportingText = "Show your profile with chat/link actions", - onClick = { - if (currentUserId != 0) { - navController.navigate("profile/$currentUserId") - } - }, - divider = true, - dividerColor = MaterialTheme.colorScheme.surface, - dividerThickness = 2.dp - ) - - ListItem( - headline = "DM screen (user 2)", - supportingText = "Open DM flow with another user", - onClick = { - navController.navigate("dm/2") - }, - divider = true, - dividerColor = MaterialTheme.colorScheme.surface, - dividerThickness = 2.dp - ) - ListItem( headline = stringResource(Res.string.logout), leadingContent = { 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 e4e1e8b..887615d 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 @@ -68,6 +68,9 @@ import com.pr0gramm3r101.components.ListItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.api.ProfileCache import ru.fromchat.api.UserProfile @@ -76,11 +79,15 @@ import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey import ru.fromchat.ui.scaleOnPress +private sealed interface ProfileLoadError { + data object Generic : ProfileLoadError + data class Message(val text: String) : ProfileLoadError +} + private data class ProfileUiState( val profile: UserProfile? = null, val isLoading: Boolean = true, - val error: String? = null, - val linkStatus: String? = null + val error: ProfileLoadError? = null ) private val profileActionCardPressSpring = spring( @@ -109,6 +116,21 @@ fun ProfileScreen( ) { val clipboardManager: ClipboardManager = LocalClipboardManager.current val navController = LocalNavController.current + var linkCopied by remember { mutableStateOf(false) } + val profileTitle = stringResource(Res.string.profile_title) + val cdBack = stringResource(Res.string.back) + val profileLoadFailed = stringResource(Res.string.profile_load_failed) + val labelSettings = stringResource(Res.string.action_open_settings) + val labelChat = stringResource(Res.string.action_chat) + val labelLink = stringResource(Res.string.action_copy_link) + val linkCopiedText = stringResource(Res.string.link_copied) + val detailsTitle = stringResource(Res.string.profile_details_category) + val headlineUsername = stringResource(Res.string.profile_headline_username) + val headlineMemberSince = stringResource(Res.string.profile_headline_member_since) + val headlineBio = stringResource(Res.string.profile_headline_bio) + 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 hideBackButton = navController.currentDestination?.route == "chat" val targetUserId = userId.takeIf { it != null && it > 0 } val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } @@ -142,7 +164,7 @@ fun ProfileScreen( state = latestUi.copy( profile = null, isLoading = false, - error = "Unable to load profile" + error = ProfileLoadError.Generic ) return@onSuccess } @@ -154,7 +176,8 @@ fun ProfileScreen( val fallback = fallbackId?.let { ProfileCache.get(it) } state = latestUi.copy( error = if (latestUi.profile == null && fallback == null) { - err.message ?: "Unable to load profile" + err.message?.takeIf { it.isNotBlank() }?.let { ProfileLoadError.Message(it) } + ?: ProfileLoadError.Generic } else { null }, @@ -171,7 +194,7 @@ fun ProfileScreen( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MediumTopAppBar( - title = { Text("Profile") }, + title = { Text(profileTitle) }, navigationIcon = { if (!hideBackButton) { Box( @@ -182,7 +205,7 @@ fun ProfileScreen( ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = cdBack, modifier = Modifier.size(24.dp) ) } @@ -195,7 +218,7 @@ fun ProfileScreen( Box( modifier = Modifier.fillMaxSize() ) { - val errorMessage = state.error + val loadError = state.error val profile = state.profile val displayName = profile?.displayName?.takeIf { it.isNotBlank() } ?: profile?.username?.takeIf { it.isNotBlank() } @@ -280,9 +303,13 @@ fun ProfileScreen( state.isLoading -> { CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) } - errorMessage != null -> { + loadError != null -> { + val errText = when (loadError) { + ProfileLoadError.Generic -> profileLoadFailed + is ProfileLoadError.Message -> loadError.text + } Text( - text = errorMessage, + text = errText, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 24.dp) ) @@ -301,7 +328,8 @@ fun ProfileScreen( } val scope = rememberCoroutineScope() - val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify" + val verificationLabel = + if (profile.verified == true) verifiedSupport else verifyPromptSupport val isOwnProfile = ApiClient.user?.id == profile.id Row( @@ -336,7 +364,7 @@ fun ProfileScreen( val primarySource = remember { MutableInteractionSource() } val primaryClick: () -> Unit val primaryIcon = if (isOwnProfile) Icons.Filled.Settings else Icons.AutoMirrored.Filled.Chat - val primaryLabel = if (isOwnProfile) "Settings" else "Chat" + val primaryLabel = if (isOwnProfile) labelSettings else labelChat primaryClick = if (isOwnProfile) { onOpenSettings @@ -404,7 +432,7 @@ fun ProfileScreen( indication = LocalIndication.current, onClick = { clipboardManager.setText(AnnotatedString(profileLink)) - state = state.copy(linkStatus = "Link copied!") + linkCopied = true } ), contentAlignment = Alignment.Center @@ -420,7 +448,7 @@ fun ProfileScreen( ) Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Link", + text = labelLink, style = MaterialTheme.typography.bodyMedium ) } @@ -428,10 +456,10 @@ fun ProfileScreen( } } - state.linkStatus?.let { status -> + if (linkCopied) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = status, + text = linkCopiedText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) @@ -450,10 +478,10 @@ fun ProfileScreen( showDetailsBio || showDetailsVerify ) { - Category(Modifier.padding(top = 16.dp), title = "Details") { + Category(Modifier.padding(top = 16.dp), title = detailsTitle) { if (showDetailsUsername) { ListItem( - headline = "Username", + headline = headlineUsername, supportingText = profile.username, divider = true, dividerColor = CategoryDefaults.dividerColor, @@ -472,7 +500,7 @@ fun ProfileScreen( } if (showDetailsMemberSince) { ListItem( - headline = "Member since", + headline = headlineMemberSince, supportingText = profile.createdAt, divider = true, dividerColor = CategoryDefaults.dividerColor, @@ -491,7 +519,7 @@ fun ProfileScreen( } if (showDetailsBio) { ListItem( - headline = "Bio", + headline = headlineBio, supportingText = profile.bio, divider = true, dividerColor = CategoryDefaults.dividerColor, @@ -522,7 +550,7 @@ fun ProfileScreen( } ListItem( - headline = "Verification", + headline = headlineVerification, supportingText = verificationLabel, leadingContent = { Icon( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt index e6f2876..0edc928 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt @@ -20,6 +20,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.* import ru.fromchat.api.ApiClient @Composable @@ -30,6 +33,8 @@ fun StatusBadge( size: Dp = 20.dp ) { var isSimilarToVerified by remember(userId, verified) { mutableStateOf(false) } + val cdVerified = stringResource(Res.string.cd_verified_account) + val cdSimilar = stringResource(Res.string.cd_similar_verified) LaunchedEffect(verified, userId, ApiClient.token) { if (verified == true) { @@ -49,18 +54,18 @@ fun StatusBadge( when { verified == true -> Icon( imageVector = Icons.Filled.Verified, - contentDescription = "Verified account", + contentDescription = cdVerified, modifier = modifier .size(size) - .semantics { contentDescription = "Verified account" }, + .semantics { contentDescription = cdVerified }, tint = MaterialTheme.colorScheme.primary ) isSimilarToVerified -> Icon( imageVector = Icons.Filled.Warning, - contentDescription = "Similar to verified account", + contentDescription = cdSimilar, modifier = modifier .size(size) - .semantics { contentDescription = "Similar to verified account" }, + .semantics { contentDescription = cdSimilar }, tint = Color(0xFFFFA000) ) else -> {} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerifyButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerifyButton.kt index 628ad11..683e3cd 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerifyButton.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerifyButton.kt @@ -13,7 +13,10 @@ import androidx.compose.runtime.setValue import androidx.compose.foundation.layout.size import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource import kotlinx.coroutines.Dispatchers +import ru.fromchat.Res +import ru.fromchat.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient @@ -29,6 +32,8 @@ fun VerifyButton( var isVerifying by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + val labelVerify = stringResource(Res.string.verify) + val labelUnverify = stringResource(Res.string.unverify) Button( onClick = { @@ -53,7 +58,7 @@ fun VerifyButton( ) } else { Text( - text = if (verified) "Unverify" else "Verify" + text = if (verified) labelUnverify else labelVerify ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt index baab3d8..1276e99 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt @@ -43,16 +43,11 @@ import com.pr0gramm3r101.utils.navigateAndWipeBackStack import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.api.WebSocketManager -import ru.fromchat.back import ru.fromchat.core.ServerConfigData import ru.fromchat.core.config.Config -import ru.fromchat.https_enabled -import ru.fromchat.save_continue -import ru.fromchat.server_config_subtitle -import ru.fromchat.server_config_title -import ru.fromchat.server_url_label import ru.fromchat.ui.LocalNavController @OptIn(ExperimentalMaterial3Api::class) @@ -62,7 +57,7 @@ fun ServerConfigScreen() { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) // Load existing config if available - var serverUrl by remember { mutableStateOf("fromchat.ru") } + var serverUrl by remember { mutableStateOf("") } var httpsEnabled by remember { mutableStateOf(true) } LaunchedEffect(Unit) { @@ -123,7 +118,7 @@ fun ServerConfigScreen() { value = serverUrl, onValueChange = { serverUrl = it }, label = { Text(stringResource(Res.string.server_url_label)) }, - placeholder = { Text("fromchat.ru") }, + placeholder = { Text(stringResource(Res.string.server_url_hint)) }, modifier = Modifier.fillMaxWidth(), singleLine = true ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt new file mode 100644 index 0000000..5b9d798 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt @@ -0,0 +1,135 @@ +package ru.fromchat.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.* +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +private fun formatFromXmlTemplate(template: String, vararg args: Any): String { + var result = template + args.forEachIndexed { index, arg -> + val n = index + 1 + val text = arg.toString() + result = result.replace("%${n}\$s", text).replace("%${n}\$d", text) + } + return result +} + +data class LastSeenFormatStrings( + val online: String, + val recently: String, + val todayAt: String, + val yesterdayAt: String, + val weekdayAt: String, + val dateThisYear: String, + val dateFull: String, + val weekdayLabel: (DayOfWeek) -> String, + val monthShort: (Int) -> String, +) + +@Composable +fun rememberLastSeenFormatStrings(): LastSeenFormatStrings { + val online = stringResource(Res.string.presence_online) + val recently = stringResource(Res.string.presence_recently) + val todayAt = stringResource(Res.string.presence_today_at) + val yesterdayAt = stringResource(Res.string.presence_yesterday_at) + val weekdayAt = stringResource(Res.string.presence_weekday_at) + val dateThisYear = stringResource(Res.string.presence_date_this_year) + val dateFull = stringResource(Res.string.presence_date_full) + val mon = stringResource(Res.string.weekday_mon) + val tue = stringResource(Res.string.weekday_tue) + val wed = stringResource(Res.string.weekday_wed) + val thu = stringResource(Res.string.weekday_thu) + val fri = stringResource(Res.string.weekday_fri) + val sat = stringResource(Res.string.weekday_sat) + val sun = stringResource(Res.string.weekday_sun) + val mJan = stringResource(Res.string.month_jan) + val mFeb = stringResource(Res.string.month_feb) + val mMar = stringResource(Res.string.month_mar) + val mApr = stringResource(Res.string.month_apr) + val mMay = stringResource(Res.string.month_may) + val mJun = stringResource(Res.string.month_jun) + val mJul = stringResource(Res.string.month_jul) + val mAug = stringResource(Res.string.month_aug) + val mSep = stringResource(Res.string.month_sep) + val mOct = stringResource(Res.string.month_oct) + val mNov = stringResource(Res.string.month_nov) + val mDec = stringResource(Res.string.month_dec) + return remember( + online, recently, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull, + mon, tue, wed, thu, fri, sat, sun, + mJan, mFeb, mMar, mApr, mMay, mJun, mJul, mAug, mSep, mOct, mNov, mDec + ) { + val months = listOf(mJan, mFeb, mMar, mApr, mMay, mJun, mJul, mAug, mSep, mOct, mNov, mDec) + LastSeenFormatStrings( + online = online, + recently = recently, + todayAt = todayAt, + yesterdayAt = yesterdayAt, + weekdayAt = weekdayAt, + dateThisYear = dateThisYear, + dateFull = dateFull, + weekdayLabel = { d -> + when (d) { + DayOfWeek.MONDAY -> mon + DayOfWeek.TUESDAY -> tue + DayOfWeek.WEDNESDAY -> wed + DayOfWeek.THURSDAY -> thu + DayOfWeek.FRIDAY -> fri + DayOfWeek.SATURDAY -> sat + DayOfWeek.SUNDAY -> sun + } + }, + monthShort = { monthNumber -> months.getOrElse(monthNumber - 1) { "" } }, + ) + } +} + +/** + * Readable last-seen line in local time (24h), using strings from [rememberLastSeenFormatStrings]. + */ +@OptIn(ExperimentalTime::class) +fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrings): String { + if (online) return s.online + val iso = lastSeenIso ?: return "" + val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return s.recently + + val timeZone = TimeZone.currentSystemDefault() + val lastLocal = instant.toLocalDateTime(timeZone) + val nowDate = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) + .toLocalDateTime(timeZone).date + val lastDate = lastLocal.date + + val hour = lastLocal.hour.toString().padStart(2, '0') + val minute = lastLocal.minute.toString().padStart(2, '0') + val timePart = "$hour:$minute" + + val yesterday = nowDate.minus(DatePeriod(days = 1)) + val daysBetween = nowDate.toEpochDays() - lastDate.toEpochDays() + + return when { + lastDate == nowDate -> formatFromXmlTemplate(s.todayAt, timePart) + lastDate == yesterday -> formatFromXmlTemplate(s.yesterdayAt, timePart) + daysBetween in 2..6 -> { + val label = s.weekdayLabel(lastLocal.dayOfWeek) + formatFromXmlTemplate(s.weekdayAt, label, timePart) + } + lastDate.year == nowDate.year -> { + val mon = s.monthShort(lastDate.monthNumber) + formatFromXmlTemplate(s.dateThisYear, lastDate.dayOfMonth, mon, timePart) + } + else -> { + val mon = s.monthShort(lastDate.monthNumber) + formatFromXmlTemplate(s.dateFull, lastDate.dayOfMonth, mon, lastDate.year, timePart) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt deleted file mode 100644 index b0b9e17..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/RelativeTime.kt +++ /dev/null @@ -1,61 +0,0 @@ -package ru.fromchat.utils - -import kotlinx.datetime.DatePeriod -import kotlinx.datetime.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.minus -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock -import kotlin.time.ExperimentalTime - -private val monthShortEn = listOf( - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" -) - -/** - * Returns a readable last-seen line in local time (24h clock), avoiding raw ISO dates. - */ -@OptIn(ExperimentalTime::class) -fun formatLastSeen(online: Boolean, lastSeenIso: String?): String { - if (online) return "Online" - val iso = lastSeenIso ?: return "" - - val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return "Last seen recently" - - val timeZone = TimeZone.currentSystemDefault() - val lastLocal = instant.toLocalDateTime(timeZone) - val nowDate = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) - .toLocalDateTime(timeZone).date - val lastDate = lastLocal.date - - val hour = lastLocal.hour.toString().padStart(2, '0') - val minute = lastLocal.minute.toString().padStart(2, '0') - val timePart = "$hour:$minute" - - val yesterday = nowDate.minus(DatePeriod(days = 1)) - val daysBetween = nowDate.toEpochDays() - lastDate.toEpochDays() - - return when { - lastDate == nowDate -> "Last seen today at $timePart" - lastDate == yesterday -> "Last seen yesterday at $timePart" - daysBetween in 2..6 -> { - val label = lastDate.dayOfWeek.name - .lowercase() - .split("_") - .joinToString(" ") { word -> - word.replaceFirstChar { c -> c.titlecase() } - } - "Last seen $label at $timePart" - } - lastDate.year == nowDate.year -> { - val mon = monthShortEn.getOrElse(lastDate.monthNumber - 1) { "" } - "Last seen ${lastDate.dayOfMonth} $mon at $timePart" - } - else -> { - val mon = monthShortEn.getOrElse(lastDate.monthNumber - 1) { "" } - "Last seen ${lastDate.dayOfMonth} $mon ${lastDate.year} at $timePart" - } - } -} - diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt index 42330b6..6668c61 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt @@ -1,291 +1,8 @@ package ru.fromchat.ui.debug -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material3.Button -import androidx.compose.material3.DividerDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.pr0gramm3r101.utils.settings.settings -import kotlinx.coroutines.launch -import ru.fromchat.api.ApiClient -import ru.fromchat.crypto.IdentityKeyManager -import ru.fromchat.crypto.decryptEnvelope -import ru.fromchat.ui.LocalNavController -@OptIn(ExperimentalMaterial3Api::class) @Composable actual fun DebugApiScreen() { - val navController = LocalNavController.current - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - - var statusMessage by rememberSaveable { mutableStateOf("") } - var profileResult by rememberSaveable { mutableStateOf(null) } - var conversationsResult by rememberSaveable { mutableStateOf(null) } - var historyResult by rememberSaveable { mutableStateOf(null) } - var historyUserId by rememberSaveable { mutableStateOf("0") } - var decryptedMessage by rememberSaveable { mutableStateOf(null) } - var sendRecipientId by rememberSaveable { mutableStateOf("") } - var sendMessageText by rememberSaveable { mutableStateOf("") } - - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) - } - }, - title = { - Text(text = "Debug API") - }, - actions = { - IconButton(onClick = {}) { - Icon(imageVector = Icons.Filled.BugReport, contentDescription = null) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .verticalScroll(scrollState) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = statusMessage.takeIf { it.isNotBlank() } - ?: "Status will appear here", - style = MaterialTheme.typography.bodyMedium - ) - - Button( - onClick = { - scope.launch { - runCatching { ApiClient.getOwnProfile() } - .onSuccess { - profileResult = ApiClient.json.encodeToString(it) - statusMessage = "Profile loaded" - } - .onFailure { - statusMessage = it.message ?: "An unknown error occurred" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Load own profile") - } - - Text( - text = profileResult ?: "No data loaded yet", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - scope.launch { - runCatching { ApiClient.getDmConversations() } - .onSuccess { - conversationsResult = ApiClient.json.encodeToString(it) - statusMessage = "Conversations loaded" - } - .onFailure { - statusMessage = it.message ?: "An unknown error occurred" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Load DM conversations") - } - - Text( - text = conversationsResult ?: "No data loaded yet", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = historyUserId, - onValueChange = { historyUserId = it }, - label = { Text(text = "History user ID") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = historyUserId.toIntOrNull() - if (userId == null) { - statusMessage = "Enter a valid user ID" - return@Button - } - - scope.launch { - runCatching { ApiClient.getDmHistory(userId) } - .onSuccess { - historyResult = ApiClient.json.encodeToString(it) - statusMessage = "DM history loaded" - } - .onFailure { - statusMessage = it.message ?: "An unknown error occurred" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Load DM history") - } - - Text( - text = historyResult ?: "No data loaded yet", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = DividerDefaults.Thickness, - color = DividerDefaults.color - ) - - Text( - text = "DM Send Test", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = sendRecipientId, - onValueChange = { sendRecipientId = it }, - label = { Text(text = "Recipient user ID") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = sendMessageText, - onValueChange = { sendMessageText = it }, - label = { Text(text = "Message text") }, - singleLine = false, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = sendRecipientId.toIntOrNull() - if (userId == null) { - statusMessage = "Enter a valid recipient ID" - return@Button - } - if (sendMessageText.isBlank()) { - statusMessage = "Enter a message to send" - return@Button - } - - scope.launch { - runCatching { - ApiClient.sendDm( - recipientId = userId, - plaintext = sendMessageText.trim(), - replyToId = null - ) - }.onSuccess { - statusMessage = "DM sent successfully" - }.onFailure { - statusMessage = it.message ?: "Failed to send DM" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Send DM") - } - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = DividerDefaults.Thickness, - color = DividerDefaults.color - ) - - Text( - text = "DM Decryption Test", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = historyUserId.toIntOrNull() - if (userId == null) { - statusMessage = "Enter a valid user ID" - return@Button - } - - scope.launch { - try { - IdentityKeyManager.restoreFromLocal() - - val history = ApiClient.getDmHistory(userId) - if (history.messages.isEmpty()) { - statusMessage = "No messages found" - decryptedMessage = null - return@launch - } - - val currentUserId = settings.getInt("current_user_id", 0) - if (currentUserId == 0) { - statusMessage = "Current user ID not found" - decryptedMessage = null - return@launch - } - - val firstEnvelope = history.messages.first() - val plaintext = decryptEnvelope(firstEnvelope, currentUserId) - decryptedMessage = "Decrypted: $plaintext" - statusMessage = "Decryption successful" - } catch (e: Exception) { - statusMessage = "Decryption failed: ${e.message}" - decryptedMessage = "Error: ${e.message}\n${e.stackTraceToString()}" - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Decrypt First DM") - } - - Text( - text = decryptedMessage ?: "No decrypted message yet", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - } - } + DebugApiScreenContent() }