Translate to Russian

This commit is contained in:
2026-04-04 00:39:39 +03:00
Unverified
parent 5353b9bb25
commit dfda05465b
27 changed files with 1111 additions and 878 deletions
@@ -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 5. For **each** chosen device `id`: **`mobile_install_app`** with `path` = absolute path to
`app/android/build/outputs/apk/debug/android-debug.apk` `app/android/build/outputs/apk/debug/android-debug.apk`
in this repo. 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. 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.
+1 -1
View File
@@ -6,7 +6,7 @@ When working with the mobile app:
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors. - After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), 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 # ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions. - ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
+17
View File
@@ -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.
@@ -1,346 +1,8 @@
package ru.fromchat.ui.debug 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.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 @Composable
actual fun DebugApiScreen() { actual fun DebugApiScreen() {
val navController = LocalNavController.current DebugApiScreenContent()
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var statusMessage by rememberSaveable { mutableStateOf("") }
var profileResult by rememberSaveable { mutableStateOf<String?>(null) }
var conversationsResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyUserId by rememberSaveable { mutableStateOf("0") }
var decryptedMessage by rememberSaveable { mutableStateOf<String?>(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()
)
}
}
} }
@@ -1,14 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">FromChat</string>
<string name="back">Назад</string>
<string name="settings">Настройки</string>
<string name="home">Главная</string>
<string name="about">О приложении</string>
<string name="about_version">Версия 1.0</string> <string name="about_version">Версия 1.0</string>
<string name="about_link_telegram">Telegram</string> <string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string> <string name="about_link_max">MAX</string>
<string name="about_link_website">Сайт</string> <string name="about_link_website">Сайт</string>
<string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string> <string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string>
<string name="welcome">Добро пожаловать!</string>
<!-- Typing Indicators --> <string name="login">Войти</string>
<string name="login_d">Войдите в свой аккаунт</string>
<string name="register">Регистрация</string>
<string name="register_d">Создать новый аккаунт</string>
<string name="register_button">Создать аккаунт</string>
<string name="username">Имя пользователя</string>
<string name="password">Пароль</string>
<string name="confirm_password">Пароль ещё раз</string>
<string name="display_name">Как вас видят другие</string>
<string name="display_name_error">От 1 до 64 символов</string>
<string name="fill_all_fields">Заполните все поля</string>
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
<string name="passwords_dont_match">Пароли не совпадают</string>
<string name="chats">Чаты</string>
<string name="contacts">Контакты</string>
<string name="profile">Профиль</string>
<string name="dms">Личные чаты</string>
<string name="coming_soon">Скоро…</string>
<string name="public_chat">Общий чат</string>
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
<string name="message_placeholder">Напишите сообщение…</string>
<string name="status_connecting">Соединение</string>
<string name="status_updating">Обновление</string>
<string name="chat_group_label">Группа</string>
<string name="chat_members_count">%1$d человек</string>
<string name="cd_call">Позвонить</string>
<string name="message_sender_you">Вы</string>
<string name="user_fallback">Человек %1$d</string>
<string name="message_corrupted">Это сообщение не удалось показать.</string>
<string name="message_edited_suffix">(изменено)</string>
<string name="message_replying_to">Ответ %1$s</string>
<string name="message_corrupted_short">Сообщение не показывается</string>
<string name="message_editing_title">Правка сообщения</string>
<string name="action_reply">Ответить</string>
<string name="action_edit">Изменить</string>
<string name="action_delete">Удалить</string>
<string name="action_save">Сохранить</string>
<string name="cd_close">Закрыть</string>
<string name="cd_remove">Убрать</string>
<string name="cd_pick_image">Выбрать фото</string>
<string name="cd_pick_file">Выбрать файл</string>
<string name="cd_send">Отправить</string>
<string name="profile_title">Профиль</string>
<string name="profile_load_failed">Не получилось загрузить профиль</string>
<string name="action_open_settings">Настройки</string>
<string name="action_chat">Написать</string>
<string name="action_copy_link">Скопировать ссылку</string>
<string name="link_copied">Ссылка скопирована</string>
<string name="profile_details_category">О человеке</string>
<string name="profile_headline_username">Имя пользователя</string>
<string name="profile_headline_member_since">Дата регистрации</string>
<string name="profile_headline_bio">О себе</string>
<string name="profile_headline_verification">Официальный аккаунт</string>
<string name="profile_verified_support">Это официальный аккаунт</string>
<string name="profile_verify_prompt_support">Нажмите, чтобы сделать аккаунт официальным (только админы)</string>
<string name="verify">Сделать официальным</string>
<string name="unverify">Снять официальный статус</string>
<string name="cd_verified_account">Официальный аккаунт</string>
<string name="cd_similar_verified">Похож на официальный аккаунт</string>
<string name="presence_online">В сети</string>
<string name="presence_recently">Недавно заходил</string>
<string name="presence_today_at">Сегодня в %1$s</string>
<string name="presence_yesterday_at">Вчера в %1$s</string>
<string name="presence_weekday_at">%1$s в %2$s</string>
<string name="presence_date_this_year">%1$d %2$s в %3$s</string>
<string name="presence_date_full">%1$d %2$s %3$d в %4$s</string>
<string name="weekday_mon">понедельник</string>
<string name="weekday_tue">вторник</string>
<string name="weekday_wed">среда</string>
<string name="weekday_thu">четверг</string>
<string name="weekday_fri">пятница</string>
<string name="weekday_sat">суббота</string>
<string name="weekday_sun">воскресенье</string>
<string name="month_jan">янв</string>
<string name="month_feb">фев</string>
<string name="month_mar">мар</string>
<string name="month_apr">апр</string>
<string name="month_may">май</string>
<string name="month_jun">июн</string>
<string name="month_jul">июл</string>
<string name="month_aug">авг</string>
<string name="month_sep">сен</string>
<string name="month_oct">окт</string>
<string name="month_nov">ноя</string>
<string name="month_dec">дек</string>
<string name="server_config_title">Подключение к серверу</string>
<string name="server_config_subtitle">Введите адрес сервера, к которому хотите подключиться</string>
<string name="server_url_label">Адрес сервера</string>
<string name="server_url_hint">example.com</string>
<string name="https_enabled">Защищённое соединение</string>
<string name="save_continue">Сохранить и продолжить</string>
<string name="change_server">Сменить сервер</string>
<string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</string>
<string name="logout">Выйти</string>
<string name="materialYou">Material You</string>
<string name="materialYou_d">Цвета как на обоях. Работает с Android 12 и новее.</string>
<string name="theme">Оформление</string>
<string name="as_system">Как на телефоне</string>
<string name="light">Светлое</string>
<string name="dark">Тёмное</string>
<string name="debug_tools">Отладка API</string>
<string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string>
<string name="error_unexpected">Что-то пошло не так</string>
<string name="error_invalid_credentials">Неверное имя пользователя или пароль</string>
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
<string name="error_unknown">Что-то пошло не так. Попробуйте ещё раз.</string>
<string name="typing_single">%1$s печатает…</string> <string name="typing_single">%1$s печатает…</string>
<string name="typing_two">%1$s и %2$s печатают…</string> <string name="typing_two">%1$s и %2$s печатают…</string>
<string name="typing_many">%1$s, %2$s и еще %3$d печатают…</string> <string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
<string name="more">Ещё</string>
<string name="unread_count">+%1$d</string>
</resources> </resources>
@@ -1,88 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- App --> <!-- App -->
<string name="app_name">FromChat</string> <string name="app_name">FromChat</string>
<!-- Navigation --> <!-- Navigation -->
<string name="back">Back</string> <string name="back">Back</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="home">Home</string> <string name="home">Home</string>
<string name="about">About app</string> <string name="about">About</string>
<string name="about_version">Version 1.0</string> <string name="about_version">Version 1.0</string>
<string name="about_link_telegram">Telegram</string> <string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string> <string name="about_link_max">MAX</string>
<string name="about_link_website">Website</string> <string name="about_link_website">Website</string>
<string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string> <string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string>
<!-- Authentication --> <!-- Authentication -->
<string name="welcome">Welcome!</string> <string name="welcome">Welcome!</string>
<string name="login">Sign In</string> <string name="login">Log in</string>
<string name="login_d">Sign in to your account</string> <string name="login_d">Log in to your account</string>
<string name="register">Registration</string> <string name="register">Sign up</string>
<string name="register_d">Create a new account</string> <string name="register_d">Create a new account</string>
<string name="register_button">Register</string> <string name="register_button">Create account</string>
<string name="username">Username</string> <string name="username">Username</string>
<string name="password">Password</string> <string name="password">Password</string>
<string name="confirm_password">Confirm Password</string> <string name="confirm_password">Password again</string>
<string name="display_name">Display Name</string> <string name="display_name">Name shown to others</string>
<string name="display_name_error">Display name must be between 1 and 64 characters</string> <string name="display_name_error">Use between 1 and 64 characters</string>
<!-- Validation Errors --> <!-- Validation Errors -->
<string name="fill_all_fields">Please fill in all fields</string> <string name="fill_all_fields">Please fill in every field</string>
<string name="username_length_error">Username must be between 3 and 20 characters</string> <string name="username_length_error">Username must be 3 to 20 characters</string>
<string name="password_length_error">Password must be between 5 and 50 characters</string> <string name="password_length_error">Password must be 5 to 50 characters</string>
<string name="passwords_dont_match">Passwords do not match</string> <string name="passwords_dont_match">The two passwords dont match</string>
<!-- Main Screen --> <!-- Main Screen -->
<string name="chats">Chats</string> <string name="chats">Chats</string>
<string name="contacts">Contacts</string> <string name="contacts">Contacts</string>
<string name="dms">Direct Messages</string> <string name="profile">Profile</string>
<string name="coming_soon">Coming soon...</string> <string name="dms">Private chats</string>
<string name="public_chat">General Chat</string> <string name="coming_soon">Coming soon…</string>
<string name="chat_last_mesaage">You: Last message</string> <string name="public_chat">Main chat</string>
<string name="message_placeholder">Type a message...</string> <string name="chat_last_mesaage">You: last message</string>
<string name="message_placeholder">Write a message…</string>
<!-- Server Configuration -->
<string name="server_config_title">Server Configuration</string> <!-- Connection / chat chrome -->
<string name="server_config_subtitle">Enter server details to connect</string> <string name="status_connecting">Connecting</string>
<string name="server_url_label">Server URL</string> <string name="status_updating">Updating</string>
<string name="server_url_hint">example.com</string> <string name="chat_group_label">Group</string>
<string name="https_enabled">Use HTTPS</string> <string name="chat_members_count">%1$d people</string>
<string name="save_continue">Save &amp; Continue</string> <string name="cd_call">Call</string>
<!-- Messages -->
<string name="message_sender_you">You</string>
<string name="user_fallback">Person %1$d</string>
<string name="message_corrupted">This message could not be shown.</string>
<string name="message_edited_suffix">(edited)</string>
<string name="message_replying_to">Reply to %1$s</string>
<string name="message_corrupted_short">Cant show this message</string>
<string name="message_editing_title">Edit message</string>
<!-- Message actions -->
<string name="action_reply">Reply</string>
<string name="action_edit">Edit</string>
<string name="action_delete">Delete</string>
<string name="action_save">Save</string>
<!-- Chat input -->
<string name="cd_close">Close</string>
<string name="cd_remove">Remove</string>
<string name="cd_pick_image">Choose photo</string>
<string name="cd_pick_file">Choose file</string>
<string name="cd_send">Send</string>
<!-- Profile -->
<string name="profile_title">Profile</string>
<string name="profile_load_failed">Couldnt load this profile</string>
<string name="action_open_settings">Settings</string>
<string name="action_chat">Chat</string>
<string name="action_copy_link">Copy link</string>
<string name="link_copied">Link copied</string>
<string name="profile_details_category">About this person</string>
<string name="profile_headline_username">Username</string>
<string name="profile_headline_member_since">Joined</string>
<string name="profile_headline_bio">About</string>
<string name="profile_headline_verification">Verified account</string>
<string name="profile_verified_support">This account is verified</string>
<string name="profile_verify_prompt_support">Tap to verify (admins only)</string>
<!-- Verify (admin) -->
<string name="verify">Verify</string>
<string name="unverify">Remove verification</string>
<!-- Status badge -->
<string name="cd_verified_account">Verified account</string>
<string name="cd_similar_verified">May be a verified account</string>
<!-- Presence / last seen -->
<string name="presence_online">Online</string>
<string name="presence_recently">Active recently</string>
<string name="presence_today_at">Today at %1$s</string>
<string name="presence_yesterday_at">Yesterday at %1$s</string>
<string name="presence_weekday_at">%1$s at %2$s</string>
<string name="presence_date_this_year">%1$d %2$s at %3$s</string>
<string name="presence_date_full">%1$d %2$s %3$d at %4$s</string>
<string name="weekday_mon">Monday</string>
<string name="weekday_tue">Tuesday</string>
<string name="weekday_wed">Wednesday</string>
<string name="weekday_thu">Thursday</string>
<string name="weekday_fri">Friday</string>
<string name="weekday_sat">Saturday</string>
<string name="weekday_sun">Sunday</string>
<string name="month_jan">Jan</string>
<string name="month_feb">Feb</string>
<string name="month_mar">Mar</string>
<string name="month_apr">Apr</string>
<string name="month_may">May</string>
<string name="month_jun">Jun</string>
<string name="month_jul">Jul</string>
<string name="month_aug">Aug</string>
<string name="month_sep">Sep</string>
<string name="month_oct">Oct</string>
<string name="month_nov">Nov</string>
<string name="month_dec">Dec</string>
<!-- Server Configuration -->
<string name="server_config_title">Connect to a server</string>
<string name="server_config_subtitle">Enter the server you want to use</string>
<string name="server_url_label">Server address</string>
<string name="server_url_hint">example.com</string>
<string name="https_enabled">Use a secure connection</string>
<string name="save_continue">Save and continue</string>
<!-- Settings --> <!-- Settings -->
<string name="change_server">Change Server</string> <string name="change_server">Change server</string>
<string name="change_server_d">Changes the FromChat instance you are connecting to. This will log you out of your account.</string> <string name="change_server_d">Connect to an alternative FromChat server and sign out.</string>
<string name="logout">Logout</string> <string name="logout">Log out</string>
<string name="materialYou">Enable Material You theming</string> <string name="materialYou">Material You</string>
<string name="materialYou_d">Enable the dynamic theme based on your wallpaper. Works only on Android 12 or higher.</string> <string name="materialYou_d">Match colors to your wallpaper. Works on Android 12 and up.</string>
<string name="theme">Theme</string> <string name="theme">Look</string>
<string name="as_system">As system</string> <string name="as_system">Same as phone</string>
<string name="light">Light</string> <string name="light">Light</string>
<string name="dark">Dark</string> <string name="dark">Dark</string>
<string name="debug_tools">Debug API</string> <string name="debug_tools">Debug API</string>
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string> <string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string>
<string name="debug_status_placeholder">Status will appear here</string>
<string name="debug_profile_loaded">Profile loaded</string>
<string name="debug_load_profile">Load own profile</string>
<string name="debug_not_loaded">No data loaded yet</string>
<string name="debug_conversations_loaded">Conversations loaded</string>
<string name="debug_load_conversations">Load DM conversations</string>
<string name="debug_history_user_id_label">History user ID</string>
<string name="debug_invalid_user_id">Enter a valid user ID</string>
<string name="debug_history_loaded">DM history loaded</string>
<string name="debug_load_history">Load DM history</string>
<!-- Error Messages --> <!-- Error Messages -->
<string name="error_unexpected">Unexpected error</string> <string name="error_unexpected">Something went wrong</string>
<string name="error_invalid_credentials">Invalid username or password</string> <string name="error_invalid_credentials">Wrong username or password</string>
<string name="error_connection">Connection error</string> <string name="error_connection">Couldnt connect. Check your internet.</string>
<string name="error_unknown">An unknown error occurred</string> <string name="error_unknown">Something went wrong. Please try again.</string>
<!-- Typing Indicators --> <!-- Typing Indicators -->
<string name="typing_single">%1$s is typing…</string> <string name="typing_single">%1$s is typing…</string>
<string name="typing_two">%1$s and %2$s are typing…</string> <string name="typing_two">%1$s and %2$s are typing…</string>
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string> <string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
<string name="more">More</string> <string name="more">More</string>
<string name="unread_count">+%1$d</string>
</resources> </resources>
@@ -5,7 +5,9 @@ import ru.fromchat.api.DmEnvelope
import ru.fromchat.crypto.dm.DmCrypto import ru.fromchat.crypto.dm.DmCrypto
/** Shown in the UI when [DmCiphertextCorruptedException] is caught while decrypting a DM. */ /** 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 * Unwrap a MEK (Message Encryption Key) using the appropriate wrapping key
@@ -70,7 +70,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.message_placeholder import ru.fromchat.*
@Composable @Composable
private fun <T> AnimatedPreviewBar( private fun <T> AnimatedPreviewBar(
@@ -101,6 +101,7 @@ private fun PreviewBar(
icon: ImageVector, icon: ImageVector,
title: String, title: String,
subtitle: String, subtitle: String,
closeContentDescription: String,
onClose: () -> Unit onClose: () -> Unit
) { ) {
Row( Row(
@@ -137,7 +138,7 @@ private fun PreviewBar(
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Close", contentDescription = closeContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -148,6 +149,7 @@ private fun PreviewBar(
private fun AttachmentChip( private fun AttachmentChip(
attachment: SelectedAttachment, attachment: SelectedAttachment,
onRemove: () -> Unit, onRemove: () -> Unit,
removeContentDescription: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
@@ -186,7 +188,7 @@ private fun AttachmentChip(
IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Remove", contentDescription = removeContentDescription,
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -206,7 +208,8 @@ fun ChatInput(
onClearReply: () -> Unit, onClearReply: () -> Unit,
onClearEdit: () -> Unit, onClearEdit: () -> Unit,
hazeState: HazeState, hazeState: HazeState,
recipientId: Int? = null recipientId: Int? = null,
currentUserId: Int? = null
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) } var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
@@ -251,6 +254,13 @@ fun ChatInput(
} }
val canSend = text.isNotBlank() || attachments.isNotEmpty() 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( Box(
modifier = Modifier modifier = Modifier
@@ -277,28 +287,31 @@ fun ChatInput(
) { ) {
AnimatedPreviewBar(replyTo) { replyTo -> AnimatedPreviewBar(replyTo) { replyTo ->
val replySubtitle = if (replyTo.isContentCorrupted) { val replySubtitle = if (replyTo.isContentCorrupted) {
"Corrupted message" corruptedShort
} else { } else {
replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "" replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else ""
} }
val replyName = messageDisplayUsername(replyTo, currentUserId)
PreviewBar( PreviewBar(
icon = Icons.AutoMirrored.Filled.Reply, icon = Icons.AutoMirrored.Filled.Reply,
title = "Replying to ${replyTo.username}", title = stringResource(Res.string.message_replying_to, replyName),
subtitle = replySubtitle, subtitle = replySubtitle,
closeContentDescription = cdClose,
onClose = { onClearReply() } onClose = { onClearReply() }
) )
} }
AnimatedPreviewBar(editingMessage) { message -> AnimatedPreviewBar(editingMessage) { message ->
val subtitle = if (message.isContentCorrupted) { val subtitle = if (message.isContentCorrupted) {
"Corrupted message" corruptedShort
} else { } else {
message.content.take(50) + if (message.content.length > 50) "..." else "" message.content.take(50) + if (message.content.length > 50) "..." else ""
} }
PreviewBar( PreviewBar(
icon = Icons.Filled.Edit, icon = Icons.Filled.Edit,
title = "Editing message", title = editingTitle,
subtitle = subtitle, subtitle = subtitle,
closeContentDescription = cdClose,
onClose = { onClearEdit() } onClose = { onClearEdit() }
) )
} }
@@ -318,7 +331,8 @@ fun ChatInput(
attachments.forEach { attachment -> attachments.forEach { attachment ->
AttachmentChip( AttachmentChip(
attachment = attachment, 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() }) { IconButton(onClick = { launchImagePicker() }) {
Icon( Icon(
imageVector = Icons.Default.Image, imageVector = Icons.Default.Image,
contentDescription = "Pick image", contentDescription = cdPickImage,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
IconButton(onClick = { launchFilePicker() }) { IconButton(onClick = { launchFilePicker() }) {
Icon( Icon(
imageVector = Icons.Default.AttachFile, imageVector = Icons.Default.AttachFile,
contentDescription = "Pick file", contentDescription = cdPickFile,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -397,7 +411,7 @@ fun ChatInput(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Send, imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send", contentDescription = cdSend,
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
@@ -74,6 +74,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.AttachmentUploadJob import ru.fromchat.api.AttachmentUploadJob
@@ -85,7 +86,6 @@ import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.ConnectingEllipsis
@@ -95,6 +95,7 @@ import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.chat.getImageAspectRatio import ru.fromchat.ui.chat.getImageAspectRatio
import ru.fromchat.ui.scaleOnPress import ru.fromchat.ui.scaleOnPress
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.rememberLastSeenFormatStrings
import kotlin.time.Clock import kotlin.time.Clock
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -144,6 +145,11 @@ fun ChatScreen(
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) 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) { LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
} }
@@ -413,7 +419,7 @@ fun ChatScreen(
panelState.profileUserId != null -> { panelState.profileUserId != null -> {
val userStatus = statusMap[panelState.profileUserId] val userStatus = statusMap[panelState.profileUserId]
val statusText = userStatus?.let { val statusText = userStatus?.let {
formatLastSeen(it.online, it.lastSeen) formatLastSeen(it.online, it.lastSeen, lastSeenFormat)
}.orEmpty() }.orEmpty()
if (statusText.isNotEmpty()) { if (statusText.isNotEmpty()) {
"presence:$statusText" "presence:$statusText"
@@ -434,12 +440,23 @@ fun ChatScreen(
) { key -> ) { key ->
when { when {
key == "updating" -> { key == "updating" -> {
Text( val st = MaterialTheme.typography.bodySmall
text = "Updating...", val col = MaterialTheme.colorScheme.onSurfaceVariant
style = MaterialTheme.typography.bodySmall, Row(
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp),
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" -> { key == "connecting" -> {
val st = MaterialTheme.typography.bodySmall val st = MaterialTheme.typography.bodySmall
@@ -449,7 +466,7 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Connecting", text = statusConnecting,
style = st, style = st,
color = col color = col
) )
@@ -477,7 +494,7 @@ fun ChatScreen(
} }
key == "group" -> { key == "group" -> {
Text( Text(
text = "group", text = chatGroupLabel,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp) modifier = Modifier.padding(top = 2.dp)
@@ -486,7 +503,7 @@ fun ChatScreen(
key.startsWith("members:") -> { key.startsWith("members:") -> {
val n = key.removePrefix("members:").toIntOrNull() ?: 0 val n = key.removePrefix("members:").toIntOrNull() ?: 0
Text( Text(
text = "$n members", text = stringResource(Res.string.chat_members_count, n),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp) modifier = Modifier.padding(top = 2.dp)
@@ -513,7 +530,7 @@ fun ChatScreen(
IconButton(onClick = { /* TODO: Handle call */ }) { IconButton(onClick = { /* TODO: Handle call */ }) {
Icon( Icon(
imageVector = Icons.Default.Call, imageVector = Icons.Default.Call,
contentDescription = "Call" contentDescription = cdCall
) )
} }
} }
@@ -547,6 +564,7 @@ fun ChatScreen(
ChatInput( ChatInput(
text = inputText, text = inputText,
onTextChange = { inputText = it }, onTextChange = { inputText = it },
currentUserId = currentUserId,
onSend = { text, attachments -> onSend = { text, attachments ->
if (editingMessage != null) { if (editingMessage != null) {
scope.launch { scope.launch {
@@ -67,8 +67,11 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.*
import ru.fromchat.ui.BackHandler import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.LocalSystemBarsVisibility import ru.fromchat.ui.LocalSystemBarsVisibility
import kotlin.math.abs import kotlin.math.abs
@@ -121,6 +124,12 @@ fun ImageFullscreenPreview(
thumbnailBounds: Rect? = null thumbnailBounds: Rect? = null
) { ) {
val file = message.files?.getOrNull(fileIndex) ?: return 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 envelope = message.dmEnvelope
val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex) val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex)
@@ -596,7 +605,7 @@ fun ImageFullscreenPreview(
IconButton(onClick = { dismissRequested = true }) { IconButton(onClick = { dismissRequested = true }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = cdBack,
tint = Color.White tint = Color.White
) )
} }
@@ -605,7 +614,7 @@ fun ImageFullscreenPreview(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = message.username, text = headerName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = Color.White color = Color.White
) )
@@ -620,7 +629,7 @@ fun ImageFullscreenPreview(
IconButton(onClick = { menuExpanded = true }) { IconButton(onClick = { menuExpanded = true }) {
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
contentDescription = "Menu", contentDescription = cdMenu,
tint = Color.White tint = Color.White
) )
} }
@@ -634,7 +643,7 @@ fun ImageFullscreenPreview(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.AutoMirrored.Filled.Reply, null, tint = Color.White) Icon(Icons.AutoMirrored.Filled.Reply, null, tint = Color.White)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Reply", color = Color.White) Text(labelReply, color = Color.White)
} }
}, },
onClick = { onClick = {
@@ -648,7 +657,7 @@ fun ImageFullscreenPreview(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.SaveAlt, null, tint = Color.White) Icon(Icons.Default.SaveAlt, null, tint = Color.White)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Save", color = Color.White) Text(labelSave, color = Color.White)
} }
}, },
onClick = { onClick = {
@@ -661,7 +670,7 @@ fun ImageFullscreenPreview(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Delete, null, tint = Color.White) Icon(Icons.Default.Delete, null, tint = Color.White)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Delete", color = Color.White) Text(labelDelete, color = Color.White)
} }
}, },
onClick = { onClick = {
@@ -46,7 +46,10 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.*
import ru.fromchat.ui.scaleOnPress import ru.fromchat.ui.scaleOnPress
data class ContextMenuState( data class ContextMenuState(
@@ -257,6 +260,9 @@ private fun ContextMenuContent(
val menuColor = MaterialTheme.colorScheme.surfaceContainer val menuColor = MaterialTheme.colorScheme.surfaceContainer
val edgePadding = 8.dp val edgePadding = 8.dp
val itemSpacing = 2.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 = containerModifier) {
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape)) Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
@@ -268,20 +274,20 @@ private fun ContextMenuContent(
) { ) {
ContextMenuItem( ContextMenuItem(
icon = Icons.AutoMirrored.Filled.Reply, icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply", text = labelReply,
onClick = { onReply(message) } onClick = { onReply(message) }
) )
if (isAuthor) { if (isAuthor) {
ContextMenuItem( ContextMenuItem(
icon = Icons.Default.Edit, icon = Icons.Default.Edit,
text = "Edit", text = labelEdit,
onClick = { onEdit(message) } onClick = { onEdit(message) }
) )
} }
if (isAuthor) { if (isAuthor) {
ContextMenuItem( ContextMenuItem(
icon = Icons.Default.Delete, icon = Icons.Default.Delete,
text = "Delete", text = labelDelete,
onClick = { onDelete(message) }, onClick = { onDelete(message) },
isError = true isError = true
) )
@@ -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
}
@@ -53,7 +53,10 @@ import androidx.compose.ui.unit.sp
import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.conditional
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.*
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -95,6 +98,10 @@ fun MessageItem(
val formattedTime = remember(message.timestamp) { val formattedTime = remember(message.timestamp) {
formatTime(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 // 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. // composition (N messages ⇒ N concurrent animations + huge JIT), causing main-thread jank.
@@ -316,7 +323,7 @@ fun MessageItem(
) )
) { ) {
Text( Text(
text = message.username, text = displayUsername,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
@@ -326,7 +333,7 @@ fun MessageItem(
} else { } else {
Box(modifier = usernameOutset) { Box(modifier = usernameOutset) {
Text( Text(
text = message.username, text = displayUsername,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
@@ -348,7 +355,8 @@ fun MessageItem(
) { ) {
Column { Column {
// Reply preview // Reply preview
message.reply_to?.let { replyTo -> replyRef?.let { replyToMsg ->
val replyName = messageDisplayUsername(replyToMsg, currentUserId)
Box( Box(
Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp) Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
) { ) {
@@ -379,7 +387,7 @@ fun MessageItem(
) { ) {
if (showUsername) { if (showUsername) {
Text( Text(
text = replyTo.username, text = replyName,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
@@ -387,7 +395,7 @@ fun MessageItem(
) )
} }
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp, fontSize = 12.sp,
@@ -401,7 +409,7 @@ fun MessageItem(
// Attachments (images/files) or corrupted message // Attachments (images/files) or corrupted message
if (isCorrupted) { if (isCorrupted) {
Text( Text(
text = "_This message is corrupted and cannot be displayed.", text = corruptedBody,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (isAuthor) { color = if (isAuthor) {
Color.White.copy(alpha = 0.8f) Color.White.copy(alpha = 0.8f)
@@ -553,7 +561,7 @@ fun MessageItem(
if (message.is_edited) { if (message.is_edited) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = "(edited)", text = editedSuffix,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp, fontSize = 11.sp,
color = if (isAuthor) { color = if (isAuthor) {
@@ -18,11 +18,14 @@ import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
class PublicChatPanel( 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?, currentUserId: Int?,
scope: CoroutineScope scope: CoroutineScope
) : ChatPanel( ) : ChatPanel(
id = "public-$chatName", id = "public-$panelKey",
currentUserId = currentUserId, currentUserId = currentUserId,
scope = scope scope = scope
) { ) {
@@ -57,8 +60,8 @@ class PublicChatPanel(
init { init {
updateState { updateState {
it.copy( it.copy(
title = chatName, title = displayTitle,
titleAvatar = AvatarInfo(displayName = chatName, profilePictureUrl = null), titleAvatar = AvatarInfo(displayName = displayTitle, profilePictureUrl = null),
publicGroupMetaLoading = true, publicGroupMetaLoading = true,
publicGroupMemberCount = null 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) { private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
updateMessage(reactionUpdate.message_id) { message -> updateMessage(reactionUpdate.message_id) { message ->
message.copy(reactions = reactionUpdate.reactions) message.copy(reactions = reactionUpdate.reactions)
@@ -3,6 +3,7 @@ package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
/** /**
* Single retained [PublicChatPanel] for the app session (same idea as [ru.fromchat.ui.dm.DmPanelCache]). * 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 * 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. * collectors and [ChatPanel] state callbacks keep working after the composable is left and re-entered.
*/ */
object PublicChatPanelCache { object PublicChatPanelCache {
private const val GENERAL_CHAT_NAME = "General Chat" private const val GeneralPublicPanelKey = "general"
private var supervisorJob = SupervisorJob() private var supervisorJob = SupervisorJob()
private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate) private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate)
private var panel: PublicChatPanel? = null 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 var cachedUserId: Int? = null
private fun ensureScope() { private fun ensureScope() {
@@ -29,25 +31,31 @@ object PublicChatPanelCache {
} }
} }
fun getOrCreateGeneralChat(currentUserId: Int?): PublicChatPanel = /**
getOrCreate(GENERAL_CHAT_NAME, currentUserId) * @param displayTitle Localized title from e.g. [ru.fromchat.Res.string.public_chat].
*/
fun getOrCreate(chatName: String, currentUserId: Int?): PublicChatPanel { fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel {
ensureScope() ensureScope()
if ( if (
panel != null && panel != null &&
cachedChatName == chatName && cachedPanelKey == GeneralPublicPanelKey &&
cachedUserId == currentUserId cachedUserId == currentUserId
) { ) {
if (cachedDisplayTitle != displayTitle) {
cachedDisplayTitle = displayTitle
panel!!.applyDisplayTitle(displayTitle)
}
return panel!! return panel!!
} }
panel?.destroy() panel?.destroy()
panel = PublicChatPanel( panel = PublicChatPanel(
chatName = chatName, panelKey = GeneralPublicPanelKey,
displayTitle = displayTitle,
currentUserId = currentUserId, currentUserId = currentUserId,
scope = panelScope scope = panelScope
) )
cachedChatName = chatName cachedPanelKey = GeneralPublicPanelKey
cachedDisplayTitle = displayTitle
cachedUserId = currentUserId cachedUserId = currentUserId
return panel!! return panel!!
} }
@@ -55,7 +63,8 @@ object PublicChatPanelCache {
fun clear() { fun clear() {
panel?.destroy() panel?.destroy()
panel = null panel = null
cachedChatName = null cachedPanelKey = null
cachedDisplayTitle = null
cachedUserId = null cachedUserId = null
supervisorJob.cancel() supervisorJob.cancel()
} }
@@ -6,8 +6,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.ui.isPublicChatVisible import ru.fromchat.ui.isPublicChatVisible
import ru.fromchat.*
@Composable @Composable
fun PublicChatScreen( fun PublicChatScreen(
@@ -16,10 +19,11 @@ fun PublicChatScreen(
animatedContentScope: AnimatedContentScope? = null animatedContentScope: AnimatedContentScope? = null
) { ) {
val currentUserId = ApiClient.user?.id 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. // Reuse one panel for the session (like DM [DmPanelCache]); avoids full reload on every visit.
val panel = remember(currentUserId) { val panel = remember(currentUserId, publicChatTitle) {
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId) PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId)
} }
LaunchedEffect(panel) { LaunchedEffect(panel) {
@@ -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<String?>(null) }
var conversationsResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyUserId by rememberSaveable { mutableStateOf("0") }
var decryptedMessage by rememberSaveable { mutableStateOf<String?>(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()
)
}
}
}
@@ -41,15 +41,14 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStateStore
import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.ConnectionStatus
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.db.CachedConversation import ru.fromchat.api.db.CachedConversation
import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.chat_last_mesaage
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.public_chat
import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.branding.FromChatBrandTitle import ru.fromchat.ui.branding.FromChatBrandTitle
@@ -119,6 +118,10 @@ fun ChatsTab() {
else -> "fromchat" else -> "fromchat"
} }
val connectingTitle = stringResource(Res.string.status_connecting)
val updatingTitle = stringResource(Res.string.status_updating)
val brandTitle = stringResource(Res.string.app_name)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -162,7 +165,7 @@ fun ChatsTab() {
horizontalArrangement = Arrangement.Start horizontalArrangement = Arrangement.Start
) { ) {
Text( Text(
text = "Connecting", text = connectingTitle,
style = style, style = style,
color = color, color = color,
maxLines = 1, maxLines = 1,
@@ -176,16 +179,29 @@ fun ChatsTab() {
} }
} }
"updating" -> { "updating" -> {
Text( val style = MaterialTheme.typography.titleLarge
val color = MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = "Updating...", verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.titleLarge, horizontalArrangement = Arrangement.Start
maxLines = 1, ) {
overflow = TextOverflow.Ellipsis Text(
) text = updatingTitle,
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
ConnectingEllipsis(
fontSize = style.fontSize,
color = color,
baseStyle = style
)
}
} }
else -> { else -> {
FromChatBrandTitle(text = "FromChat") FromChatBrandTitle(text = brandTitle)
} }
} }
} }
@@ -233,7 +249,9 @@ fun ChatsTab() {
val avatarUrl = cached?.profilePicture val avatarUrl = cached?.profilePicture
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.username?.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() val preview = conv.lastMessagePreview?.trim().orEmpty()
ListItem( ListItem(
leadingContent = { leadingContent = {
@@ -264,7 +282,7 @@ fun ChatsTab() {
}, },
trailingContent = { trailingContent = {
if (conv.unreadCount > 0) { if (conv.unreadCount > 0) {
Text("+${conv.unreadCount}") Text(stringResource(Res.string.unread_count, conv.unreadCount))
} }
}, },
modifier = Modifier.clickable { modifier = Modifier.clickable {
@@ -25,11 +25,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient 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.utils.exclude
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
@@ -62,7 +59,7 @@ fun MainScreen(onLogout: () -> Unit = {}) {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == "profile", selected = selectedTab == "profile",
onClick = { selectedTab = "profile" }, onClick = { selectedTab = "profile" },
label = { Text("Profile") }, label = { Text(stringResource(Res.string.profile)) },
icon = { Icon(Icons.Filled.Person, contentDescription = null) } icon = { Icon(Icons.Filled.Person, contentDescription = null) }
) )
} }
@@ -48,6 +48,7 @@ import com.pr0gramm3r101.utils.materialYouAvailable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.about import ru.fromchat.about
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
@@ -203,8 +204,8 @@ fun SettingsTab(
) )
ListItem( ListItem(
headline = "Debug API", headline = stringResource(Res.string.debug_tools),
supportingText = "Inspect profile and DM endpoints used by the client.", supportingText = stringResource(Res.string.debug_tools_d),
onClick = { onClick = {
navController.navigate("debug") 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( ListItem(
headline = stringResource(Res.string.logout), headline = stringResource(Res.string.logout),
leadingContent = { leadingContent = {
@@ -68,6 +68,9 @@ import com.pr0gramm3r101.components.ListItem
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile 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.chat.publicChatProfileSharedAvatarKey
import ru.fromchat.ui.scaleOnPress import ru.fromchat.ui.scaleOnPress
private sealed interface ProfileLoadError {
data object Generic : ProfileLoadError
data class Message(val text: String) : ProfileLoadError
}
private data class ProfileUiState( private data class ProfileUiState(
val profile: UserProfile? = null, val profile: UserProfile? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: ProfileLoadError? = null
val linkStatus: String? = null
) )
private val profileActionCardPressSpring = spring<Float>( private val profileActionCardPressSpring = spring<Float>(
@@ -109,6 +116,21 @@ fun ProfileScreen(
) { ) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val navController = LocalNavController.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 hideBackButton = navController.currentDestination?.route == "chat"
val targetUserId = userId.takeIf { it != null && it > 0 } val targetUserId = userId.takeIf { it != null && it > 0 }
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
@@ -142,7 +164,7 @@ fun ProfileScreen(
state = latestUi.copy( state = latestUi.copy(
profile = null, profile = null,
isLoading = false, isLoading = false,
error = "Unable to load profile" error = ProfileLoadError.Generic
) )
return@onSuccess return@onSuccess
} }
@@ -154,7 +176,8 @@ fun ProfileScreen(
val fallback = fallbackId?.let { ProfileCache.get(it) } val fallback = fallbackId?.let { ProfileCache.get(it) }
state = latestUi.copy( state = latestUi.copy(
error = if (latestUi.profile == null && fallback == null) { 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 { } else {
null null
}, },
@@ -171,7 +194,7 @@ fun ProfileScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
title = { Text("Profile") }, title = { Text(profileTitle) },
navigationIcon = { navigationIcon = {
if (!hideBackButton) { if (!hideBackButton) {
Box( Box(
@@ -182,7 +205,7 @@ fun ProfileScreen(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = cdBack,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
@@ -195,7 +218,7 @@ fun ProfileScreen(
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
val errorMessage = state.error val loadError = state.error
val profile = state.profile val profile = state.profile
val displayName = profile?.displayName?.takeIf { it.isNotBlank() } val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
?: profile?.username?.takeIf { it.isNotBlank() } ?: profile?.username?.takeIf { it.isNotBlank() }
@@ -280,9 +303,13 @@ fun ProfileScreen(
state.isLoading -> { state.isLoading -> {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) 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(
text = errorMessage, text = errText,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 24.dp) modifier = Modifier.padding(top = 24.dp)
) )
@@ -301,7 +328,8 @@ fun ProfileScreen(
} }
val scope = rememberCoroutineScope() 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 val isOwnProfile = ApiClient.user?.id == profile.id
Row( Row(
@@ -336,7 +364,7 @@ fun ProfileScreen(
val primarySource = remember { MutableInteractionSource() } val primarySource = remember { MutableInteractionSource() }
val primaryClick: () -> Unit val primaryClick: () -> Unit
val primaryIcon = if (isOwnProfile) Icons.Filled.Settings else Icons.AutoMirrored.Filled.Chat 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) { primaryClick = if (isOwnProfile) {
onOpenSettings onOpenSettings
@@ -404,7 +432,7 @@ fun ProfileScreen(
indication = LocalIndication.current, indication = LocalIndication.current,
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(profileLink)) clipboardManager.setText(AnnotatedString(profileLink))
state = state.copy(linkStatus = "Link copied!") linkCopied = true
} }
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -420,7 +448,7 @@ fun ProfileScreen(
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
text = "Link", text = labelLink,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -428,10 +456,10 @@ fun ProfileScreen(
} }
} }
state.linkStatus?.let { status -> if (linkCopied) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = status, text = linkCopiedText,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -450,10 +478,10 @@ fun ProfileScreen(
showDetailsBio || showDetailsBio ||
showDetailsVerify showDetailsVerify
) { ) {
Category(Modifier.padding(top = 16.dp), title = "Details") { Category(Modifier.padding(top = 16.dp), title = detailsTitle) {
if (showDetailsUsername) { if (showDetailsUsername) {
ListItem( ListItem(
headline = "Username", headline = headlineUsername,
supportingText = profile.username, supportingText = profile.username,
divider = true, divider = true,
dividerColor = CategoryDefaults.dividerColor, dividerColor = CategoryDefaults.dividerColor,
@@ -472,7 +500,7 @@ fun ProfileScreen(
} }
if (showDetailsMemberSince) { if (showDetailsMemberSince) {
ListItem( ListItem(
headline = "Member since", headline = headlineMemberSince,
supportingText = profile.createdAt, supportingText = profile.createdAt,
divider = true, divider = true,
dividerColor = CategoryDefaults.dividerColor, dividerColor = CategoryDefaults.dividerColor,
@@ -491,7 +519,7 @@ fun ProfileScreen(
} }
if (showDetailsBio) { if (showDetailsBio) {
ListItem( ListItem(
headline = "Bio", headline = headlineBio,
supportingText = profile.bio, supportingText = profile.bio,
divider = true, divider = true,
dividerColor = CategoryDefaults.dividerColor, dividerColor = CategoryDefaults.dividerColor,
@@ -522,7 +550,7 @@ fun ProfileScreen(
} }
ListItem( ListItem(
headline = "Verification", headline = headlineVerification,
supportingText = verificationLabel, supportingText = verificationLabel,
leadingContent = { leadingContent = {
Icon( Icon(
@@ -20,6 +20,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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.ApiClient
@Composable @Composable
@@ -30,6 +33,8 @@ fun StatusBadge(
size: Dp = 20.dp size: Dp = 20.dp
) { ) {
var isSimilarToVerified by remember(userId, verified) { mutableStateOf(false) } 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) { LaunchedEffect(verified, userId, ApiClient.token) {
if (verified == true) { if (verified == true) {
@@ -49,18 +54,18 @@ fun StatusBadge(
when { when {
verified == true -> Icon( verified == true -> Icon(
imageVector = Icons.Filled.Verified, imageVector = Icons.Filled.Verified,
contentDescription = "Verified account", contentDescription = cdVerified,
modifier = modifier modifier = modifier
.size(size) .size(size)
.semantics { contentDescription = "Verified account" }, .semantics { contentDescription = cdVerified },
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
isSimilarToVerified -> Icon( isSimilarToVerified -> Icon(
imageVector = Icons.Filled.Warning, imageVector = Icons.Filled.Warning,
contentDescription = "Similar to verified account", contentDescription = cdSimilar,
modifier = modifier modifier = modifier
.size(size) .size(size)
.semantics { contentDescription = "Similar to verified account" }, .semantics { contentDescription = cdSimilar },
tint = Color(0xFFFFA000) tint = Color(0xFFFFA000)
) )
else -> {} else -> {}
@@ -13,7 +13,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import ru.fromchat.Res
import ru.fromchat.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
@@ -29,6 +32,8 @@ fun VerifyButton(
var isVerifying by remember { mutableStateOf(false) } var isVerifying by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val labelVerify = stringResource(Res.string.verify)
val labelUnverify = stringResource(Res.string.unverify)
Button( Button(
onClick = { onClick = {
@@ -53,7 +58,7 @@ fun VerifyButton(
) )
} else { } else {
Text( Text(
text = if (verified) "Unverify" else "Verify" text = if (verified) labelUnverify else labelVerify
) )
} }
} }
@@ -43,16 +43,11 @@ import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.back
import ru.fromchat.core.ServerConfigData import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.config.Config 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 import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -62,7 +57,7 @@ fun ServerConfigScreen() {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
// Load existing config if available // Load existing config if available
var serverUrl by remember { mutableStateOf("fromchat.ru") } var serverUrl by remember { mutableStateOf("") }
var httpsEnabled by remember { mutableStateOf(true) } var httpsEnabled by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -123,7 +118,7 @@ fun ServerConfigScreen() {
value = serverUrl, value = serverUrl,
onValueChange = { serverUrl = it }, onValueChange = { serverUrl = it },
label = { Text(stringResource(Res.string.server_url_label)) }, label = { Text(stringResource(Res.string.server_url_label)) },
placeholder = { Text("fromchat.ru") }, placeholder = { Text(stringResource(Res.string.server_url_hint)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true singleLine = true
) )
@@ -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)
}
}
}
@@ -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"
}
}
}
@@ -1,291 +1,8 @@
package ru.fromchat.ui.debug 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.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 @Composable
actual fun DebugApiScreen() { actual fun DebugApiScreen() {
val navController = LocalNavController.current DebugApiScreenContent()
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var statusMessage by rememberSaveable { mutableStateOf("") }
var profileResult by rememberSaveable { mutableStateOf<String?>(null) }
var conversationsResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyUserId by rememberSaveable { mutableStateOf("0") }
var decryptedMessage by rememberSaveable { mutableStateOf<String?>(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()
)
}
}
} }