mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Translate to Russian
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- App -->
|
||||
<string name="app_name">FromChat</string>
|
||||
@@ -6,7 +7,7 @@
|
||||
<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>
|
||||
@@ -15,69 +16,139 @@
|
||||
|
||||
<!-- 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 don’t 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>
|
||||
<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">Can’t 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">Couldn’t 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">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_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 HTTPS</string>
|
||||
<string name="save_continue">Save & Continue</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="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">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="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">Couldn’t 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>
|
||||
@@ -85,4 +156,5 @@
|
||||
<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" -> {
|
||||
val st = MaterialTheme.typography.bodySmall
|
||||
val col = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Updating...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
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,
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user