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
`app/android/build/outputs/apk/debug/android-debug.apk`
in this repo.
6. For **each** same device: **`mobile_launch_app`** with `packageName` **`ru.fromchat.beta`** (debug `applicationIdSuffix`).
6. For **each** same device: **`mobile_launch_app`** with `packageName` **`ru.fromchat.beta`**. The debug APK from `assembleDebug` uses `applicationIdSuffix = ".beta"` (`app/android/build.gradle.kts`), so the on-device package is **`ru.fromchat.beta`**, not `ru.fromchat`—`ru.fromchat` will fail to launch after a debug install. (Release / no-suffix id is `ru.fromchat`.)
Also run **`:app:shared:compileKotlinIosArm64`** (and fix errors) when shared Kotlin changes should stay valid for iOS—either with the same Gradle invocation as in `general.mdc` or right after the Android APK build.
+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 completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected (see that rule for APK path and `ru.fromchat.beta`).
- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected. For **`mobile_launch_app`** after a debug install, use package **`ru.fromchat.beta`** (not `ru.fromchat`); details in that rule.
# ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
+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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.Button
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.crypto.Base64
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.SendDmFile
import ru.fromchat.crypto.IdentityKeyManager
import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.crypto.transport.TransportCrypto
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
actual fun DebugApiScreen() {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var statusMessage by rememberSaveable { mutableStateOf("") }
var profileResult by rememberSaveable { mutableStateOf<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()
)
}
}
DebugApiScreenContent()
}
@@ -1,14 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<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_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string>
<string name="about_link_website">Сайт</string>
<string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string>
<!-- Typing Indicators -->
<string name="welcome">Добро пожаловать!</string>
<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_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>
@@ -1,88 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- App -->
<string name="app_name">FromChat</string>
<!-- Navigation -->
<string name="back">Back</string>
<string name="settings">Settings</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_link_telegram">Telegram</string>
<string name="about_link_max">MAX</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>
<!-- Authentication -->
<string name="welcome">Welcome!</string>
<string name="login">Sign In</string>
<string name="login_d">Sign in to your account</string>
<string name="register">Registration</string>
<string name="login">Log in</string>
<string name="login_d">Log in to your account</string>
<string name="register">Sign up</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="password">Password</string>
<string name="confirm_password">Confirm Password</string>
<string name="display_name">Display Name</string>
<string name="display_name_error">Display name must be between 1 and 64 characters</string>
<string name="confirm_password">Password again</string>
<string name="display_name">Name shown to others</string>
<string name="display_name_error">Use between 1 and 64 characters</string>
<!-- Validation Errors -->
<string name="fill_all_fields">Please fill in all fields</string>
<string name="username_length_error">Username must be between 3 and 20 characters</string>
<string name="password_length_error">Password must be between 5 and 50 characters</string>
<string name="passwords_dont_match">Passwords do not match</string>
<string name="fill_all_fields">Please fill in every field</string>
<string name="username_length_error">Username must be 3 to 20 characters</string>
<string name="password_length_error">Password must be 5 to 50 characters</string>
<string name="passwords_dont_match">The two passwords dont match</string>
<!-- Main Screen -->
<string name="chats">Chats</string>
<string name="contacts">Contacts</string>
<string name="dms">Direct Messages</string>
<string name="coming_soon">Coming soon...</string>
<string name="public_chat">General Chat</string>
<string name="chat_last_mesaage">You: Last message</string>
<string name="message_placeholder">Type a message...</string>
<!-- Server Configuration -->
<string name="server_config_title">Server Configuration</string>
<string name="server_config_subtitle">Enter server details to connect</string>
<string name="server_url_label">Server URL</string>
<string name="server_url_hint">example.com</string>
<string name="https_enabled">Use HTTPS</string>
<string name="save_continue">Save &amp; Continue</string>
<string name="profile">Profile</string>
<string name="dms">Private chats</string>
<string name="coming_soon">Coming soon…</string>
<string name="public_chat">Main chat</string>
<string name="chat_last_mesaage">You: last message</string>
<string name="message_placeholder">Write a message…</string>
<!-- Connection / chat chrome -->
<string name="status_connecting">Connecting</string>
<string name="status_updating">Updating</string>
<string name="chat_group_label">Group</string>
<string name="chat_members_count">%1$d people</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 -->
<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="logout">Logout</string>
<string name="materialYou">Enable Material You theming</string>
<string name="materialYou_d">Enable the dynamic theme based on your wallpaper. Works only on Android 12 or higher.</string>
<string name="theme">Theme</string>
<string name="as_system">As system</string>
<string name="change_server">Change server</string>
<string name="change_server_d">Connect to an alternative FromChat server and sign out.</string>
<string name="logout">Log out</string>
<string name="materialYou">Material You</string>
<string name="materialYou_d">Match colors to your wallpaper. Works on Android 12 and up.</string>
<string name="theme">Look</string>
<string name="as_system">Same as phone</string>
<string name="light">Light</string>
<string name="dark">Dark</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_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 -->
<string name="error_unexpected">Unexpected error</string>
<string name="error_invalid_credentials">Invalid username or password</string>
<string name="error_connection">Connection error</string>
<string name="error_unknown">An unknown error occurred</string>
<string name="error_unexpected">Something went wrong</string>
<string name="error_invalid_credentials">Wrong username or password</string>
<string name="error_connection">Couldnt connect. Check your internet.</string>
<string name="error_unknown">Something went wrong. Please try again.</string>
<!-- Typing Indicators -->
<string name="typing_single">%1$s is 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="more">More</string>
<string name="unread_count">+%1$d</string>
</resources>
@@ -5,7 +5,9 @@ import ru.fromchat.api.DmEnvelope
import ru.fromchat.crypto.dm.DmCrypto
/** Shown in the UI when [DmCiphertextCorruptedException] is caught while decrypting a DM. */
const val CorruptedDmMessagePlaceholder = "This message is corrupted and can't be displayed."
/** Must match [ru.fromchat.Res.string.message_corrupted] (Compose resources). */
const val CorruptedDmMessagePlaceholder =
"Сообщение повреждено и не может быть отображено."
/**
* Unwrap a MEK (Message Encryption Key) using the appropriate wrapping key
@@ -70,7 +70,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.Message
import ru.fromchat.message_placeholder
import ru.fromchat.*
@Composable
private fun <T> AnimatedPreviewBar(
@@ -101,6 +101,7 @@ private fun PreviewBar(
icon: ImageVector,
title: String,
subtitle: String,
closeContentDescription: String,
onClose: () -> Unit
) {
Row(
@@ -137,7 +138,7 @@ private fun PreviewBar(
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
contentDescription = closeContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -148,6 +149,7 @@ private fun PreviewBar(
private fun AttachmentChip(
attachment: SelectedAttachment,
onRemove: () -> Unit,
removeContentDescription: String,
modifier: Modifier = Modifier
) {
Row(
@@ -186,7 +188,7 @@ private fun AttachmentChip(
IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove",
contentDescription = removeContentDescription,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -206,7 +208,8 @@ fun ChatInput(
onClearReply: () -> Unit,
onClearEdit: () -> Unit,
hazeState: HazeState,
recipientId: Int? = null
recipientId: Int? = null,
currentUserId: Int? = null
) {
val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
@@ -251,6 +254,13 @@ fun ChatInput(
}
val canSend = text.isNotBlank() || attachments.isNotEmpty()
val cdClose = stringResource(Res.string.cd_close)
val cdRemove = stringResource(Res.string.cd_remove)
val cdPickImage = stringResource(Res.string.cd_pick_image)
val cdPickFile = stringResource(Res.string.cd_pick_file)
val cdSend = stringResource(Res.string.cd_send)
val corruptedShort = stringResource(Res.string.message_corrupted_short)
val editingTitle = stringResource(Res.string.message_editing_title)
Box(
modifier = Modifier
@@ -277,28 +287,31 @@ fun ChatInput(
) {
AnimatedPreviewBar(replyTo) { replyTo ->
val replySubtitle = if (replyTo.isContentCorrupted) {
"Corrupted message"
corruptedShort
} else {
replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else ""
}
val replyName = messageDisplayUsername(replyTo, currentUserId)
PreviewBar(
icon = Icons.AutoMirrored.Filled.Reply,
title = "Replying to ${replyTo.username}",
title = stringResource(Res.string.message_replying_to, replyName),
subtitle = replySubtitle,
closeContentDescription = cdClose,
onClose = { onClearReply() }
)
}
AnimatedPreviewBar(editingMessage) { message ->
val subtitle = if (message.isContentCorrupted) {
"Corrupted message"
corruptedShort
} else {
message.content.take(50) + if (message.content.length > 50) "..." else ""
}
PreviewBar(
icon = Icons.Filled.Edit,
title = "Editing message",
title = editingTitle,
subtitle = subtitle,
closeContentDescription = cdClose,
onClose = { onClearEdit() }
)
}
@@ -318,7 +331,8 @@ fun ChatInput(
attachments.forEach { attachment ->
AttachmentChip(
attachment = attachment,
onRemove = { attachments = attachments.filter { it.id != attachment.id } }
onRemove = { attachments = attachments.filter { it.id != attachment.id } },
removeContentDescription = cdRemove
)
}
}
@@ -332,14 +346,14 @@ fun ChatInput(
IconButton(onClick = { launchImagePicker() }) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Pick image",
contentDescription = cdPickImage,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { launchFilePicker() }) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "Pick file",
contentDescription = cdPickFile,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -397,7 +411,7 @@ fun ChatInput(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
contentDescription = cdSend,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
@@ -74,6 +74,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.AttachmentUploadJob
@@ -85,7 +86,6 @@ import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis
@@ -95,6 +95,7 @@ import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.chat.getImageAspectRatio
import ru.fromchat.ui.scaleOnPress
import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.rememberLastSeenFormatStrings
import kotlin.time.Clock
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -144,6 +145,11 @@ fun ChatScreen(
val statusMap by UserStatusStore.status.collectAsState()
val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val lastSeenFormat = rememberLastSeenFormatStrings()
val statusConnecting = stringResource(Res.string.status_connecting)
val statusUpdating = stringResource(Res.string.status_updating)
val chatGroupLabel = stringResource(Res.string.chat_group_label)
val cdCall = stringResource(Res.string.cd_call)
LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
@@ -413,7 +419,7 @@ fun ChatScreen(
panelState.profileUserId != null -> {
val userStatus = statusMap[panelState.profileUserId]
val statusText = userStatus?.let {
formatLastSeen(it.online, it.lastSeen)
formatLastSeen(it.online, it.lastSeen, lastSeenFormat)
}.orEmpty()
if (statusText.isNotEmpty()) {
"presence:$statusText"
@@ -434,12 +440,23 @@ fun ChatScreen(
) { key ->
when {
key == "updating" -> {
Text(
text = "Updating...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
val st = MaterialTheme.typography.bodySmall
val col = MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = statusUpdating,
style = st,
color = col
)
ConnectingEllipsis(
fontSize = st.fontSize,
color = col,
baseStyle = st
)
}
}
key == "connecting" -> {
val st = MaterialTheme.typography.bodySmall
@@ -449,7 +466,7 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Connecting",
text = statusConnecting,
style = st,
color = col
)
@@ -477,7 +494,7 @@ fun ChatScreen(
}
key == "group" -> {
Text(
text = "group",
text = chatGroupLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
@@ -486,7 +503,7 @@ fun ChatScreen(
key.startsWith("members:") -> {
val n = key.removePrefix("members:").toIntOrNull() ?: 0
Text(
text = "$n members",
text = stringResource(Res.string.chat_members_count, n),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
@@ -513,7 +530,7 @@ fun ChatScreen(
IconButton(onClick = { /* TODO: Handle call */ }) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Call"
contentDescription = cdCall
)
}
}
@@ -547,6 +564,7 @@ fun ChatScreen(
ChatInput(
text = inputText,
onTextChange = { inputText = it },
currentUserId = currentUserId,
onSend = { text, attachments ->
if (editingMessage != null) {
scope.launch {
@@ -67,8 +67,11 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message
import ru.fromchat.*
import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.LocalSystemBarsVisibility
import kotlin.math.abs
@@ -121,6 +124,12 @@ fun ImageFullscreenPreview(
thumbnailBounds: Rect? = null
) {
val file = message.files?.getOrNull(fileIndex) ?: return
val cdBack = stringResource(Res.string.back)
val cdMenu = stringResource(Res.string.more)
val labelReply = stringResource(Res.string.action_reply)
val labelSave = stringResource(Res.string.action_save)
val labelDelete = stringResource(Res.string.action_delete)
val headerName = messageDisplayUsername(message, currentUserId)
val envelope = message.dmEnvelope
val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex)
@@ -596,7 +605,7 @@ fun ImageFullscreenPreview(
IconButton(onClick = { dismissRequested = true }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = cdBack,
tint = Color.White
)
}
@@ -605,7 +614,7 @@ fun ImageFullscreenPreview(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = message.username,
text = headerName,
style = MaterialTheme.typography.titleMedium,
color = Color.White
)
@@ -620,7 +629,7 @@ fun ImageFullscreenPreview(
IconButton(onClick = { menuExpanded = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu",
contentDescription = cdMenu,
tint = Color.White
)
}
@@ -634,7 +643,7 @@ fun ImageFullscreenPreview(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.AutoMirrored.Filled.Reply, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Reply", color = Color.White)
Text(labelReply, color = Color.White)
}
},
onClick = {
@@ -648,7 +657,7 @@ fun ImageFullscreenPreview(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.SaveAlt, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Save", color = Color.White)
Text(labelSave, color = Color.White)
}
},
onClick = {
@@ -661,7 +670,7 @@ fun ImageFullscreenPreview(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Delete, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Delete", color = Color.White)
Text(labelDelete, color = Color.White)
}
},
onClick = {
@@ -46,7 +46,10 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.Message
import ru.fromchat.*
import ru.fromchat.ui.scaleOnPress
data class ContextMenuState(
@@ -257,6 +260,9 @@ private fun ContextMenuContent(
val menuColor = MaterialTheme.colorScheme.surfaceContainer
val edgePadding = 8.dp
val itemSpacing = 2.dp
val labelReply = stringResource(Res.string.action_reply)
val labelEdit = stringResource(Res.string.action_edit)
val labelDelete = stringResource(Res.string.action_delete)
Box(modifier = containerModifier) {
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
@@ -268,20 +274,20 @@ private fun ContextMenuContent(
) {
ContextMenuItem(
icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply",
text = labelReply,
onClick = { onReply(message) }
)
if (isAuthor) {
ContextMenuItem(
icon = Icons.Default.Edit,
text = "Edit",
text = labelEdit,
onClick = { onEdit(message) }
)
}
if (isAuthor) {
ContextMenuItem(
icon = Icons.Default.Delete,
text = "Delete",
text = labelDelete,
onClick = { onDelete(message) },
isError = true
)
@@ -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 kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.Message
import ru.fromchat.*
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@@ -95,6 +98,10 @@ fun MessageItem(
val formattedTime = remember(message.timestamp) {
formatTime(message.timestamp)
}
val corruptedBody = stringResource(Res.string.message_corrupted)
val editedSuffix = stringResource(Res.string.message_edited_suffix)
val displayUsername = messageDisplayUsername(message, currentUserId)
val replyRef = message.reply_to
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
// composition (N messages ⇒ N concurrent animations + huge JIT), causing main-thread jank.
@@ -316,7 +323,7 @@ fun MessageItem(
)
) {
Text(
text = message.username,
text = displayUsername,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
@@ -326,7 +333,7 @@ fun MessageItem(
} else {
Box(modifier = usernameOutset) {
Text(
text = message.username,
text = displayUsername,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
@@ -348,7 +355,8 @@ fun MessageItem(
) {
Column {
// Reply preview
message.reply_to?.let { replyTo ->
replyRef?.let { replyToMsg ->
val replyName = messageDisplayUsername(replyToMsg, currentUserId)
Box(
Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
) {
@@ -379,7 +387,7 @@ fun MessageItem(
) {
if (showUsername) {
Text(
text = replyTo.username,
text = replyName,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
@@ -387,7 +395,7 @@ fun MessageItem(
)
}
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
text = replyToMsg.content.take(50) + if (replyToMsg.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
@@ -401,7 +409,7 @@ fun MessageItem(
// Attachments (images/files) or corrupted message
if (isCorrupted) {
Text(
text = "_This message is corrupted and cannot be displayed.",
text = corruptedBody,
style = MaterialTheme.typography.bodyMedium,
color = if (isAuthor) {
Color.White.copy(alpha = 0.8f)
@@ -553,7 +561,7 @@ fun MessageItem(
if (message.is_edited) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "(edited)",
text = editedSuffix,
style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp,
color = if (isAuthor) {
@@ -18,11 +18,14 @@ import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.core.Logger
class PublicChatPanel(
chatName: String,
/** Stable cache / panel id (not localized; hardcoded in [PublicChatPanelCache]). */
panelKey: String,
/** Shown in the app bar and avatars. */
displayTitle: String,
currentUserId: Int?,
scope: CoroutineScope
) : ChatPanel(
id = "public-$chatName",
id = "public-$panelKey",
currentUserId = currentUserId,
scope = scope
) {
@@ -57,8 +60,8 @@ class PublicChatPanel(
init {
updateState {
it.copy(
title = chatName,
titleAvatar = AvatarInfo(displayName = chatName, profilePictureUrl = null),
title = displayTitle,
titleAvatar = AvatarInfo(displayName = displayTitle, profilePictureUrl = null),
publicGroupMetaLoading = true,
publicGroupMemberCount = null
)
@@ -82,6 +85,19 @@ class PublicChatPanel(
}
}
/** When locale changes, keep the same panel but refresh the visible title. */
fun applyDisplayTitle(title: String) {
updateState { s ->
s.copy(
title = title,
titleAvatar = AvatarInfo(
displayName = title,
profilePictureUrl = s.titleAvatar?.profilePictureUrl
)
)
}
}
private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
updateMessage(reactionUpdate.message_id) { message ->
message.copy(reactions = reactionUpdate.reactions)
@@ -3,6 +3,7 @@ package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* Single retained [PublicChatPanel] for the app session (same idea as [ru.fromchat.ui.dm.DmPanelCache]).
* Navigating away from public chat used to dispose [remember] and recreate the panel, so every open
@@ -13,13 +14,14 @@ import kotlinx.coroutines.SupervisorJob
* collectors and [ChatPanel] state callbacks keep working after the composable is left and re-entered.
*/
object PublicChatPanelCache {
private const val GENERAL_CHAT_NAME = "General Chat"
private const val GeneralPublicPanelKey = "general"
private var supervisorJob = SupervisorJob()
private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate)
private var panel: PublicChatPanel? = null
private var cachedChatName: String? = null
private var cachedPanelKey: String? = null
private var cachedDisplayTitle: String? = null
private var cachedUserId: Int? = null
private fun ensureScope() {
@@ -29,25 +31,31 @@ object PublicChatPanelCache {
}
}
fun getOrCreateGeneralChat(currentUserId: Int?): PublicChatPanel =
getOrCreate(GENERAL_CHAT_NAME, currentUserId)
fun getOrCreate(chatName: String, currentUserId: Int?): PublicChatPanel {
/**
* @param displayTitle Localized title from e.g. [ru.fromchat.Res.string.public_chat].
*/
fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel {
ensureScope()
if (
panel != null &&
cachedChatName == chatName &&
cachedPanelKey == GeneralPublicPanelKey &&
cachedUserId == currentUserId
) {
if (cachedDisplayTitle != displayTitle) {
cachedDisplayTitle = displayTitle
panel!!.applyDisplayTitle(displayTitle)
}
return panel!!
}
panel?.destroy()
panel = PublicChatPanel(
chatName = chatName,
panelKey = GeneralPublicPanelKey,
displayTitle = displayTitle,
currentUserId = currentUserId,
scope = panelScope
)
cachedChatName = chatName
cachedPanelKey = GeneralPublicPanelKey
cachedDisplayTitle = displayTitle
cachedUserId = currentUserId
return panel!!
}
@@ -55,7 +63,8 @@ object PublicChatPanelCache {
fun clear() {
panel?.destroy()
panel = null
cachedChatName = null
cachedPanelKey = null
cachedDisplayTitle = null
cachedUserId = null
supervisorJob.cancel()
}
@@ -6,8 +6,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.ui.isPublicChatVisible
import ru.fromchat.*
@Composable
fun PublicChatScreen(
@@ -16,10 +19,11 @@ fun PublicChatScreen(
animatedContentScope: AnimatedContentScope? = null
) {
val currentUserId = ApiClient.user?.id
val publicChatTitle = stringResource(Res.string.public_chat)
// Reuse one panel for the session (like DM [DmPanelCache]); avoids full reload on every visit.
val panel = remember(currentUserId) {
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
val panel = remember(currentUserId, publicChatTitle) {
PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId)
}
LaunchedEffect(panel) {
@@ -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 org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ConnectionStateStore
import ru.fromchat.api.ConnectionStatus
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.db.CachedConversation
import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.chat_last_mesaage
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.public_chat
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.branding.FromChatBrandTitle
@@ -119,6 +118,10 @@ fun ChatsTab() {
else -> "fromchat"
}
val connectingTitle = stringResource(Res.string.status_connecting)
val updatingTitle = stringResource(Res.string.status_updating)
val brandTitle = stringResource(Res.string.app_name)
Scaffold(
topBar = {
TopAppBar(
@@ -162,7 +165,7 @@ fun ChatsTab() {
horizontalArrangement = Arrangement.Start
) {
Text(
text = "Connecting",
text = connectingTitle,
style = style,
color = color,
maxLines = 1,
@@ -176,16 +179,29 @@ fun ChatsTab() {
}
}
"updating" -> {
Text(
val style = MaterialTheme.typography.titleLarge
val color = MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier.fillMaxWidth(),
text = "Updating...",
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = updatingTitle,
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
ConnectingEllipsis(
fontSize = style.fontSize,
color = color,
baseStyle = style
)
}
}
else -> {
FromChatBrandTitle(text = "FromChat")
FromChatBrandTitle(text = brandTitle)
}
}
}
@@ -233,7 +249,9 @@ fun ChatsTab() {
val avatarUrl = cached?.profilePicture
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() }
?: conv.displayName.ifBlank { "User ${conv.otherUserId}" }
?: conv.displayName.ifBlank {
stringResource(Res.string.user_fallback, conv.otherUserId)
}
val preview = conv.lastMessagePreview?.trim().orEmpty()
ListItem(
leadingContent = {
@@ -264,7 +282,7 @@ fun ChatsTab() {
},
trailingContent = {
if (conv.unreadCount > 0) {
Text("+${conv.unreadCount}")
Text(stringResource(Res.string.unread_count, conv.unreadCount))
}
},
modifier = Modifier.clickable {
@@ -25,11 +25,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.chats
import ru.fromchat.coming_soon
import ru.fromchat.contacts
import ru.fromchat.settings
import ru.fromchat.utils.exclude
import ru.fromchat.ui.profile.ProfileScreen
@@ -62,7 +59,7 @@ fun MainScreen(onLogout: () -> Unit = {}) {
NavigationBarItem(
selected = selectedTab == "profile",
onClick = { selectedTab = "profile" },
label = { Text("Profile") },
label = { Text(stringResource(Res.string.profile)) },
icon = { Icon(Icons.Filled.Person, contentDescription = null) }
)
}
@@ -48,6 +48,7 @@ import com.pr0gramm3r101.utils.materialYouAvailable
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.about
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
@@ -203,8 +204,8 @@ fun SettingsTab(
)
ListItem(
headline = "Debug API",
supportingText = "Inspect profile and DM endpoints used by the client.",
headline = stringResource(Res.string.debug_tools),
supportingText = stringResource(Res.string.debug_tools_d),
onClick = {
navController.navigate("debug")
},
@@ -216,31 +217,6 @@ fun SettingsTab(
}
)
val currentUserId = ApiClient.user?.id ?: 0
ListItem(
headline = "Profile screen",
supportingText = "Show your profile with chat/link actions",
onClick = {
if (currentUserId != 0) {
navController.navigate("profile/$currentUserId")
}
},
divider = true,
dividerColor = MaterialTheme.colorScheme.surface,
dividerThickness = 2.dp
)
ListItem(
headline = "DM screen (user 2)",
supportingText = "Open DM flow with another user",
onClick = {
navController.navigate("dm/2")
},
divider = true,
dividerColor = MaterialTheme.colorScheme.surface,
dividerThickness = 2.dp
)
ListItem(
headline = stringResource(Res.string.logout),
leadingContent = {
@@ -68,6 +68,9 @@ import com.pr0gramm3r101.components.ListItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile
@@ -76,11 +79,15 @@ import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey
import ru.fromchat.ui.scaleOnPress
private sealed interface ProfileLoadError {
data object Generic : ProfileLoadError
data class Message(val text: String) : ProfileLoadError
}
private data class ProfileUiState(
val profile: UserProfile? = null,
val isLoading: Boolean = true,
val error: String? = null,
val linkStatus: String? = null
val error: ProfileLoadError? = null
)
private val profileActionCardPressSpring = spring<Float>(
@@ -109,6 +116,21 @@ fun ProfileScreen(
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val navController = LocalNavController.current
var linkCopied by remember { mutableStateOf(false) }
val profileTitle = stringResource(Res.string.profile_title)
val cdBack = stringResource(Res.string.back)
val profileLoadFailed = stringResource(Res.string.profile_load_failed)
val labelSettings = stringResource(Res.string.action_open_settings)
val labelChat = stringResource(Res.string.action_chat)
val labelLink = stringResource(Res.string.action_copy_link)
val linkCopiedText = stringResource(Res.string.link_copied)
val detailsTitle = stringResource(Res.string.profile_details_category)
val headlineUsername = stringResource(Res.string.profile_headline_username)
val headlineMemberSince = stringResource(Res.string.profile_headline_member_since)
val headlineBio = stringResource(Res.string.profile_headline_bio)
val headlineVerification = stringResource(Res.string.profile_headline_verification)
val verifiedSupport = stringResource(Res.string.profile_verified_support)
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
val hideBackButton = navController.currentDestination?.route == "chat"
val targetUserId = userId.takeIf { it != null && it > 0 }
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
@@ -142,7 +164,7 @@ fun ProfileScreen(
state = latestUi.copy(
profile = null,
isLoading = false,
error = "Unable to load profile"
error = ProfileLoadError.Generic
)
return@onSuccess
}
@@ -154,7 +176,8 @@ fun ProfileScreen(
val fallback = fallbackId?.let { ProfileCache.get(it) }
state = latestUi.copy(
error = if (latestUi.profile == null && fallback == null) {
err.message ?: "Unable to load profile"
err.message?.takeIf { it.isNotBlank() }?.let { ProfileLoadError.Message(it) }
?: ProfileLoadError.Generic
} else {
null
},
@@ -171,7 +194,7 @@ fun ProfileScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text("Profile") },
title = { Text(profileTitle) },
navigationIcon = {
if (!hideBackButton) {
Box(
@@ -182,7 +205,7 @@ fun ProfileScreen(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = cdBack,
modifier = Modifier.size(24.dp)
)
}
@@ -195,7 +218,7 @@ fun ProfileScreen(
Box(
modifier = Modifier.fillMaxSize()
) {
val errorMessage = state.error
val loadError = state.error
val profile = state.profile
val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
?: profile?.username?.takeIf { it.isNotBlank() }
@@ -280,9 +303,13 @@ fun ProfileScreen(
state.isLoading -> {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
}
errorMessage != null -> {
loadError != null -> {
val errText = when (loadError) {
ProfileLoadError.Generic -> profileLoadFailed
is ProfileLoadError.Message -> loadError.text
}
Text(
text = errorMessage,
text = errText,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 24.dp)
)
@@ -301,7 +328,8 @@ fun ProfileScreen(
}
val scope = rememberCoroutineScope()
val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify"
val verificationLabel =
if (profile.verified == true) verifiedSupport else verifyPromptSupport
val isOwnProfile = ApiClient.user?.id == profile.id
Row(
@@ -336,7 +364,7 @@ fun ProfileScreen(
val primarySource = remember { MutableInteractionSource() }
val primaryClick: () -> Unit
val primaryIcon = if (isOwnProfile) Icons.Filled.Settings else Icons.AutoMirrored.Filled.Chat
val primaryLabel = if (isOwnProfile) "Settings" else "Chat"
val primaryLabel = if (isOwnProfile) labelSettings else labelChat
primaryClick = if (isOwnProfile) {
onOpenSettings
@@ -404,7 +432,7 @@ fun ProfileScreen(
indication = LocalIndication.current,
onClick = {
clipboardManager.setText(AnnotatedString(profileLink))
state = state.copy(linkStatus = "Link copied!")
linkCopied = true
}
),
contentAlignment = Alignment.Center
@@ -420,7 +448,7 @@ fun ProfileScreen(
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Link",
text = labelLink,
style = MaterialTheme.typography.bodyMedium
)
}
@@ -428,10 +456,10 @@ fun ProfileScreen(
}
}
state.linkStatus?.let { status ->
if (linkCopied) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = status,
text = linkCopiedText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@@ -450,10 +478,10 @@ fun ProfileScreen(
showDetailsBio ||
showDetailsVerify
) {
Category(Modifier.padding(top = 16.dp), title = "Details") {
Category(Modifier.padding(top = 16.dp), title = detailsTitle) {
if (showDetailsUsername) {
ListItem(
headline = "Username",
headline = headlineUsername,
supportingText = profile.username,
divider = true,
dividerColor = CategoryDefaults.dividerColor,
@@ -472,7 +500,7 @@ fun ProfileScreen(
}
if (showDetailsMemberSince) {
ListItem(
headline = "Member since",
headline = headlineMemberSince,
supportingText = profile.createdAt,
divider = true,
dividerColor = CategoryDefaults.dividerColor,
@@ -491,7 +519,7 @@ fun ProfileScreen(
}
if (showDetailsBio) {
ListItem(
headline = "Bio",
headline = headlineBio,
supportingText = profile.bio,
divider = true,
dividerColor = CategoryDefaults.dividerColor,
@@ -522,7 +550,7 @@ fun ProfileScreen(
}
ListItem(
headline = "Verification",
headline = headlineVerification,
supportingText = verificationLabel,
leadingContent = {
Icon(
@@ -20,6 +20,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient
@Composable
@@ -30,6 +33,8 @@ fun StatusBadge(
size: Dp = 20.dp
) {
var isSimilarToVerified by remember(userId, verified) { mutableStateOf(false) }
val cdVerified = stringResource(Res.string.cd_verified_account)
val cdSimilar = stringResource(Res.string.cd_similar_verified)
LaunchedEffect(verified, userId, ApiClient.token) {
if (verified == true) {
@@ -49,18 +54,18 @@ fun StatusBadge(
when {
verified == true -> Icon(
imageVector = Icons.Filled.Verified,
contentDescription = "Verified account",
contentDescription = cdVerified,
modifier = modifier
.size(size)
.semantics { contentDescription = "Verified account" },
.semantics { contentDescription = cdVerified },
tint = MaterialTheme.colorScheme.primary
)
isSimilarToVerified -> Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Similar to verified account",
contentDescription = cdSimilar,
modifier = modifier
.size(size)
.semantics { contentDescription = "Similar to verified account" },
.semantics { contentDescription = cdSimilar },
tint = Color(0xFFFFA000)
)
else -> {}
@@ -13,7 +13,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.Dispatchers
import ru.fromchat.Res
import ru.fromchat.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
@@ -29,6 +32,8 @@ fun VerifyButton(
var isVerifying by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val labelVerify = stringResource(Res.string.verify)
val labelUnverify = stringResource(Res.string.unverify)
Button(
onClick = {
@@ -53,7 +58,7 @@ fun VerifyButton(
)
} else {
Text(
text = if (verified) "Unverify" else "Verify"
text = if (verified) labelUnverify else labelVerify
)
}
}
@@ -43,16 +43,11 @@ import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.back
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.config.Config
import ru.fromchat.https_enabled
import ru.fromchat.save_continue
import ru.fromchat.server_config_subtitle
import ru.fromchat.server_config_title
import ru.fromchat.server_url_label
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class)
@@ -62,7 +57,7 @@ fun ServerConfigScreen() {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
// Load existing config if available
var serverUrl by remember { mutableStateOf("fromchat.ru") }
var serverUrl by remember { mutableStateOf("") }
var httpsEnabled by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
@@ -123,7 +118,7 @@ fun ServerConfigScreen() {
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text(stringResource(Res.string.server_url_label)) },
placeholder = { Text("fromchat.ru") },
placeholder = { Text(stringResource(Res.string.server_url_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
@@ -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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.Button
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.crypto.IdentityKeyManager
import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
actual fun DebugApiScreen() {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var statusMessage by rememberSaveable { mutableStateOf("") }
var profileResult by rememberSaveable { mutableStateOf<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()
)
}
}
DebugApiScreenContent()
}